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 décroché pendant sonnerie : `answer` automatique.
|
||||||
- Si raccroché pendant appel : `end/reject` 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
|
## 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).
|
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">
|
<div class="inline-actions">
|
||||||
<button id="btDiscoverableOnBtn">Discoverable ON</button>
|
<button id="btDiscoverableOnBtn">Discoverable ON</button>
|
||||||
<button id="btDiscoverableOffBtn">Discoverable OFF</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="btPbapSyncBtn">PBAP Sync</button>
|
||||||
<button id="btBleStartBtn">BLE Start</button>
|
<button id="btBleStartBtn">BLE Start</button>
|
||||||
<button id="btBleStopBtn">BLE Stop</button>
|
<button id="btBleStopBtn">BLE Stop</button>
|
||||||
|
|||||||
+47
-1
@@ -6,6 +6,7 @@ const SECTION_MAP = {
|
|||||||
};
|
};
|
||||||
let realtimeSource = null;
|
let realtimeSource = null;
|
||||||
let realtimeConnected = false;
|
let realtimeConnected = false;
|
||||||
|
let fallbackPollingTimer = null;
|
||||||
|
|
||||||
function showSection(section) {
|
function showSection(section) {
|
||||||
Object.values(SECTION_MAP).forEach((id) => {
|
Object.values(SECTION_MAP).forEach((id) => {
|
||||||
@@ -90,11 +91,12 @@ function applyStatusSnapshot(status) {
|
|||||||
const peers = status.espnow?.peer_count ?? 0;
|
const peers = status.espnow?.peer_count ?? 0;
|
||||||
const btCallState = status.bluetooth?.call_state || "n/a";
|
const btCallState = status.bluetooth?.call_state || "n/a";
|
||||||
const btConnected = status.bluetooth?.connected ? "on" : "off";
|
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 pbapSupported = status.bluetooth?.pbap_supported ? "yes" : "no";
|
||||||
const liveState = realtimeConnected ? "live=on" : "live=off";
|
const liveState = realtimeConnected ? "live=on" : "live=off";
|
||||||
line.textContent =
|
line.textContent =
|
||||||
`board=${status.board_profile || "n/a"} telephony=${telephonyState} hook=${hook} ` +
|
`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);
|
setJson("statusJson", status);
|
||||||
if (status.wifi) {
|
if (status.wifi) {
|
||||||
@@ -122,6 +124,10 @@ function connectRealtime() {
|
|||||||
|
|
||||||
realtimeSource = new EventSource("/api/events");
|
realtimeSource = new EventSource("/api/events");
|
||||||
|
|
||||||
|
realtimeSource.onopen = () => {
|
||||||
|
realtimeConnected = true;
|
||||||
|
};
|
||||||
|
|
||||||
realtimeSource.addEventListener("hello", () => {
|
realtimeSource.addEventListener("hello", () => {
|
||||||
realtimeConnected = true;
|
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() {
|
async function refreshStatus() {
|
||||||
try {
|
try {
|
||||||
const status = await requestJson("/api/status");
|
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 () => {
|
document.getElementById("btPbapSyncBtn").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const result = await requestJson("/api/bluetooth/pbap/sync", {
|
const result = await requestJson("/api/bluetooth/pbap/sync", {
|
||||||
@@ -672,6 +717,7 @@ function bindEvents() {
|
|||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
bindEvents();
|
bindEvents();
|
||||||
connectRealtime();
|
connectRealtime();
|
||||||
|
ensureFallbackPolling();
|
||||||
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork(), refreshBluetooth()]);
|
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork(), refreshBluetooth()]);
|
||||||
showSection("dashboard");
|
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)
|
## Todo
|
||||||
- Compatibilité ESP-NOW renforcée côté RTC:
|
- Validate hardware gate `incoming GSM -> RTC ring -> BT_ANSWER` on `/dev/cu.usbserial-0001`.
|
||||||
- commandes runtime `ESPNOW_ON` / `ESPNOW_OFF`
|
- Validate hardware gate `dial len10 (pulse)` with queued HFP reconnection then auto dial.
|
||||||
- extraction de commande bridge depuis payload JSON imbriqué (`event/message/payload`)
|
- Validate hardware gate `dial len10 (DTMF)` with no false pulse digits.
|
||||||
- Correctif mesures ESP-NOW:
|
- Re-run `ESP-NOW` 2-board validation when peer card is available.
|
||||||
- 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`
|
|
||||||
|
|
||||||
## Gates prioritaires
|
## In Progress
|
||||||
- Extension endpoints HTTP : audio, batterie, rtos, bluetooth, wifi
|
- Track `rtos-coex-wifi-bt`:
|
||||||
- Sécurisation endpoints : authentification, validation, gestion des droits
|
- endurance monitor 10 min sans `abort/assert`.
|
||||||
- Tests fonctionnels avancés : charge, robustesse, scénarios multi-utilisateurs
|
- Track `bt-hfp-stability`:
|
||||||
- Documentation endpoints et API : structure, exemples, onboarding
|
- validation E2E entrant GSM -> ring RTC -> décroché -> `BT_ANSWER`.
|
||||||
- CI : validation automatisée, reporting, traçabilité
|
- Track `webui-runtime`:
|
||||||
|
- valider charge SSE en AP fallback + STA connecté.
|
||||||
|
|
||||||
## Objectifs
|
## Blocked
|
||||||
- Couverture complète des modules audio, SLIC, téléphone, RTOS, Bluetooth, Wifi
|
- `PBAP` contact sync on Arduino ESP32 Bluedroid stack (`BT_PBAP_SYNC` remains `unsupported`).
|
||||||
- Robustesse et sécurité des interfaces web
|
- Full ESP-NOW E2E protocol proof while second board is unavailable.
|
||||||
- Traçabilité des artefacts, logs et verdicts
|
- Notion sync automation blocked until MCP Notion OAuth session is reconnected.
|
||||||
- Onboarding et documentation utilisateur/technique
|
|
||||||
|
|
||||||
## Actions à lancer
|
## Done
|
||||||
- Développement endpoints manquants
|
- Added HFP auto reconnect runtime control (`BT_AUTO_RECONNECT_ON/OFF`) and status flag.
|
||||||
- Implémentation sécurité et validation
|
- Preserved dial queue logic (`pending_dial_number`) with auto dial after SLC.
|
||||||
- Création/extension des tests fonctionnels
|
- Added `/api/bluetooth/hfp/auto` endpoint (GET status via `BT_STATUS`, POST toggle).
|
||||||
- Mise à jour documentation et scripts CI
|
- 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`
|
||||||
|
|
||||||
---
|
## Run Ledger
|
||||||
|
- `2026-02-22`:
|
||||||
**Version :** 2026-02-17
|
- 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 run -e esp32dev` : OK
|
||||||
- `platformio test --without-uploading --without-testing -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é)
|
- `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é)
|
- `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 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`).
|
- 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`).
|
- 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`.
|
- 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).
|
- 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`
|
## 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.
|
1. Conserver le suivi S3 Bluetooth/HFP hors scope parity V1.
|
||||||
2. Conserver le suivi S3 Bluetooth/HFP hors scope parity V1.
|
|
||||||
|
|
||||||
## 5) Risques actifs
|
## 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)
|
- 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`)
|
- 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
|
- 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
|
## État validé (run local USB `/dev/cu.usbserial-0001`)
|
||||||
- Instanciation, configuration codec OK
|
- Boot stable: audio + codec + web server OK.
|
||||||
- Aucun crash lors des appels de méthodes
|
- 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é
|
## Correctifs clés appliqués
|
||||||
- Méthodes stables, pas d’erreur détectée
|
- `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
|
## Plan exécutable (skills coordonnés)
|
||||||
- Tests hardware à approfondir
|
|
||||||
|
|
||||||
---
|
### Track 1 — `esp32-rtos-coex-triage` (en cours)
|
||||||
_Audit généré automatiquement._
|
- 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
|
```json
|
||||||
{
|
{
|
||||||
|
"proto": "rtcbl/1",
|
||||||
"msg_id": "req-001",
|
"msg_id": "req-001",
|
||||||
"seq": 1,
|
"seq": 1,
|
||||||
"type": "command",
|
"type": "command",
|
||||||
@@ -26,9 +27,10 @@ Normaliser les trames ESP-NOW pour:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Règles:
|
Règles:
|
||||||
|
- `proto=rtcbl/1` recommandé (toléré absent pour compat).
|
||||||
- `msg_id` sert à corréler la réponse.
|
- `msg_id` sert à corréler la réponse.
|
||||||
- `seq` est un compteur local de trame (recommandé monotone par source).
|
- `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.
|
- `ack=true` demande une réponse corrélée.
|
||||||
- `payload.cmd` obligatoire pour une commande dispatcher.
|
- `payload.cmd` obligatoire pour une commande dispatcher.
|
||||||
- `payload.args` optionnel; sérialisé puis passé au dispatcher.
|
- `payload.args` optionnel; sérialisé puis passé au dispatcher.
|
||||||
@@ -37,6 +39,7 @@ Règles:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"proto": "rtcbl/1",
|
||||||
"msg_id": "req-001",
|
"msg_id": "req-001",
|
||||||
"seq": 1,
|
"seq": 1,
|
||||||
"type": "ack",
|
"type": "ack",
|
||||||
|
|||||||
@@ -94,3 +94,52 @@
|
|||||||
- ESP-NOW:
|
- ESP-NOW:
|
||||||
- `ESPNOW_STATUS.ready=true`.
|
- `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.
|
- `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 |
|
| Fonction | ESP32 Audio Kit V2.2 Pin | Direction | Remarque |
|
||||||
|--------------------|--------------------------|-----------|-------------------------------------------|
|
|--------------------|--------------------------|-----------|-------------------------------------------|
|
||||||
| hookSense | GPIO36 (ADC1_CH0) | IN | Crochet (INPUT_PULLUP) |
|
| hookSense (SHK) | GPIO23 | IN | Crochet (INPUT_PULLUP, actif haut) |
|
||||||
| ringCmd | GPIO21 | OUT | Commande sonnerie (GPIO dispo) |
|
| ringCmd (RM) | GPIO18 | OUT | Commande sonnerie |
|
||||||
| lineEnable | GPIO19 | OUT | Activation ligne (GPIO dispo) |
|
| ringFreq (FR) | GPIO5 | OUT | Modulation sonnerie |
|
||||||
| led | GPIO22 | OUT | Debug LED (ou autre GPIO libre) |
|
| 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 BCK | GPIO27 | OUT | I2S0_BCK_OUT (vers ES8388, casque, HP) |
|
||||||
| I2S WS | GPIO25 | OUT | I2S0_WS_OUT (vers ES8388) |
|
| I2S WS | GPIO25 | OUT | I2S0_WS_OUT (vers ES8388) |
|
||||||
| I2S DIN | GPIO26 | OUT | I2S0_DO_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.
|
- **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.).
|
- 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.).
|
- GPIO34/36 restent disponibles pour entrées analogiques supplémentaires.
|
||||||
- GPIO21/19/22 sont libres sur la carte pour la logique RTC.
|
- 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.**
|
**À ajuster selon ton routage réel et la disponibilité des broches sur ta carte.**
|
||||||
## Options de câblage audio ESP32 <-> SLIC K50835F
|
## Options de câblage audio ESP32 <-> SLIC K50835F
|
||||||
@@ -171,10 +173,11 @@ void loop() {
|
|||||||
+-------------------+ +---------------------+
|
+-------------------+ +---------------------+
|
||||||
| ESP32 | | SLIC K50835F |
|
| ESP32 | | SLIC K50835F |
|
||||||
| | | |
|
| | | |
|
||||||
| GPIO4 <-------- |---HOOK--| > Hook sense |
|
| GPIO23 <-------- |---SHK---| > Hook sense |
|
||||||
| GPIO5 --------> |---RING--| < Ring control |
|
| GPIO18 --------> |---RM----| < Ring control |
|
||||||
| GPIO6 --------> |---LINE--| < Line enable |
|
| GPIO5 --------> |---FR----| < Ring modulation |
|
||||||
| GPIO48 --------> |---LED---| (debug, optionnel) |
|
| GPIO19 --------> |---PD----| < Power down ctrl |
|
||||||
|
| GPIO21 --------> |--AMP_EN-| (actif bas) |
|
||||||
| | | |
|
| | | |
|
||||||
| I2S_OUT ------+ | | +-- AUDIO_IN |
|
| I2S_OUT ------+ | | +-- AUDIO_IN |
|
||||||
| |--|---------|--| |
|
| |--|---------|--| |
|
||||||
@@ -183,7 +186,8 @@ void loop() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Explications :**
|
**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.
|
- 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.
|
- 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.
|
- 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)
|
## 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`).
|
- 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:
|
- Numérotation/contrôle appel via HFP AT: implémenté côté firmware:
|
||||||
- `BT_DIAL <number>` / alias `DIAL <number>`
|
- `BT_DIAL <number>` / alias `DIAL <number>`
|
||||||
- `BT_REDIAL`
|
- `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_CALLS` (query `AT+CLCC`)
|
||||||
- `BT_STATUS` expose maintenant `slc_connected`, `call_state`, `last_dialed_number`.
|
- `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`.
|
- 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
|
## 3. Périmètre
|
||||||
|
|
||||||
@@ -50,6 +57,7 @@ Out-of-scope v1:
|
|||||||
- `connected=true` après connexion RFCOMM/SLC,
|
- `connected=true` après connexion RFCOMM/SLC,
|
||||||
- `hfp_active=true` lorsque l'audio HFP est actif.
|
- `hfp_active=true` lorsque l'audio HFP est actif.
|
||||||
- Les commandes `BT_HFP_CONNECT` et `BT_HFP_DISCONNECT` DOIVENT piloter la session réelle.
|
- 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
|
### EF-02 — PBAP contacts
|
||||||
- Le firmware DOIT récupérer un sous-ensemble de contacts via PBAP.
|
- 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
|
### EF-03 — Numérotation
|
||||||
- Le firmware DOIT exposer une commande de numérotation sortante (ex: `DIAL <number>`).
|
- 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).
|
- Les transitions d'appel DOIVENT être visibles dans `STATUS`/`BT_STATUS` (idle, dialing, ringing, active, ended).
|
||||||
|
|
||||||
### EF-04 — Compatibilité pairing
|
### EF-04 — Compatibilité pairing
|
||||||
@@ -69,6 +80,12 @@ Out-of-scope v1:
|
|||||||
- Le flux de jumelage DOIT être documenté et testable depuis iPhone/Mac.
|
- 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.
|
- 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
|
## 5. Exigences non fonctionnelles
|
||||||
|
|
||||||
- Temps de connexion HFP cible: < 15s dans des conditions normales.
|
- 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`).
|
- [ ] 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).
|
- [ ] 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 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).
|
- [ ] Rapport de test hardware mis à jour avec preuves (logs + JSON).
|
||||||
- [ ] Documentation opératoire de pairing/mise en service publiée.
|
- [ ] 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
|
## 7. Preuves attendues
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
[platformio]
|
[platformio]
|
||||||
default_envs = esp32dev
|
default_envs = esp32dev
|
||||||
|
build_dir = .pio/build_live
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
platform = espressif32
|
platform = espressif32
|
||||||
@@ -10,6 +11,9 @@ monitor_speed = 115200
|
|||||||
test_build_src = yes
|
test_build_src = yes
|
||||||
build_flags =
|
build_flags =
|
||||||
-DCORE_DEBUG_LEVEL=1
|
-DCORE_DEBUG_LEVEL=1
|
||||||
|
-DCONFIG_ASYNC_TCP_STACK_SIZE=3072
|
||||||
|
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=8
|
||||||
|
-DCONFIG_ASYNC_TCP_PRIORITY=8
|
||||||
lib_deps =
|
lib_deps =
|
||||||
bblanchon/ArduinoJson@^7.0.4
|
bblanchon/ArduinoJson@^7.0.4
|
||||||
ESP32Async/AsyncTCP@^3.3.2
|
ESP32Async/AsyncTCP@^3.3.2
|
||||||
|
|||||||
+453
-4
@@ -1,7 +1,39 @@
|
|||||||
#include "audio/AudioEngine.h"
|
#include "audio/AudioEngine.h"
|
||||||
|
|
||||||
|
#include <SPIFFS.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstring>
|
#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 defaultAudioConfigForProfile(BoardProfile profile) {
|
||||||
AudioConfig cfg;
|
AudioConfig cfg;
|
||||||
@@ -27,6 +59,7 @@ AudioConfig defaultAudioConfigForProfile(BoardProfile profile) {
|
|||||||
AudioEngine::AudioEngine()
|
AudioEngine::AudioEngine()
|
||||||
: driver_installed_(false),
|
: driver_installed_(false),
|
||||||
capture_active_(false),
|
capture_active_(false),
|
||||||
|
capture_clients_mask_(0),
|
||||||
playing_(false),
|
playing_(false),
|
||||||
play_until_ms_(0),
|
play_until_ms_(0),
|
||||||
features_(getFeatureMatrix(detectBoardProfile())) {}
|
features_(getFeatureMatrix(detectBoardProfile())) {}
|
||||||
@@ -35,6 +68,19 @@ AudioEngine::~AudioEngine() {
|
|||||||
end();
|
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) {
|
bool AudioEngine::begin(const AudioConfig& config) {
|
||||||
end();
|
end();
|
||||||
_config = config;
|
_config = config;
|
||||||
@@ -76,10 +122,32 @@ bool AudioEngine::begin(const AudioConfig& config) {
|
|||||||
return false;
|
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);
|
i2s_zero_dma_buffer(_config.port);
|
||||||
driver_installed_ = true;
|
driver_installed_ = true;
|
||||||
|
portENTER_CRITICAL(&capture_lock_);
|
||||||
|
capture_clients_mask_ = 0U;
|
||||||
|
capture_active_ = false;
|
||||||
|
portEXIT_CRITICAL(&capture_lock_);
|
||||||
capture_active_ = false;
|
capture_active_ = false;
|
||||||
playing_ = 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",
|
Serial.printf("[AudioEngine] ready (full_duplex=%s)\n",
|
||||||
supportsFullDuplex() ? "true" : "false");
|
supportsFullDuplex() ? "true" : "false");
|
||||||
return true;
|
return true;
|
||||||
@@ -89,11 +157,67 @@ void AudioEngine::end() {
|
|||||||
if (!driver_installed_) {
|
if (!driver_installed_) {
|
||||||
return;
|
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);
|
i2s_driver_uninstall(_config.port);
|
||||||
driver_installed_ = false;
|
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) {
|
bool AudioEngine::playFile(const char* path) {
|
||||||
if (!driver_installed_ || path == nullptr || path[0] == '\0') {
|
if (!driver_installed_ || path == nullptr || path[0] == '\0') {
|
||||||
return false;
|
return false;
|
||||||
@@ -105,21 +229,47 @@ bool AudioEngine::playFile(const char* path) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AudioEngine::startCapture() {
|
bool AudioEngine::requestCapture(CaptureClient client) {
|
||||||
if (!driver_installed_) {
|
if (!driver_installed_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const uint8_t bit = static_cast<uint8_t>(client);
|
||||||
|
if (bit == 0U) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!supportsFullDuplex() && playing_) {
|
if (!supportsFullDuplex() && playing_) {
|
||||||
return false;
|
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;
|
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) {
|
size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
|
||||||
if (!capture_active_ || !driver_installed_ || dst == nullptr || samples == 0) {
|
if (!capture_active_ || !driver_installed_ || dst == nullptr || samples == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (!lockI2s()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
metrics_.frames_requested += static_cast<uint32_t>(samples);
|
metrics_.frames_requested += static_cast<uint32_t>(samples);
|
||||||
const uint32_t start_ms = millis();
|
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_.drop_frames += static_cast<uint32_t>(samples);
|
||||||
metrics_.last_latency_ms = millis() - start_ms;
|
metrics_.last_latency_ms = millis() - start_ms;
|
||||||
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
|
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
|
||||||
|
unlockI2s();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const size_t read_samples = bytes_read / sizeof(int16_t);
|
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_.last_latency_ms = millis() - start_ms;
|
||||||
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
|
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
|
||||||
|
unlockI2s();
|
||||||
return read_samples;
|
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() {
|
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 {
|
bool AudioEngine::supportsFullDuplex() const {
|
||||||
@@ -167,8 +388,236 @@ void AudioEngine::tick() {
|
|||||||
if (playing_ && millis() >= play_until_ms_) {
|
if (playing_ && millis() >= play_until_ms_) {
|
||||||
playing_ = false;
|
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 {
|
const AudioConfig& AudioEngine::config() const {
|
||||||
return _config;
|
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
|
#define AUDIO_ENGINE_H
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
#include <FS.h>
|
||||||
#include <driver/i2s.h>
|
#include <driver/i2s.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
|
||||||
#include "core/PlatformProfile.h"
|
#include "core/PlatformProfile.h"
|
||||||
|
|
||||||
@@ -33,14 +37,27 @@ AudioConfig defaultAudioConfigForProfile(BoardProfile profile);
|
|||||||
|
|
||||||
class AudioEngine {
|
class AudioEngine {
|
||||||
public:
|
public:
|
||||||
|
enum CaptureClient : uint8_t {
|
||||||
|
CAPTURE_CLIENT_GENERIC = 0x01,
|
||||||
|
CAPTURE_CLIENT_TELEPHONY = 0x02,
|
||||||
|
CAPTURE_CLIENT_BLUETOOTH = 0x04,
|
||||||
|
};
|
||||||
|
|
||||||
virtual ~AudioEngine();
|
virtual ~AudioEngine();
|
||||||
AudioEngine();
|
AudioEngine();
|
||||||
virtual bool begin(const AudioConfig& config);
|
virtual bool begin(const AudioConfig& config);
|
||||||
virtual void end();
|
virtual void end();
|
||||||
virtual bool playFile(const char* path);
|
virtual bool playFile(const char* path);
|
||||||
|
virtual bool requestCapture(CaptureClient client);
|
||||||
|
virtual void releaseCapture(CaptureClient client);
|
||||||
virtual bool startCapture();
|
virtual bool startCapture();
|
||||||
virtual size_t readCaptureFrame(int16_t* dst, size_t samples);
|
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 void stopCapture();
|
||||||
|
virtual bool startDialTone();
|
||||||
|
virtual void stopDialTone();
|
||||||
|
virtual bool isDialToneActive() const;
|
||||||
virtual bool supportsFullDuplex() const;
|
virtual bool supportsFullDuplex() const;
|
||||||
virtual bool isPlaying() const;
|
virtual bool isPlaying() const;
|
||||||
virtual AudioRuntimeMetrics metrics() const;
|
virtual AudioRuntimeMetrics metrics() const;
|
||||||
@@ -49,15 +66,43 @@ public:
|
|||||||
const AudioConfig& config() const;
|
const AudioConfig& config() const;
|
||||||
|
|
||||||
private:
|
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 driver_installed_ = false;
|
||||||
bool capture_active_ = false;
|
bool capture_active_ = false;
|
||||||
|
uint8_t capture_clients_mask_ = 0;
|
||||||
bool playing_ = false;
|
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;
|
uint32_t play_until_ms_ = 0;
|
||||||
AudioConfig _config;
|
AudioConfig _config;
|
||||||
FeatureMatrix features_;
|
FeatureMatrix features_;
|
||||||
AudioRuntimeMetrics metrics_;
|
AudioRuntimeMetrics metrics_;
|
||||||
i2s_config_t _i2s_config{};
|
i2s_config_t _i2s_config{};
|
||||||
i2s_pin_config_t _i2s_pins{};
|
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
|
#endif // AUDIO_ENGINE_H
|
||||||
|
|||||||
+60
-17
@@ -3,28 +3,65 @@
|
|||||||
#include <Wire.h>
|
#include <Wire.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <initializer_list>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
uint8_t clampVolumeToReg(uint8_t percent) {
|
uint8_t clampVolumeToReg(uint8_t percent) {
|
||||||
const uint8_t clamped = std::min<uint8_t>(100, 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
|
} // namespace
|
||||||
|
|
||||||
bool Es8388Driver::begin(int sda_pin, int scl_pin, uint8_t address) {
|
bool Es8388Driver::begin(int sda_pin, int scl_pin, uint8_t address) {
|
||||||
address_ = address;
|
address_ = address;
|
||||||
Wire.begin(sda_pin, scl_pin);
|
Wire.begin(sda_pin, scl_pin);
|
||||||
|
Wire.setClock(100000);
|
||||||
|
|
||||||
// Minimal ES8388 init sequence for playback + capture paths.
|
const auto write_sequence = [&](std::initializer_list<std::pair<uint8_t, uint8_t>> seq) {
|
||||||
const bool ok = writeReg(0x00, 0x80) && // reset
|
bool ok = true;
|
||||||
writeReg(0x01, 0x58) &&
|
for (const auto& it : seq) {
|
||||||
writeReg(0x02, 0x50) &&
|
ok &= writeReg(it.first, it.second);
|
||||||
writeReg(0x04, 0xC0) &&
|
}
|
||||||
writeReg(0x08, 0x00) &&
|
return ok;
|
||||||
writeReg(0x0A, 0x00) &&
|
};
|
||||||
writeReg(0x17, 0x18) &&
|
|
||||||
writeReg(0x19, 0x02);
|
// 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;
|
ready_ = ok;
|
||||||
if (!ready_) {
|
if (!ready_) {
|
||||||
@@ -43,7 +80,8 @@ bool Es8388Driver::setVolume(uint8_t percent) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const uint8_t reg = clampVolumeToReg(volume_);
|
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) {
|
bool Es8388Driver::setMute(bool enabled) {
|
||||||
@@ -51,7 +89,8 @@ bool Es8388Driver::setMute(bool enabled) {
|
|||||||
if (!ready_) {
|
if (!ready_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return writeReg(0x2F, enabled ? 0x01 : 0x00);
|
// DACCONTROL3 bit2 is mute.
|
||||||
|
return writeReg(0x19, enabled ? 0x04 : 0x00);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Es8388Driver::setRoute(const String& route) {
|
bool Es8388Driver::setRoute(const String& route) {
|
||||||
@@ -61,14 +100,18 @@ bool Es8388Driver::setRoute(const String& route) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route_ == "bluetooth") {
|
// Current hardware path uses DAC->mixer->line output for both RTC and BT.
|
||||||
return writeReg(0x30, 0x01);
|
// 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") {
|
if (route_ == "none") {
|
||||||
return writeReg(0x30, 0x02);
|
return writeReg(0x04, 0xC0);
|
||||||
}
|
}
|
||||||
route_ = "rtc";
|
route_ = "rtc";
|
||||||
return writeReg(0x30, 0x00);
|
return writeReg(0x26, 0x00) && writeReg(0x27, 0x90) && writeReg(0x2A, 0x90) &&
|
||||||
|
writeReg(0x04, 0x3C);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Es8388Driver::isReady() const {
|
bool Es8388Driver::isReady() const {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,13 @@
|
|||||||
|
|
||||||
#include "core/PlatformProfile.h"
|
#include "core/PlatformProfile.h"
|
||||||
|
|
||||||
|
class AudioEngine;
|
||||||
|
|
||||||
class BluetoothManager {
|
class BluetoothManager {
|
||||||
public:
|
public:
|
||||||
BluetoothManager();
|
BluetoothManager();
|
||||||
bool begin(BoardProfile profile);
|
bool begin(BoardProfile profile);
|
||||||
|
void setAudioBridge(AudioEngine* audio);
|
||||||
bool connect(const char* mac);
|
bool connect(const char* mac);
|
||||||
bool disconnect();
|
bool disconnect();
|
||||||
bool isConnected() const;
|
bool isConnected() const;
|
||||||
@@ -18,7 +21,11 @@ public:
|
|||||||
bool stopHFP();
|
bool stopHFP();
|
||||||
bool startBLE();
|
bool startBLE();
|
||||||
bool stopBLE();
|
bool stopBLE();
|
||||||
|
void tick();
|
||||||
bool setDiscoverable(bool enabled);
|
bool setDiscoverable(bool enabled);
|
||||||
|
bool setAutoReconnectEnabled(bool enabled);
|
||||||
|
bool autoReconnectEnabled() const;
|
||||||
|
void suspendReconnect(uint32_t duration_ms);
|
||||||
bool dial(const String& number);
|
bool dial(const String& number);
|
||||||
bool redial();
|
bool redial();
|
||||||
bool answerCall();
|
bool answerCall();
|
||||||
@@ -32,7 +39,11 @@ public:
|
|||||||
void publishBleStatus();
|
void publishBleStatus();
|
||||||
String executeBleCommand(const String& cmd);
|
String executeBleCommand(const String& cmd);
|
||||||
void handleHfpEvent(int event, const void* param);
|
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 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 isSecurityEnabled() const;
|
||||||
bool isHfpActive() const;
|
bool isHfpActive() const;
|
||||||
bool isBleActive() const;
|
bool isBleActive() const;
|
||||||
@@ -45,28 +56,49 @@ private:
|
|||||||
bool ensureBtStackReady();
|
bool ensureBtStackReady();
|
||||||
bool ensureHfpClientReady();
|
bool ensureHfpClientReady();
|
||||||
bool requireCallControlReady(const char* operation);
|
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;
|
bool parseMac(const String& mac, uint8_t out[6]) const;
|
||||||
String formatMac(const uint8_t* mac) const;
|
String formatMac(const uint8_t* mac) const;
|
||||||
|
void applyDiscoverablePolicy();
|
||||||
|
void onHfpAudioStateChanged(bool connected);
|
||||||
|
void requestAudioIfNeeded(const char* reason);
|
||||||
|
bool hasAnyBluetoothClientConnected() const;
|
||||||
|
|
||||||
FeatureMatrix features_;
|
FeatureMatrix features_;
|
||||||
bool stack_ready_;
|
bool stack_ready_;
|
||||||
bool hfp_initialized_;
|
bool hfp_initialized_;
|
||||||
bool hfp_requested_;
|
bool hfp_requested_;
|
||||||
|
bool auto_reconnect_enabled_;
|
||||||
bool ble_stack_initialized_;
|
bool ble_stack_initialized_;
|
||||||
bool ble_service_ready_;
|
bool ble_service_ready_;
|
||||||
bool ble_client_connected_;
|
bool ble_client_connected_;
|
||||||
bool connected_;
|
bool connected_;
|
||||||
bool hfp_active_;
|
bool hfp_active_;
|
||||||
bool slc_connected_;
|
bool slc_connected_;
|
||||||
|
bool call_setup_active_;
|
||||||
bool ble_active_;
|
bool ble_active_;
|
||||||
bool discoverable_;
|
bool discoverable_;
|
||||||
bool security_enabled_;
|
bool security_enabled_;
|
||||||
bool pbap_supported_;
|
bool pbap_supported_;
|
||||||
bool pbap_synced_;
|
bool pbap_synced_;
|
||||||
|
bool hfp_data_callback_registered_;
|
||||||
|
bool hfp_audio_link_up_;
|
||||||
String peer_mac_;
|
String peer_mac_;
|
||||||
uint8_t peer_addr_[6];
|
uint8_t peer_addr_[6];
|
||||||
bool peer_addr_valid_;
|
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 call_state_;
|
||||||
|
String pending_dial_number_;
|
||||||
String last_dialed_number_;
|
String last_dialed_number_;
|
||||||
String pbap_last_error_;
|
String pbap_last_error_;
|
||||||
String last_hfp_event_;
|
String last_hfp_event_;
|
||||||
@@ -74,6 +106,10 @@ private:
|
|||||||
String last_error_;
|
String last_error_;
|
||||||
String ble_last_command_;
|
String ble_last_command_;
|
||||||
String ble_last_response_;
|
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_;
|
std::function<String(const String&)> ble_command_handler_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
|
|||||||
std::vector<int> used;
|
std::vector<int> used;
|
||||||
used.reserve(11);
|
used.reserve(11);
|
||||||
|
|
||||||
const int critical_pins[] = {
|
const int required_pins[] = {
|
||||||
cfg.i2s_bck,
|
cfg.i2s_bck,
|
||||||
cfg.i2s_ws,
|
cfg.i2s_ws,
|
||||||
cfg.i2s_dout,
|
cfg.i2s_dout,
|
||||||
@@ -281,11 +281,10 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
|
|||||||
cfg.slic_rm,
|
cfg.slic_rm,
|
||||||
cfg.slic_fr,
|
cfg.slic_fr,
|
||||||
cfg.slic_shk,
|
cfg.slic_shk,
|
||||||
cfg.slic_line,
|
|
||||||
cfg.slic_pd,
|
cfg.slic_pd,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (int pin : critical_pins) {
|
for (int pin : required_pins) {
|
||||||
if (pin < 0 || pin > 39) {
|
if (pin < 0 || pin > 39) {
|
||||||
error = "invalid_pin_range";
|
error = "invalid_pin_range";
|
||||||
return false;
|
return false;
|
||||||
@@ -297,6 +296,19 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
|
|||||||
used.push_back(pin);
|
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 = "";
|
error = "";
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct A252PinsConfig {
|
|||||||
int slic_rm = 22;
|
int slic_rm = 22;
|
||||||
int slic_fr = 19;
|
int slic_fr = 19;
|
||||||
int slic_shk = 36;
|
int slic_shk = 36;
|
||||||
int slic_line = 23;
|
int slic_line = -1;
|
||||||
int slic_pd = 18;
|
int slic_pd = 18;
|
||||||
bool hook_active_high = false;
|
bool hook_active_high = false;
|
||||||
};
|
};
|
||||||
@@ -27,7 +27,7 @@ struct A252AudioConfig {
|
|||||||
uint32_t sample_rate = 16000;
|
uint32_t sample_rate = 16000;
|
||||||
uint8_t bits_per_sample = 16;
|
uint8_t bits_per_sample = 16;
|
||||||
bool enable_capture = true;
|
bool enable_capture = true;
|
||||||
uint8_t volume = 80;
|
uint8_t volume = 90;
|
||||||
bool mute = false;
|
bool mute = false;
|
||||||
String route = "rtc";
|
String route = "rtc";
|
||||||
};
|
};
|
||||||
|
|||||||
+72
-20
@@ -1,5 +1,6 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
|
#include <esp_log.h>
|
||||||
|
|
||||||
#include "audio/AudioEngine.h"
|
#include "audio/AudioEngine.h"
|
||||||
#include "audio/Es8388Driver.h"
|
#include "audio/Es8388Driver.h"
|
||||||
@@ -19,6 +20,10 @@
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr uint32_t kSerialBaud = 115200;
|
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;
|
BoardProfile g_profile = BoardProfile::ESP32_A252;
|
||||||
FeatureMatrix g_features = getFeatureMatrix(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_rm = static_cast<uint8_t>(g_pins_cfg.slic_rm),
|
||||||
.pin_fr = static_cast<uint8_t>(g_pins_cfg.slic_fr),
|
.pin_fr = static_cast<uint8_t>(g_pins_cfg.slic_fr),
|
||||||
.pin_shk = static_cast<uint8_t>(g_pins_cfg.slic_shk),
|
.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),
|
.pin_pd = static_cast<int8_t>(g_pins_cfg.slic_pd),
|
||||||
.hook_active_high = g_pins_cfg.hook_active_high,
|
.hook_active_high = g_pins_cfg.hook_active_high,
|
||||||
};
|
};
|
||||||
|
|
||||||
const bool slic_ok = g_slic.begin(slic_pins);
|
const bool slic_ok = g_slic.begin(slic_pins);
|
||||||
g_slic.setPowerDown(false);
|
g_slic.setPowerDown(false);
|
||||||
g_slic.setLineEnabled(true);
|
|
||||||
g_slic.setRing(false);
|
g_slic.setRing(false);
|
||||||
|
|
||||||
const bool codec_ok = g_codec.begin(g_pins_cfg.es8388_sda, g_pins_cfg.es8388_scl);
|
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_audio.resetMetrics();
|
||||||
|
|
||||||
g_telephony.begin(g_profile, g_slic, g_audio);
|
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",
|
Serial.printf("[RTC_BL_PHONE] HW init slic=%s codec=%s audio=%s\n",
|
||||||
slic_ok ? "ok" : "fail",
|
slic_ok ? "ok" : "fail",
|
||||||
@@ -331,6 +341,7 @@ void appendAudioMetrics(JsonObject root) {
|
|||||||
|
|
||||||
JsonObject audio = root["audio"].to<JsonObject>();
|
JsonObject audio = root["audio"].to<JsonObject>();
|
||||||
audio["full_duplex"] = g_audio.supportsFullDuplex();
|
audio["full_duplex"] = g_audio.supportsFullDuplex();
|
||||||
|
audio["dial_tone_active"] = g_audio.isDialToneActive();
|
||||||
audio["frames"] = metrics.frames_read;
|
audio["frames"] = metrics.frames_read;
|
||||||
audio["underrun"] = metrics.underrun_count;
|
audio["underrun"] = metrics.underrun_count;
|
||||||
audio["drop"] = metrics.drop_frames;
|
audio["drop"] = metrics.drop_frames;
|
||||||
@@ -410,9 +421,6 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
|
|||||||
if (patch["slic"]["shk"].is<int>()) {
|
if (patch["slic"]["shk"].is<int>()) {
|
||||||
next.slic_shk = patch["slic"]["shk"].as<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>()) {
|
if (patch["slic"]["pd"].is<int>()) {
|
||||||
next.slic_pd = patch["slic"]["pd"].as<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>()) {
|
if (patch["slic_shk"].is<int>()) {
|
||||||
next.slic_shk = patch["slic_shk"].as<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>()) {
|
if (patch["slic_pd"].is<int>()) {
|
||||||
next.slic_pd = patch["slic_pd"].as<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>();
|
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)) {
|
if (!A252ConfigStore::validatePins(next, error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -591,6 +599,26 @@ void registerCommands() {
|
|||||||
return makeResponse(true, "RESET_METRICS");
|
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) {
|
g_dispatcher.registerCommand("WIFI_CONNECT", [](const String& args) {
|
||||||
String ssid;
|
String ssid;
|
||||||
String password_raw;
|
String password_raw;
|
||||||
@@ -623,6 +651,8 @@ void registerCommands() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
g_dispatcher.registerCommand("WIFI_SCAN", [](const String&) {
|
g_dispatcher.registerCommand("WIFI_SCAN", [](const String&) {
|
||||||
|
// Guard BT/HFP reconnect while WiFi allocates scan resources.
|
||||||
|
g_bt.suspendReconnect(8000U);
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
JsonObject root = doc.to<JsonObject>();
|
JsonObject root = doc.to<JsonObject>();
|
||||||
root["ok"] = true;
|
root["ok"] = true;
|
||||||
@@ -815,6 +845,14 @@ void registerCommands() {
|
|||||||
return makeResponse(g_bt.setDiscoverable(false), "BT_DISCOVERABLE_OFF");
|
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&) {
|
g_dispatcher.registerCommand("BT_STATUS", [](const String&) {
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
g_bt.statusToJson(doc.to<JsonObject>());
|
g_bt.statusToJson(doc.to<JsonObject>());
|
||||||
@@ -1018,7 +1056,12 @@ void pollSerial() {
|
|||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(kSerialBaud);
|
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_profile = BoardProfile::ESP32_A252;
|
||||||
g_features = getFeatureMatrix(g_profile);
|
g_features = getFeatureMatrix(g_profile);
|
||||||
@@ -1028,9 +1071,13 @@ void setup() {
|
|||||||
g_pins_cfg.slic_rm = 18;
|
g_pins_cfg.slic_rm = 18;
|
||||||
g_pins_cfg.slic_fr = 5;
|
g_pins_cfg.slic_fr = 5;
|
||||||
g_pins_cfg.slic_shk = 23;
|
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.slic_pd = 19;
|
||||||
g_pins_cfg.hook_active_high = true;
|
g_pins_cfg.hook_active_high = true;
|
||||||
|
|
||||||
|
pinMode(kAudioAmpEnablePin, OUTPUT);
|
||||||
|
digitalWrite(kAudioAmpEnablePin, LOW);
|
||||||
A252ConfigStore::loadAudio(g_audio_cfg);
|
A252ConfigStore::loadAudio(g_audio_cfg);
|
||||||
A252ConfigStore::loadMqtt(g_mqtt_cfg);
|
A252ConfigStore::loadMqtt(g_mqtt_cfg);
|
||||||
A252ConfigStore::loadEspNowPeers(g_peer_store);
|
A252ConfigStore::loadEspNowPeers(g_peer_store);
|
||||||
@@ -1041,23 +1088,17 @@ void setup() {
|
|||||||
g_bt.setBleCommandHandler([](const String& cmd) {
|
g_bt.setBleCommandHandler([](const String& cmd) {
|
||||||
return responseToText(executeCommandLine(cmd));
|
return responseToText(executeCommandLine(cmd));
|
||||||
});
|
});
|
||||||
|
g_bt.setAudioBridge(&g_audio);
|
||||||
g_bt.begin(g_profile);
|
|
||||||
|
|
||||||
g_mqtt.begin(g_mqtt_cfg);
|
g_mqtt.begin(g_mqtt_cfg);
|
||||||
g_mqtt.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
|
g_mqtt.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
|
||||||
processInboundBridgeCommand(source, 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 ssid;
|
||||||
String password;
|
String password;
|
||||||
if (WifiCredentialsStorage::load(ssid, 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) {
|
if (g_wifi.isConnected() && g_mqtt_cfg.enabled) {
|
||||||
g_mqtt.connectNow();
|
g_mqtt.connectNow();
|
||||||
}
|
}
|
||||||
@@ -1065,6 +1106,13 @@ void setup() {
|
|||||||
g_wifi.ensureFallbackAp();
|
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.setRateLimitMs(250);
|
||||||
g_web.setAuthEnabled(false);
|
g_web.setAuthEnabled(false);
|
||||||
g_web.setStatusCallback(fillStatusSnapshot);
|
g_web.setStatusCallback(fillStatusSnapshot);
|
||||||
@@ -1075,17 +1123,21 @@ void setup() {
|
|||||||
boardProfileToString(g_profile),
|
boardProfileToString(g_profile),
|
||||||
g_features.has_bt_classic ? "true" : "false",
|
g_features.has_bt_classic ? "true" : "false",
|
||||||
g_features.has_full_duplex_i2s ? "true" : "false");
|
g_features.has_full_duplex_i2s ? "true" : "false");
|
||||||
printHelp();
|
if (kPrintHelpOnBoot) {
|
||||||
|
printHelp();
|
||||||
|
}
|
||||||
publishStatusIfConnected();
|
publishStatusIfConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
|
g_telephony.setIncomingRing(g_bt.callState() == "ringing");
|
||||||
g_telephony.tick();
|
g_telephony.tick();
|
||||||
g_wifi.loop();
|
g_wifi.loop();
|
||||||
|
g_bt.tick();
|
||||||
g_mqtt.tick();
|
g_mqtt.tick();
|
||||||
g_espnow.tick();
|
g_espnow.tick();
|
||||||
g_web.handle();
|
g_web.handle();
|
||||||
pollSerial();
|
pollSerial();
|
||||||
delay(10);
|
delay(1);
|
||||||
}
|
}
|
||||||
#endif // UNIT_TEST
|
#endif // UNIT_TEST
|
||||||
|
|||||||
@@ -2,11 +2,23 @@
|
|||||||
|
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
#include <esp_now.h>
|
#include <esp_now.h>
|
||||||
|
#include <esp_wifi.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
EspNowBridge* EspNowBridge::instance_ = nullptr;
|
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() {
|
EspNowBridge::EspNowBridge() {
|
||||||
instance_ = this;
|
instance_ = this;
|
||||||
}
|
}
|
||||||
@@ -18,11 +30,20 @@ bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) {
|
|||||||
|
|
||||||
store_ = 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) {
|
if (esp_now_init() != ESP_OK) {
|
||||||
ready_ = false;
|
ready_ = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
enforceEspNowCoexPolicy();
|
||||||
|
|
||||||
esp_now_register_recv_cb(onDataRecv);
|
esp_now_register_recv_cb(onDataRecv);
|
||||||
esp_now_register_send_cb(onDataSent);
|
esp_now_register_send_cb(onDataSent);
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
#include "telephony/TelephonyService.h"
|
#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) {
|
const char* telephonyStateToString(TelephonyState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case TelephonyState::IDLE:
|
case TelephonyState::IDLE:
|
||||||
@@ -20,10 +35,30 @@ TelephonyService::TelephonyService()
|
|||||||
features_(getFeatureMatrix(BoardProfile::ESP32_A252)),
|
features_(getFeatureMatrix(BoardProfile::ESP32_A252)),
|
||||||
slic_(nullptr),
|
slic_(nullptr),
|
||||||
audio_(nullptr),
|
audio_(nullptr),
|
||||||
|
dial_callback_(nullptr),
|
||||||
|
answer_callback_(nullptr),
|
||||||
|
dtmf_(8000U, kDtmfFrameSamples),
|
||||||
state_(TelephonyState::IDLE),
|
state_(TelephonyState::IDLE),
|
||||||
incoming_ring_(false),
|
incoming_ring_(false),
|
||||||
ring_phase_on_(false),
|
ring_phase_on_(false),
|
||||||
ring_cycle_start_ms_(0),
|
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") {}
|
message_path_("/welcome.wav") {}
|
||||||
|
|
||||||
bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEngine& audio) {
|
bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEngine& audio) {
|
||||||
@@ -35,25 +70,200 @@ bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEn
|
|||||||
incoming_ring_ = false;
|
incoming_ring_ = false;
|
||||||
ring_phase_on_ = false;
|
ring_phase_on_ = false;
|
||||||
ring_cycle_start_ms_ = millis();
|
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_->setRing(false);
|
||||||
slic_->setLineEnabled(true);
|
slic_->setLineEnabled(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TelephonyService::setDialCallback(DialCallback cb) {
|
||||||
|
dial_callback_ = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TelephonyService::setAnswerCallback(AnswerCallback cb) {
|
||||||
|
answer_callback_ = cb;
|
||||||
|
}
|
||||||
|
|
||||||
void TelephonyService::triggerIncomingRing() {
|
void TelephonyService::triggerIncomingRing() {
|
||||||
incoming_ring_ = true;
|
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() {
|
void TelephonyService::tick() {
|
||||||
if (slic_ == nullptr || audio_ == nullptr) {
|
if (slic_ == nullptr || audio_ == nullptr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
slic_->tick();
|
slic_->tick();
|
||||||
audio_->tick();
|
|
||||||
|
|
||||||
const bool hook_off = slic_->isHookOff();
|
const bool hook_off = slic_->isHookOff();
|
||||||
|
const uint32_t now = millis();
|
||||||
|
const TelephonyState prev_state = state_;
|
||||||
|
|
||||||
switch (state_) {
|
switch (state_) {
|
||||||
case TelephonyState::IDLE:
|
case TelephonyState::IDLE:
|
||||||
if (incoming_ring_ && !hook_off) {
|
if (incoming_ring_ && !hook_off) {
|
||||||
@@ -71,8 +281,12 @@ void TelephonyService::tick() {
|
|||||||
incoming_ring_ = false;
|
incoming_ring_ = false;
|
||||||
ring_phase_on_ = false;
|
ring_phase_on_ = false;
|
||||||
slic_->setRing(false);
|
slic_->setRing(false);
|
||||||
audio_->playFile(message_path_);
|
const bool answered = answer_callback_ ? answer_callback_() : false;
|
||||||
state_ = TelephonyState::PLAYING_MESSAGE;
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +313,104 @@ void TelephonyService::tick() {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case TelephonyState::OFF_HOOK:
|
case TelephonyState::OFF_HOOK:
|
||||||
|
if ((now - off_hook_enter_ms_) >= kHookStabilizeMs) {
|
||||||
|
updatePulseDecode(hook_off, now);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hook_off) {
|
if (!hook_off) {
|
||||||
incoming_ring_ = false;
|
// Stop audible dial tone immediately on hangup, even if we keep
|
||||||
state_ = TelephonyState::IDLE;
|
// 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;
|
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 {
|
TelephonyState TelephonyService::state() const {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
#ifndef TELEPHONY_SERVICE_H
|
#ifndef TELEPHONY_SERVICE_H
|
||||||
#define TELEPHONY_SERVICE_H
|
#define TELEPHONY_SERVICE_H
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
#include "audio/AudioEngine.h"
|
#include "audio/AudioEngine.h"
|
||||||
#include "core/PlatformProfile.h"
|
#include "core/PlatformProfile.h"
|
||||||
#include "slic/SlicController.h"
|
#include "slic/SlicController.h"
|
||||||
|
#include "telephony/DtmfDecoder.h"
|
||||||
|
|
||||||
enum class TelephonyState : uint8_t {
|
enum class TelephonyState : uint8_t {
|
||||||
IDLE = 0,
|
IDLE = 0,
|
||||||
@@ -16,21 +19,52 @@ const char* telephonyStateToString(TelephonyState state);
|
|||||||
|
|
||||||
class TelephonyService {
|
class TelephonyService {
|
||||||
public:
|
public:
|
||||||
|
using DialCallback = std::function<bool(const String&)>;
|
||||||
|
using AnswerCallback = std::function<bool()>;
|
||||||
|
|
||||||
TelephonyService();
|
TelephonyService();
|
||||||
bool begin(BoardProfile profile, SlicController& slic, AudioEngine& audio);
|
bool begin(BoardProfile profile, SlicController& slic, AudioEngine& audio);
|
||||||
|
void setDialCallback(DialCallback cb);
|
||||||
|
void setAnswerCallback(AnswerCallback cb);
|
||||||
void triggerIncomingRing();
|
void triggerIncomingRing();
|
||||||
|
void setIncomingRing(bool active);
|
||||||
void tick();
|
void tick();
|
||||||
TelephonyState state() const;
|
TelephonyState state() const;
|
||||||
|
|
||||||
private:
|
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_;
|
BoardProfile profile_;
|
||||||
FeatureMatrix features_;
|
FeatureMatrix features_;
|
||||||
SlicController* slic_;
|
SlicController* slic_;
|
||||||
AudioEngine* audio_;
|
AudioEngine* audio_;
|
||||||
|
DialCallback dial_callback_;
|
||||||
|
AnswerCallback answer_callback_;
|
||||||
|
DtmfDecoder dtmf_;
|
||||||
TelephonyState state_;
|
TelephonyState state_;
|
||||||
bool incoming_ring_;
|
bool incoming_ring_;
|
||||||
bool ring_phase_on_;
|
bool ring_phase_on_;
|
||||||
uint32_t ring_cycle_start_ms_;
|
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_;
|
const char* message_path_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr bool kForceAuthDisabled = true;
|
constexpr bool kForceAuthDisabled = true;
|
||||||
|
constexpr bool kEnableRealtimeEvents = true;
|
||||||
|
|
||||||
String quoteArg(const String& value) {
|
String quoteArg(const String& value) {
|
||||||
String escaped = value;
|
String escaped = value;
|
||||||
@@ -20,6 +21,7 @@ WebServerManager::WebServerManager(uint16_t port)
|
|||||||
last_status_push_ms_(0),
|
last_status_push_ms_(0),
|
||||||
status_cache_json_(""),
|
status_cache_json_(""),
|
||||||
status_cache_ready_(false),
|
status_cache_ready_(false),
|
||||||
|
status_cache_mux_(portMUX_INITIALIZER_UNLOCKED),
|
||||||
auth_enabled_(false),
|
auth_enabled_(false),
|
||||||
auth_user_("admin"),
|
auth_user_("admin"),
|
||||||
auth_pass_("admin") {}
|
auth_pass_("admin") {}
|
||||||
@@ -41,6 +43,7 @@ void WebServerManager::handle() {
|
|||||||
if (now - last_status_push_ms_ >= 1000U) {
|
if (now - last_status_push_ms_ >= 1000U) {
|
||||||
last_status_push_ms_ = now;
|
last_status_push_ms_ = now;
|
||||||
refreshStatusCache();
|
refreshStatusCache();
|
||||||
|
publishRealtimeStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,22 +81,28 @@ void WebServerManager::setCommandExecutor(std::function<DispatchResponse(const S
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WebServerManager::registerRoutes() {
|
void WebServerManager::registerRoutes() {
|
||||||
events_.onConnect([this](AsyncEventSourceClient* client) {
|
if (kEnableRealtimeEvents) {
|
||||||
JsonDocument hello;
|
events_.onConnect([this](AsyncEventSourceClient* client) {
|
||||||
hello["transport"] = "sse";
|
JsonDocument hello;
|
||||||
hello["connected"] = true;
|
hello["transport"] = "sse";
|
||||||
hello["ts"] = millis();
|
hello["connected"] = true;
|
||||||
const String payload = toJsonString(hello);
|
hello["ts"] = millis();
|
||||||
client->send(payload.c_str(), "hello", millis());
|
const String payload = toJsonString(hello);
|
||||||
if (status_cache_ready_) {
|
client->send(payload.c_str(), "hello", millis());
|
||||||
client->send(status_cache_json_.c_str(), "status", millis());
|
bool ready = false;
|
||||||
}
|
const String cached = snapshotStatusCache(&ready);
|
||||||
});
|
if (ready) {
|
||||||
server_.addHandler(&events_);
|
client->send(cached.c_str(), "status", millis());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server_.addHandler(&events_);
|
||||||
|
}
|
||||||
|
|
||||||
server_.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
server_.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
||||||
if (status_cache_ready_) {
|
bool ready = false;
|
||||||
request->send(200, "application/json", status_cache_json_);
|
const String cached = snapshotStatusCache(&ready);
|
||||||
|
if (ready) {
|
||||||
|
request->send(200, "application/json", cached);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +298,17 @@ void WebServerManager::registerRoutes() {
|
|||||||
});
|
});
|
||||||
server_.on("/api/bluetooth/hfp/disconnect", HTTP_POST,
|
server_.on("/api/bluetooth/hfp/disconnect", HTTP_POST,
|
||||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_HFP_DISCONNECT"); });
|
[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,
|
server_.on("/api/bluetooth/discoverable/on", HTTP_POST,
|
||||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_DISCOVERABLE_ON"); });
|
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_DISCOVERABLE_ON"); });
|
||||||
server_.on("/api/bluetooth/discoverable/off", HTTP_POST,
|
server_.on("/api/bluetooth/discoverable/off", HTTP_POST,
|
||||||
@@ -376,27 +396,49 @@ bool WebServerManager::isEffectCommand(const String& command_line) {
|
|||||||
|
|
||||||
void WebServerManager::refreshStatusCache() {
|
void WebServerManager::refreshStatusCache() {
|
||||||
if (!status_callback_) {
|
if (!status_callback_) {
|
||||||
|
portENTER_CRITICAL(&status_cache_mux_);
|
||||||
status_cache_ready_ = false;
|
status_cache_ready_ = false;
|
||||||
status_cache_json_ = "";
|
status_cache_json_ = "";
|
||||||
|
portEXIT_CRITICAL(&status_cache_mux_);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
doc["auth_enabled"] = isAuthEnabled();
|
doc["auth_enabled"] = isAuthEnabled();
|
||||||
status_callback_(doc.to<JsonObject>());
|
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;
|
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) {
|
void WebServerManager::publishRealtimeEvent(const char* event_name, const String& payload_json) {
|
||||||
|
if (!kEnableRealtimeEvents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
events_.send(payload_json.c_str(), event_name, millis());
|
events_.send(payload_json.c_str(), event_name, millis());
|
||||||
}
|
}
|
||||||
|
|
||||||
void WebServerManager::publishRealtimeStatus() {
|
void WebServerManager::publishRealtimeStatus() {
|
||||||
if (!status_cache_ready_) {
|
bool ready = false;
|
||||||
|
const String cached = snapshotStatusCache(&ready);
|
||||||
|
if (!ready) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
publishRealtimeEvent("status", status_cache_json_);
|
publishRealtimeEvent("status", cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
void WebServerManager::publishDispatchEvent(const String& command_line, const DispatchResponse& res) {
|
void WebServerManager::publishDispatchEvent(const String& command_line, const DispatchResponse& res) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <ESPAsyncWebServer.h>
|
#include <ESPAsyncWebServer.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ private:
|
|||||||
uint32_t last_status_push_ms_;
|
uint32_t last_status_push_ms_;
|
||||||
String status_cache_json_;
|
String status_cache_json_;
|
||||||
bool status_cache_ready_;
|
bool status_cache_ready_;
|
||||||
|
portMUX_TYPE status_cache_mux_;
|
||||||
bool auth_enabled_;
|
bool auth_enabled_;
|
||||||
String auth_user_;
|
String auth_user_;
|
||||||
String auth_pass_;
|
String auth_pass_;
|
||||||
@@ -42,6 +44,7 @@ private:
|
|||||||
static String toJsonString(const JsonDocument& doc);
|
static String toJsonString(const JsonDocument& doc);
|
||||||
static bool isValidInput(const String& value, size_t max_len);
|
static bool isValidInput(const String& value, size_t max_len);
|
||||||
static bool isEffectCommand(const String& command_line);
|
static bool isEffectCommand(const String& command_line);
|
||||||
|
String snapshotStatusCache(bool* ready = nullptr);
|
||||||
void refreshStatusCache();
|
void refreshStatusCache();
|
||||||
void publishRealtimeEvent(const char* event_name, const String& payload_json);
|
void publishRealtimeEvent(const char* event_name, const String& payload_json);
|
||||||
void publishRealtimeStatus();
|
void publishRealtimeStatus();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "wifi/WifiCredentialsStorage.h"
|
#include "wifi/WifiCredentialsStorage.h"
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
#include <esp_wifi.h>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr char kFallbackApPrefix[] = "RTC_BL_A252";
|
constexpr char kFallbackApPrefix[] = "RTC_BL_A252";
|
||||||
@@ -60,6 +61,15 @@ String buildFallbackApSsid() {
|
|||||||
return String(name);
|
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
|
} // namespace
|
||||||
|
|
||||||
WifiManager::WifiManager()
|
WifiManager::WifiManager()
|
||||||
@@ -70,7 +80,12 @@ WifiManager::WifiManager()
|
|||||||
ap_ssid_(buildFallbackApSsid()),
|
ap_ssid_(buildFallbackApSsid()),
|
||||||
ap_password_(kFallbackApPassword),
|
ap_password_(kFallbackApPassword),
|
||||||
next_auto_reconnect_ms_(0),
|
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) {
|
bool WifiManager::begin(const char* ssid, const char* password, uint32_t timeout_ms) {
|
||||||
return connect(ssid ? String(ssid) : "", password ? String(password) : "", timeout_ms, true);
|
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();
|
stopFallbackAp();
|
||||||
WiFi.mode(WIFI_STA);
|
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);
|
WiFi.disconnect(false, true);
|
||||||
|
enforceCoexPolicy();
|
||||||
delay(100);
|
delay(100);
|
||||||
WiFi.begin(ssid_.c_str(), password_.c_str());
|
WiFi.begin(ssid_.c_str(), password_.c_str());
|
||||||
|
enforceCoexPolicy();
|
||||||
|
|
||||||
connected_ = waitForConnection(timeout_ms);
|
connected_ = waitForConnection(timeout_ms);
|
||||||
if (connected_) {
|
if (connected_) {
|
||||||
@@ -111,8 +131,10 @@ bool WifiManager::connect(const String& ssid, const String& password, uint32_t t
|
|||||||
next_auto_reconnect_ms_ = 0;
|
next_auto_reconnect_ms_ = 0;
|
||||||
stopFallbackAp();
|
stopFallbackAp();
|
||||||
} else {
|
} else {
|
||||||
|
// Clear partial STA state/timers before switching to fallback.
|
||||||
|
WiFi.disconnect(false, true);
|
||||||
notifyWifi("connect_failed");
|
notifyWifi("connect_failed");
|
||||||
next_auto_reconnect_ms_ = millis() + reconnect_backoff_ms_;
|
next_auto_reconnect_ms_ = 0;
|
||||||
startFallbackAp();
|
startFallbackAp();
|
||||||
}
|
}
|
||||||
return connected_;
|
return connected_;
|
||||||
@@ -146,6 +168,15 @@ void WifiManager::disconnect(bool erase_credentials) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WifiManager::loop() {
|
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);
|
connected_ = (WiFi.status() == WL_CONNECTED);
|
||||||
if (connected_) {
|
if (connected_) {
|
||||||
next_auto_reconnect_ms_ = 0;
|
next_auto_reconnect_ms_ = 0;
|
||||||
@@ -157,9 +188,7 @@ void WifiManager::loop() {
|
|||||||
startFallbackAp();
|
startFallbackAp();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ssid_.isEmpty() && next_auto_reconnect_ms_ != 0 && millis() >= next_auto_reconnect_ms_) {
|
// Manual reconnect only (WIFI_RECONNECT command).
|
||||||
reconnect(5000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void WifiManager::ensureFallbackAp() {
|
void WifiManager::ensureFallbackAp() {
|
||||||
@@ -256,12 +285,16 @@ bool WifiManager::startFallbackAp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WiFi.mode(WIFI_AP_STA);
|
WiFi.mode(WIFI_AP_STA);
|
||||||
|
// Required by ESP32 WiFi+BT coexistence in AP+STA mode.
|
||||||
|
WiFi.setAutoReconnect(false);
|
||||||
|
enforceCoexPolicy();
|
||||||
const bool ok = WiFi.softAP(
|
const bool ok = WiFi.softAP(
|
||||||
ap_ssid_.c_str(),
|
ap_ssid_.c_str(),
|
||||||
ap_password_.c_str(),
|
ap_password_.c_str(),
|
||||||
kFallbackApChannel,
|
kFallbackApChannel,
|
||||||
false,
|
false,
|
||||||
kFallbackApMaxConnections);
|
kFallbackApMaxConnections);
|
||||||
|
enforceCoexPolicy();
|
||||||
|
|
||||||
ap_active_ = ok;
|
ap_active_ = ok;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ public:
|
|||||||
void scanToJson(JsonArray arr, int max_networks = 20) const;
|
void scanToJson(JsonArray arr, int max_networks = 20) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void enforceCoexPolicy() const;
|
||||||
|
|
||||||
bool connected_;
|
bool connected_;
|
||||||
String ssid_;
|
String ssid_;
|
||||||
String password_;
|
String password_;
|
||||||
@@ -46,6 +48,7 @@ private:
|
|||||||
String ap_password_;
|
String ap_password_;
|
||||||
mutable uint32_t next_auto_reconnect_ms_;
|
mutable uint32_t next_auto_reconnect_ms_;
|
||||||
uint32_t reconnect_backoff_ms_;
|
uint32_t reconnect_backoff_ms_;
|
||||||
|
uint32_t next_coex_reassert_ms_;
|
||||||
|
|
||||||
bool startFallbackAp();
|
bool startFallbackAp();
|
||||||
void stopFallbackAp();
|
void stopFallbackAp();
|
||||||
|
|||||||
Reference in New Issue
Block a user