8 Commits

Author SHA1 Message Date
Clément SAILLANT 7da6ea37e7 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.
2026-02-23 11:54:09 +01:00
Clément SAILLANT f20b12e77a fix: avoid bt+wifi modem sleep crash and expose wifi link info 2026-02-22 01:10:21 +01:00
Clément SAILLANT 7c9e6fe189 fix: stabilize web realtime push and espnow envelope flow 2026-02-22 00:48:24 +01:00
Clément SAILLANT fcaef73c59 feat: chain wifi webui espnow and bt track execution 2026-02-21 23:58:52 +01:00
Clément SAILLANT 841fabd4bf fix(slic): hardcode hook_active_high=true for SHK on GPIO23 2026-02-21 23:21:32 +01:00
Clément SAILLANT 52c4aba36b feat(bt): add HFP call controls and PBAP capability status 2026-02-21 22:51:25 +01:00
Clément SAILLANT 1f277f8598 Merge pull request #26 from electron-rare/codex/route-parity-v1-closure
[codex] close route parity v1 with ci evidence
2026-02-21 22:11:19 +01:00
Clément SAILLANT 16d0f29f3f close route parity v1 with ci evidence 2026-02-21 22:07:12 +01:00
33 changed files with 3834 additions and 175 deletions
+11 -3
View File
@@ -2,9 +2,9 @@ name: Firmware CI
on:
push:
branches: [main, develop, dev, audit/**]
branches: [main, release/stable]
pull_request:
branches: [main, develop, dev, audit/**]
branches: [main, release/stable]
jobs:
build-and-test:
@@ -23,7 +23,15 @@ jobs:
run: python3 -m unittest scripts/test_check_web_route_parity.py
- name: Check Web Route Parity
run: python3 scripts/check_web_route_parity.py
run: python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json
- name: Upload route parity report
if: always()
uses: actions/upload-artifact@v4
with:
name: route-parity-report
path: artifacts/route_parity_report.json
if-no-files-found: warn
- name: Install PlatformIO
run: |
+9
View File
@@ -108,6 +108,15 @@ _Agent Repo & GitHub README généré automatiquement._
- Si décroché pendant sonnerie : `answer` automatique.
- Si raccroché pendant appel : `end/reject` automatique.
## Wiring A252 validé (bench courant)
- `SLIC RM` -> `GPIO18`
- `SLIC FR` -> `GPIO5`
- `SLIC SHK` -> `GPIO23` (`INPUT_PULLUP`, hook actif haut)
- `SLIC PD` -> `GPIO19`
- `SLIC LINE` -> non utilisé (`-1`, logique retirée du runtime)
- `AMP_EN` carte audio -> `GPIO21`, polarité active bas (`LOW=ON`, `HIGH=OFF`)
- tonalité locale: `425 Hz` (couleur France/Europe)
## Choix de cartes ESP32
Voir `docs/solutions_rtc_phone_esp32.md` pour la shortlist des DevKit utilisables (ESP32-DevKitC, ESP32-S3-DevKitC-1, NodeMCU-32S, LOLIN32), les liens de référence web, et les solutions dinterface (direct combiné/clavier, SLIC/FXS, ATA externe), dont une variante AG1171S (Silvertel).
+15
View File
@@ -116,8 +116,23 @@
<input type="text" id="btHfpAddr" placeholder="MAC téléphone HFP (AA:BB:CC:DD:EE:FF)">
<button type="submit">HFP Connect</button>
</form>
<form id="btDialForm">
<input type="text" id="btDialNumber" placeholder="Numéro (ex: 0612345678)">
<button type="submit">Dial</button>
</form>
<div class="inline-actions">
<button id="btHfpDisconnectBtn">HFP Disconnect</button>
<button id="btRedialBtn">Redial</button>
<button id="btAnswerBtn">Answer</button>
<button id="btHangupBtn">Hangup</button>
<button id="btCallsBtn">Calls Query</button>
</div>
<div class="inline-actions">
<button id="btDiscoverableOnBtn">Discoverable ON</button>
<button id="btDiscoverableOffBtn">Discoverable OFF</button>
<button id="btAutoReconnectOnBtn">Auto Reconnect ON</button>
<button id="btAutoReconnectOffBtn">Auto Reconnect OFF</button>
<button id="btPbapSyncBtn">PBAP Sync</button>
<button id="btBleStartBtn">BLE Start</button>
<button id="btBleStopBtn">BLE Stop</button>
<button id="btRefreshBtn">Rafraîchir BT</button>
+253 -10
View File
@@ -4,6 +4,9 @@ const SECTION_MAP = {
network: "networkSection",
control: "controlSection",
};
let realtimeSource = null;
let realtimeConnected = false;
let fallbackPollingTimer = null;
function showSection(section) {
Object.values(SECTION_MAP).forEach((id) => {
@@ -71,21 +74,117 @@ function parsePayloadValue(rawPayload) {
return rawPayload;
}
async function refreshStatus() {
function parseRealtimeData(raw) {
try {
return JSON.parse(raw);
} catch (_) {
return { raw };
}
}
function applyStatusSnapshot(status) {
const line = document.getElementById("statusLine");
const telephonyState = status.telephony?.state || "n/a";
const hook = status.telephony?.hook || "n/a";
const wifiState = status.wifi?.state || "n/a";
const mqttConnected = status.mqtt?.connected ? "on" : "off";
const peers = status.espnow?.peer_count ?? 0;
const btCallState = status.bluetooth?.call_state || "n/a";
const btConnected = status.bluetooth?.connected ? "on" : "off";
const btAutoReconnect = status.bluetooth?.auto_reconnect_enabled ? "on" : "off";
const pbapSupported = status.bluetooth?.pbap_supported ? "yes" : "no";
const liveState = realtimeConnected ? "live=on" : "live=off";
line.textContent =
`board=${status.board_profile || "n/a"} telephony=${telephonyState} hook=${hook} ` +
`wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers} bt=${btConnected} bt_auto=${btAutoReconnect} bt_call=${btCallState} pbap=${pbapSupported} ${liveState}`;
setJson("statusJson", status);
if (status.wifi) {
setJson("wifiJson", status.wifi);
}
if (status.mqtt) {
setJson("mqttJson", status.mqtt);
}
if (status.espnow) {
setJson("espnowJson", status.espnow);
setJson("espnowPeersJson", { peers: status.espnow.peers || [] });
}
if (status.bluetooth) {
setJson("btJson", status.bluetooth);
}
}
function connectRealtime() {
if (!window.EventSource) {
return;
}
if (realtimeSource) {
realtimeSource.close();
}
realtimeSource = new EventSource("/api/events");
realtimeSource.onopen = () => {
realtimeConnected = true;
};
realtimeSource.addEventListener("hello", () => {
realtimeConnected = true;
});
realtimeSource.addEventListener("status", (event) => {
realtimeConnected = true;
const status = parseRealtimeData(event.data);
applyStatusSnapshot(status);
});
realtimeSource.addEventListener("dispatch", (event) => {
const payload = parseRealtimeData(event.data);
const out = { stream: "dispatch" };
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
Object.assign(out, payload);
} else {
out.payload = payload;
}
setJson("actionResult", out);
});
realtimeSource.addEventListener("effect", (event) => {
const payload = parseRealtimeData(event.data);
const out = { stream: "effect" };
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
Object.assign(out, payload);
} else {
out.payload = payload;
}
setJson("actionResult", out);
});
realtimeSource.onerror = () => {
realtimeConnected = false;
};
}
function ensureFallbackPolling() {
if (fallbackPollingTimer !== null) {
return;
}
fallbackPollingTimer = window.setInterval(() => {
if (!realtimeConnected) {
refreshStatus().catch(() => {});
}
}, 2000);
}
async function refreshStatus() {
try {
const status = await requestJson("/api/status");
const telephonyState = status.telephony?.state || "n/a";
const hook = status.telephony?.hook || "n/a";
const wifiState = status.wifi?.state || "n/a";
const mqttConnected = status.mqtt?.connected ? "on" : "off";
const peers = status.espnow?.peer_count ?? 0;
line.textContent =
`board=${status.board_profile || "n/a"} telephony=${telephonyState} hook=${hook} ` +
`wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers}`;
setJson("statusJson", status);
applyStatusSnapshot(status);
} catch (error) {
const line = document.getElementById("statusLine");
const liveState = realtimeConnected ? "live=on" : "live=off";
line.textContent = `Erreur statut: ${error.message}`;
line.textContent += ` ${liveState}`;
setJson("statusJson", { error: error.message });
}
}
@@ -424,6 +523,148 @@ function bindEvents() {
}
});
document.getElementById("btDialForm").addEventListener("submit", async (event) => {
event.preventDefault();
const number = document.getElementById("btDialNumber").value.trim();
try {
const result = await requestJson("/api/bluetooth/hfp/dial", {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ number }),
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btRedialBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/redial", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btAnswerBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/answer", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btHangupBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/hangup", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btCallsBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/calls", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btDiscoverableOnBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/discoverable/on", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btDiscoverableOffBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/discoverable/off", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btAutoReconnectOnBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/auto", {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ enabled: true }),
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btAutoReconnectOffBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/auto", {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ enabled: false }),
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btPbapSyncBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/pbap/sync", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btBleStartBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/ble/start", {
@@ -475,6 +716,8 @@ function bindEvents() {
document.addEventListener("DOMContentLoaded", async () => {
bindEvents();
connectRealtime();
ensureFallbackPolling();
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork(), refreshBluetooth()]);
showSection("dashboard");
});
+49 -30
View File
@@ -1,35 +1,54 @@
# Gates & objectifs — Phase suivante RTC_BL_PHONE
# RTC_BL_PHONE Kanban (Live Execution)
## [2026-02-20] Alignement multi-repos (RTC + Zacus + Kill_LIFE)
- Compatibilité ESP-NOW renforcée côté RTC:
- commandes runtime `ESPNOW_ON` / `ESPNOW_OFF`
- extraction de commande bridge depuis payload JSON imbriqué (`event/message/payload`)
- Correctif mesures ESP-NOW:
- suppression du double comptage `tx_ok` (ack + callback)
- Base méthodologique importée depuis Kill_LIFE:
- gates minimales Spec/Build/Test/Release
- evidence pack et standards firmware de référence
- documentée dans `docs/CROSS_REPO_INTELLIGENCE.md`
## Todo
- Validate hardware gate `incoming GSM -> RTC ring -> BT_ANSWER` on `/dev/cu.usbserial-0001`.
- Validate hardware gate `dial len10 (pulse)` with queued HFP reconnection then auto dial.
- Validate hardware gate `dial len10 (DTMF)` with no false pulse digits.
- Re-run `ESP-NOW` 2-board validation when peer card is available.
## Gates prioritaires
- Extension endpoints HTTP : audio, batterie, rtos, bluetooth, wifi
- Sécurisation endpoints : authentification, validation, gestion des droits
- Tests fonctionnels avancés : charge, robustesse, scénarios multi-utilisateurs
- Documentation endpoints et API : structure, exemples, onboarding
- CI : validation automatisée, reporting, traçabilité
## In Progress
- Track `rtos-coex-wifi-bt`:
- endurance monitor 10 min sans `abort/assert`.
- Track `bt-hfp-stability`:
- validation E2E entrant GSM -> ring RTC -> décroché -> `BT_ANSWER`.
- Track `webui-runtime`:
- valider charge SSE en AP fallback + STA connecté.
## Objectifs
- Couverture complète des modules audio, SLIC, téléphone, RTOS, Bluetooth, Wifi
- Robustesse et sécurité des interfaces web
- Traçabilité des artefacts, logs et verdicts
- Onboarding et documentation utilisateur/technique
## Blocked
- `PBAP` contact sync on Arduino ESP32 Bluedroid stack (`BT_PBAP_SYNC` remains `unsupported`).
- Full ESP-NOW E2E protocol proof while second board is unavailable.
- Notion sync automation blocked until MCP Notion OAuth session is reconnected.
## Actions à lancer
- Développement endpoints manquants
- Implémentation sécurité et validation
- Création/extension des tests fonctionnels
- Mise à jour documentation et scripts CI
## Done
- Added HFP auto reconnect runtime control (`BT_AUTO_RECONNECT_ON/OFF`) and status flag.
- Preserved dial queue logic (`pending_dial_number`) with auto dial after SLC.
- Added `/api/bluetooth/hfp/auto` endpoint (GET status via `BT_STATUS`, POST toggle).
- Added WebUI controls for BT auto reconnect and status line visibility.
- Re-enabled SSE transport server-side and added browser fallback polling.
- Hardened ESP-NOW init to preserve WiFi mode and apply WiFi/BT modem sleep policy.
- Boot order adjusted to initialize WiFi + ESP-NOW before BT stack bring-up.
- Re-asserted WiFi/BT coex policy periodically in `WifiManager::loop()`.
- Added coex policy enforcement in BT stack bring-up (`BluetoothManager::ensureBtStackReady()`).
- Reduced GAP callback logging pressure (guarded by `kVerboseGapLogs=false`).
- Reduced AsyncTCP memory footprint (`STACK=4096`, `QUEUE=12`) to lower RTOS pressure.
- Reordered main loop to run `g_wifi.loop()` before `g_bt.tick()`.
- Updated GitHub issues `#10` and `#11` with latest CI/HW proof links:
- `https://github.com/electron-rare/RTC_BL_PHONE/issues/10#issuecomment-3941685856`
- `https://github.com/electron-rare/RTC_BL_PHONE/issues/11#issuecomment-3941685860`
---
**Version :** 2026-02-17
## Run Ledger
- `2026-02-22`:
- Scope: coex WiFi/BT hardening + HFP autoradio policy + realtime WebUI + docs sync.
- Target port: `/dev/cu.usbserial-0001`.
- Firmware hash (sha256): `2c345d5ac459e6aa914f47438cc35bbe33dda68e5a65f41eaf3d8f1efb53e833`.
- Executed:
- `platformio run -e esp32dev` -> `PASS`
- `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
- `python3 scripts/hw_validation.py --port-a252 /dev/cu.usbserial-0001 --report-json artifacts/hw_validation_report.json --report-md artifacts/hw_validation_report.md` -> `PASS`
- `bash scripts/test_terminal.sh` -> `PASS`
- `python3 -m unittest scripts/test_check_web_route_parity.py` -> `PASS`
- `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` -> `PASS`
- Artifacts:
- `artifacts/hw_validation_report.json`
- `artifacts/hw_validation_report.md`
- `artifacts/route_parity_report.json`
+4
View File
@@ -37,6 +37,10 @@ Chaque fiche détaille : rôle, API, notifications, points de validation, synerg
| Tests livraison | plan_tests_livraison.md |
| WebUI | plan_webui.md |
| Spec WebUI route parity | spec_webui_route_parity_and_coverage_v1.md |
| Spec BT HFP/PBAP dial | spec_bt_hfp_pbap_dialing_v1.md |
| API ESP-NOW v1 | espnow_api_v1.md |
| Faisabilité migration PBAP | bt_pbap_migration_feasibility.md |
| État des specs | SPECS_STATE.md |
## Procédures QA & hardware
| Procédure | Fichier |
+173
View File
@@ -0,0 +1,173 @@
# État des specs RTC_BL_PHONE
## 1) Index des specs
- `docs/spec_webui_route_parity_and_coverage_v1.md` (spécification principale active)
- `docs/spec_bt_hfp_pbap_dialing_v1.md` (nouvelle spec téléphonie Bluetooth, statut `Draft`)
- Notion hub: `RTC_BL_PHONE Delivery Hub`
- Notion spec mirror: `RTC_BL_PHONE - Spec - WebUI Route Parity v1`
- Notion plan: `RTC_BL_PHONE - Plan d'implementation - Route Parity Closure`
- Notion tasks DB: `RTC_BL_PHONE - Tasks` (`collection://89fa36aa-8933-4294-a922-90c6c34d5130`)
## 2) Bilan opérationnel de la spec webui route parity
| Axe | Statut | Preuve / commentaire |
|---|---|---|
| EF-01 — Gate route parity en CI | Done | Workflow CI exécute le checker parity avec export JSON. |
| EF-02 — Artefact de preuve | Done | Rapport JSON généré (`artifacts/route_parity_report.json`) et upload CI via `route-parity-report`. |
| EF-03 — Couverture routes WebUI | Done | Couverture documentée et validée dans `docs/rapport_tests_fonctionnels.md` (section WebUI Route Parity V1). |
| EF-04 — Non-régression des routes existantes | Done | Smoke flows routes tracés dans `docs/rapport_tests_fonctionnels.md` et cohérents avec parity checker. |
## 3) État global de consolidation
- `platformio run -e esp32dev` : OK
- `platformio test --without-uploading --without-testing -e esp32dev` : OK
- `scripts/check_web_route_parity.py` (local) : backend routes 39, frontend routes 38, check passé
- `scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` : OK (report généré)
- `platformio run -e esp32-s3-devkitc-1` : échec connu sur liens Bluetooth/HFP externes (suivi séparé)
## 3-bis) État spec BT HFP/PBAP/Numérotation
- HFP commande-level: OK (connect/disconnect commandes acceptées).
- HFP call-control AT: implémenté (`BT_DIAL`/`DIAL`, `BT_REDIAL`, `BT_ANSWER`, `BT_HANGUP`, `BT_CALLS`).
- Discoverable policy: OK (si aucun client BT connecté, `discoverable=true` forcé; validation locale via `BT_STATUS` avant/après `BT_DISCOVERABLE_OFF`).
- HFP liaison réelle: KO sur tests assistés (pas de montée `connected=true`/`hfp_active=true`).
- PBAP: bloqué sur stack actuelle (`arduino-esp32` bluedroid sans API PBAP exposée côté firmware), `BT_PBAP_SYNC` retourne `unsupported`.
- Numérotation HFP/AT: implémentée côté firmware, validation E2E téléphone encore à obtenir (dépend de la liaison HFP réelle).
## 4) Lacunes à combler pour passer la spec en `DONE`
1. Conserver le suivi S3 Bluetooth/HFP hors scope parity V1.
## 5) Risques actifs
- Écart documentation / exécution (doD non uniformément tracée)
- Dépendances CI sur les branches de support non conservées (si nettoyage de branches engagé)
- Blocs matériels/firmware S3 (liens Bluetooth/HFP) pouvant masquer la visibilité produit
## 6) Enchaînement Track A / Track B (Bluetooth, WiFi, WebUI, ESP-NOW)
### Track A — Delivery rapide (état courant)
- WiFi:
- endpoints Web/API présents: `/api/network/wifi`, `/connect`, `/disconnect`, `/reconnect`, `/scan`.
- fallback AP actif en perte STA (`state=ap_fallback`).
- WebUI Bluetooth:
- endpoints étendus pour discoverable, dial/redial/answer/hangup/calls, pbap sync.
- affichage états BT enrichis (`call_state`, `slc_connected`, `pbap_supported`).
- ESP-NOW:
- protocole v1 documenté (`docs/espnow_api_v1.md`).
- compat legacy conservée (`cmd/raw/command/action`).
- Bluetooth HFP:
- commandes d'appel opérationnelles.
- PBAP toujours `unsupported` sur stack actuelle (comportement explicitement tracé).
### Track B — Migration BT/PBAP (préparation)
- Faisabilité documentée: `docs/bt_pbap_migration_feasibility.md`
- Build migration préparé: `env:esp32dev-bt-migrate`
- Interface d'abstraction ajoutée: `src/bluetooth/IBluetoothBackend.h`
### Gates de validation
- Gate local minimal:
- `platformio run -e esp32dev`
- `bash scripts/test_terminal.sh`
- `scripts/hw_validation.py --port-a252 /dev/cu.usbserial-0001`
- Gate CI:
- workflows `Repo State` + `Firmware CI` verts sur `main`
## 7) Exécution hardware locale (2026-02-21, ESP32 Audio Kit)
- Cible testée: `/dev/cu.usbserial-0001`
- Upload firmware: `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
- Gate local: `bash scripts/test_terminal.sh` -> `PASS` (build unit test, host dtmf, parity parser, parity routes)
- Smoke HW: `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`
- Snapshot runtime: `artifacts/runtime_snapshot.json` (inclut `BT_STATUS`, `WIFI_STATUS`, `ESPNOW_STATUS`, `/api/status`)
- WiFi stability: `artifacts/wifi_stability_report.json` -> `PASS` (disconnect => `ap_fallback`, reconnect => `connected`, credentials persistées)
- HFP opérable (stack actuelle): `artifacts/hfp_operational_report.json` -> `PASS` (discoverable on/off ok, `BT_DIAL` sans SLC en erreur contrôlée, `BT_PBAP_SYNC unsupported` explicite)
- ESP-NOW protocole v1: `artifacts/espnow_protocol_v1_report.json` -> `FAIL` en bench 1 carte (pas de peer radio actif, `ESPNOW_SEND` retourne `ERR`)
- Décision de gate: WiFi/WebUI/Bluetooth HFP command-level `GO`; ESP-NOW E2E v1 two-board `BLOCKED` jusqu’à disponibilité dune 2e carte
### Addendum (2026-02-22)
- Firmware reflash `esp32dev` sur `/dev/cu.usbserial-0001` effectué.
- Validation BT ready-for-pairing: `artifacts/bt_hfp_readiness.json`
- `discoverable=true` sans client connecté.
- `BT_CALLS`/`BT_DIAL` en erreur contrôlée hors SLC.
- `BT_PBAP_SYNC` retourne `ERR ... unsupported` (attendu).
- Validation discoverable forcé: `artifacts/bt_discoverable_policy_check.json`
- `BT_DISCOVERABLE_OFF` accepté mais état final reste `discoverable=true` tant que non connecté.
- ESP-NOW (tests minimisés): `artifacts/espnow_peer91_probe_clean.json`
- peer `10:20:BA:58:C7:48` ajouté.
- envoi unitaire observé avec `tx_fail=1` côté A252 (non bloquant pour le track Bluetooth).
### Addendum (2026-02-22 — wiring/polarité/audio)
- Polarité `GPIO21` verrouillée: `AMP_EN` actif bas (`LOW=ON`, `HIGH=OFF`).
- `SLIC LINE` retiré du runtime (broche non utilisée en bench courant).
- Mapping bench confirmé:
- `RM=GPIO18`, `FR=GPIO5`, `SHK=GPIO23`, `PD=GPIO19`.
- Tonalité locale fixée à `425 Hz` et niveau augmenté (boost léger) sans changement de timbre.
- Durcissement Bluetooth en cours:
- reprise explicite sur `ACL_DISCONN`,
- retry audio SCO piloté par état d'appel (`dialing/alerting/ringing/active`).
### Addendum (2026-02-22 — dial/ring policy BT)
- Spécification ajoutée:
- `DIAL`/`BT_DIAL` doit accepter une demande même sans SLC immédiat:
- queue numéro,
- déclenche/reprend connexion HFP,
- compose automatiquement après `SLC_CONNECTED`.
- Appel entrant GSM via HFP (`call_state=ringing`) doit déclencher la sonnerie RTC.
- Décroché RTC en phase sonnerie doit déclencher `BT_ANSWER`.
- Précondition produit explicitée:
- sans lien HFP actif, la sonnerie RTC sur appel GSM n'est pas possible;
- mitigation firmware: auto-reconnect sur peer bondé + discoverable forcé si non connecté.
- Statut:
- implémentation firmware: `in_progress` (queue dial + auto reconnect renforcés),
- validation hardware E2E appel entrant/sortant: `pending`.
### Addendum (2026-02-22 — autonomous execution batch)
- Runtime policy applied:
- HFP auto reconnect remains enabled by default (`auto_reconnect_enabled=true`).
- New runtime toggles available:
- `BT_AUTO_RECONNECT_ON`
- `BT_AUTO_RECONNECT_OFF`
- `BT_STATUS` now exposes `auto_reconnect_enabled`.
- Web/API alignment:
- endpoint added: `POST /api/bluetooth/hfp/auto` with body `{"enabled":true|false}`.
- endpoint readback: `GET /api/bluetooth/hfp/auto` via `BT_STATUS`.
- SSE realtime path re-enabled (`/api/events`) with client polling fallback.
- Coexistence hardening:
- ESP-NOW init now preserves current WiFi mode and applies modem sleep policy for WiFi/BT coexistence.
- setup order adjusted: WiFi + ESP-NOW before BT startup.
- Tracking:
- `docs/AGENT_TODO.md` converted to live kanban with run ledger.
### Addendum (2026-02-22 — coex/runtime hardening batch 2)
- Build + flash:
- `platformio run -e esp32dev` -> `PASS`
- `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
- Runtime/bench:
- `python3 scripts/hw_validation.py --port-a252 /dev/cu.usbserial-0001 --report-json artifacts/hw_validation_report.json --report-md artifacts/hw_validation_report.md` -> `PASS`
- firmware hash (sha256): `2c345d5ac459e6aa914f47438cc35bbe33dda68e5a65f41eaf3d8f1efb53e833`
- Coexistence hardening applied:
- réimposition périodique `WIFI_PS_MIN_MODEM` dans `WifiManager::loop()`.
- réimposition coex avant/après `btStart()` dans `BluetoothManager::ensureBtStackReady()`.
- logs GAP en callback réduits (`kVerboseGapLogs=false`) pour éviter contention newlib/locks en task BT.
- pression mémoire AsyncTCP réduite (`STACK=4096`, `QUEUE=12`).
- Qualif scripts:
- `bash scripts/test_terminal.sh` -> `PASS`
- `python3 -m unittest scripts/test_check_web_route_parity.py` -> `PASS`
- `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` -> `PASS`
- Résiduel:
- endurance 10 min sans assert à confirmer en monitor live.
- validation E2E entrant GSM -> ring RTC -> `BT_ANSWER` encore `pending`.
- Gouvernance:
- Issues mises à jour avec preuves:
- `#10`: `https://github.com/electron-rare/RTC_BL_PHONE/issues/10#issuecomment-3941685856`
- `#11`: `https://github.com/electron-rare/RTC_BL_PHONE/issues/11#issuecomment-3941685860`
+78 -10
View File
@@ -1,14 +1,82 @@
# Audit initial Audio
# Audit Audio / BT / RTOS — état et plan d'enchaînement
## Robustesse
- Instanciation, configuration codec OK
- Aucun crash lors des appels de méthodes
## État validé (run local USB `/dev/cu.usbserial-0001`)
- Boot stable: audio + codec + web server OK.
- Capture audio: arbitrage multi-clients en place (`TELEPHONY`, `BLUETOOTH`, `GENERIC`).
- Bluetooth HFP:
- plus de crash immédiat au boot.
- plus d'auto-connect agressif au boot (pair bond restauré, connexion sur demande).
- dial peut rester en queue (`pending_dial_number`) si SLC non prêt.
- Coexistence WiFi/BT:
- erreurs fatales/abort au boot réduites après déport d'actions callback vers `tick()`.
## Fiabilité
- Méthodes stables, pas derreur détectée
## Correctifs clés appliqués
- `AudioEngine`:
- `requestCapture/releaseCapture` thread-safe.
- task audio avec backoff idle (charge RTOS réduite).
- `TelephonyService` / `BluetoothManager`:
- plus de `startCapture/stopCapture` concurrents.
- utilisation des clients de capture dédiés.
- `BluetoothManager`:
- déport de `applyDiscoverablePolicy/publishBleStatus` hors callbacks.
- suppression dauto-reconnect HFP au boot.
- timeout SLC en mode backoff (sans disconnect agressif).
- désactivation `AgentSupervisor` côté BT pour retirer allocations C++ en callback.
## Points à surveiller
- Tests hardware à approfondir
## Plan exécutable (skills coordonnés)
---
_Audit généré automatiquement._
### Track 1 — `esp32-rtos-coex-triage` (en cours)
- Objectif: zéro reboot/assert sur séquence boot + WiFi STA + BT activé.
- Actions:
- garder le BT callback-safe (déjà fait).
- surveiller `wifi: fail to alloc timer` sur endurance.
- livrer un rapport de stabilité sur 10 min.
### Track 2 — `esp32-bt-hfp-stability` (prochaine étape)
- Objectif: connexion GSM stable, SLC up, dial auto depuis buffer len10.
- Actions:
- test live: `BT_HFP_CONNECT`, `BT_STATUS`, appel sortant/entrant.
- valider transition `pending_dial_number -> dial requested` quand SLC connecté.
- verrouiller comportement discoverable auto si non connecté.
### Track 3 — `esp32-audio-runtime-gating` (prochaine étape)
- Objectif: tonalité et audio call sans glitch ni coupure.
- Actions:
- vérifier arrêt instant tonalité au 1er digit.
- vérifier arrêt tonalité immédiat au raccroché.
- valider chemin HFP audio I2S en appel actif.
## Critères de sortie
- Aucun `abort/assert` pendant test run.
- Aucun `vQueueDelete queue.c` observé.
- Numérotation 10 chiffres pulse/DTMF déclenche `BT_DIAL` avec succès quand SLC monte.
- Appel entrant GSM: ring, décroché, audio duplex.
## Plan autonome coordonné (skills + agents)
- Skill utilisé pour cadrage spec->implémentation: `notion-spec-to-implementation` (adapté en mode repo local).
- Agent `bt-hfp-stability`:
- garantir `DIAL => queue + connect HFP + auto-dial post-SLC`.
- garder `discoverable=true` si aucun client BT connecté.
- Agent `rtos-coex-wifi-bt`:
- forcer `WIFI_PS_MIN_MODEM` en STA/APSTA pour coexistence WiFi+BT.
- supprimer les reboots `Should enable WiFi modem sleep ...`.
- Agent `telephony-call-routing`:
- appel entrant HFP (`ringing`) => sonnerie RTC.
- décroché RTC en sonnerie => `BT_ANSWER`.
- Gate de validation utilisateur (bench USB):
- confirmer logs `dial_trigger ... ok=true` puis `hfp_slc_connected`/`hfp_dial_requested`.
- confirmer appel entrant GSM => sonnerie RTC immédiate.
## Exécution autonome (batch courant)
- `main.cpp`:
- ordre de boot ajusté pour réduire contention WiFi/BT (`WiFi/ESP-NOW` avant `BT begin`).
- commandes runtime ajoutées: `BT_AUTO_RECONNECT_ON`, `BT_AUTO_RECONNECT_OFF`.
- `BluetoothManager`:
- flag runtime `auto_reconnect_enabled` exposé dans `BT_STATUS`.
- reconnexion auto conditionnée par policy + queue dial.
- `WebServerManager` + WebUI:
- SSE réactivé (`/api/events`) + fallback polling navigateur.
- endpoint ajouté: `/api/bluetooth/hfp/auto`.
- `EspNowBridge`:
- init WiFi coex durci (mode préservé + modem sleep policy).
+61
View File
@@ -0,0 +1,61 @@
# Faisabilité migration Bluetooth PBAP
Date: 2026-02-21
Repo: `RTC_BL_PHONE`
## Résumé exécutif
- Constats actuels:
- HFP est opérable côté commandes firmware.
- PBAP n'est pas exposé par la stack Arduino-ESP32/Bluedroid utilisée actuellement.
- Décision court terme:
- conserver Track A livrable (HFP + WebUI + WiFi + ESP-NOW),
- lancer Track B migration stack pour PBAP.
## 1) État technique actuel
- Stack firmware active: Arduino ESP32 + Bluedroid (HFP client).
- PBAP: API indisponible dans ce périmètre actuel.
- Symptôme attendu: `BT_PBAP_SYNC` retourne `unsupported` avec cause explicite.
## 2) Cibles étudiées
1. Arduino-ESP32 actuel (baseline):
- Avantage: stable, faible risque régression.
- Limite: PBAP absent.
2. Mode mixte Arduino + ESP-IDF (backend BT dédié):
- Avantage: accès plus bas niveau profils BT.
- Risque: complexité build/intégration.
3. Backend BT alternatif (abstraction `IBluetoothBackend`):
- Avantage: migration progressive sans casser Track A.
- Risque: surcharge maintenance temporaire.
## 3) Critère GO / NOGO Track B
GO si:
- PBAP sync fonctionne réellement (au moins 1 contact avec `display_name`, `phone_number`).
- Build reproductible via `platformio.ini` (`env:esp32dev-bt-migrate`).
- Pas de régression des commandes HFP existantes.
NOGO si:
- PBAP non atteignable en 2 itérations de migration contrôlée.
- Régressions HFP non résolues.
## 4) Plan de pivot
Si NOGO:
1. marquer PBAP `blocked by stack`,
2. conserver Track A en production,
3. ouvrir lot dédié migration profonde (estimation + risques + dépendances).
## 5) Implémentation préparatoire déjà posée
- Environnement de build migration ajouté:
- `env:esp32dev-bt-migrate` (`-DBT_BACKEND_EXPERIMENTAL=1`)
- Interface d'abstraction introduite:
- `src/bluetooth/IBluetoothBackend.h`
## 6) Risques
- Risque principal: coût de migration stack BT supérieur au bénéfice court terme.
- Risque secondaire: divergence comportements HFP entre backend baseline et backend migré.
+92
View File
@@ -0,0 +1,92 @@
# ESP-NOW API v1 (enveloppe `msg_id/seq/type/payload/ack`)
Date: 2026-02-21
Scope: contrat d'échange entre `RTC_BL_PHONE` (A252) et la seconde carte.
## 1. Objectif
Normaliser les trames ESP-NOW pour:
- corréler requête/réponse,
- conserver compatibilité legacy,
- simplifier l'intégration du second repo.
## 2. Requête v1 (nouveau format recommandé)
```json
{
"proto": "rtcbl/1",
"msg_id": "req-001",
"seq": 1,
"type": "command",
"ack": true,
"payload": {
"cmd": "STATUS",
"args": {}
}
}
```
Règles:
- `proto=rtcbl/1` recommandé (toléré absent pour compat).
- `msg_id` sert à corréler la réponse.
- `seq` est un compteur local de trame (recommandé monotone par source).
- `type=command|request|cmd` déclenche l'exécution côté firmware.
- `ack=true` demande une réponse corrélée.
- `payload.cmd` obligatoire pour une commande dispatcher.
- `payload.args` optionnel; sérialisé puis passé au dispatcher.
## 3. Réponse v1 (ack corrélée)
```json
{
"proto": "rtcbl/1",
"msg_id": "req-001",
"seq": 1,
"type": "ack",
"ack": true,
"payload": {
"ok": true,
"code": "STATUS",
"data": {},
"error": ""
}
}
```
Règles:
- `msg_id` et `seq` reprennent la requête.
- `payload.ok=false` => `payload.error` non vide.
- `payload.data` contient le JSON de la commande si disponible.
- fallback possible `payload.data_raw` si la réponse n'est pas JSON.
## 4. Compatibilité legacy
Le firmware continue d'accepter les formats existants:
- `{"cmd":"..."}`
- `{"raw":"..."}`
- `{"command":"..."}`
- `{"action":"..."}`
- variantes imbriquées via `event`, `message`, `payload`
- format historique `rtcbl/1`:
- `{"proto":"rtcbl/1","id":"...","cmd":"...","args":{}}`
## 5. Commandes recommandées v1
- `STATUS`
- `RING`
- `CALL`
- `HOOK`
- `LIST_FILES`
- `PLAY_FILE`
- `WIFI_STATUS`
- `MQTT_STATUS`
- `ESPNOW_STATUS`
- `BT_STATUS`
## 6. Intégration second repo
Checklist minimum côté seconde carte:
1. Émettre `msg_id` unique + `seq` monotone.
2. Positionner `type=command` et `ack=true` pour obtenir une réponse.
3. Implémenter timeout de réponse (2-5s) et corréler sur `msg_id`.
4. Prévoir fallback legacy (`rtcbl/1` + formats `cmd/raw/command/action`) pour compat.
+132
View File
@@ -11,3 +11,135 @@
| S3 local mode | FAIL | `{"duration_s": 20, "telephony_state": "OFF_HOOK", "audio_drop_frames": 0, "bench_latency_ms": 9999}` |
| A252 web endpoints | PASS | `{"skipped": true}` |
| S3 web endpoints | PASS | `{"skipped": true}` |
## WebUI Route Parity V1 (EF-03 / EF-04)
- Date UTC: 2026-02-21T22:00:00Z
- Scope: fermeture spec `docs/spec_webui_route_parity_and_coverage_v1.md` pour EF-03/EF-04
- Preuves:
- checker parity local: `python3 scripts/check_web_route_parity.py`
- report local: `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json`
- CI: artefact `route-parity-report` attendu via workflow GitHub Actions
### EF-03 — Couverture routes WebUI
| Endpoint | Verdict | Preuve |
|---|---|---|
| `/api/bluetooth` | PASS | Front present (`data/webui/script.js`), backend present (`src/web/WebServerManager.cpp`) |
| `/api/bluetooth/hfp/connect` | PASS | Front present, backend present |
| `/api/bluetooth/hfp/disconnect` | PASS | Front present, backend present |
| `/api/bluetooth/ble/start` | PASS | Front present, backend present |
| `/api/bluetooth/ble/stop` | PASS | Front present, backend present |
| `/api/config/audio` | PASS | Front present, backend present |
| `/api/config/mqtt` | PASS | Front present, backend present |
| `/api/config/pins` | PASS | Front present, backend present |
| `/api/network/mqtt` | PASS | Front present, backend present |
| `/api/network/espnow` | PASS | Front present, backend present |
| `/api/network/espnow/peer` | PASS | Front present, backend present |
### EF-04 — Smoke flows routes existantes
| Flux | Verdict | Preuve |
|---|---|---|
| `status global` | PASS | route parity OK + endpoint `/api/status` cote front/backend |
| `wifi connect/disconnect/reconnect` | PASS | endpoints `/api/network/wifi/*` presents front/backend |
| `mqtt connect/disconnect/publish` | PASS | endpoints `/api/network/mqtt/*` presents front/backend |
| `espnow on/off/send/peer` | PASS | endpoints `/api/network/espnow/*` presents front/backend |
| `control actions` | PASS | endpoint `/api/control` present front/backend |
## Enchaînement Track A — Bluetooth / WiFi / WebUI / ESP-NOW
- Date UTC: 2026-02-21T22:30:00Z
- Scope: stabilisation continue Track A + préparation Track B PBAP.
### Vérifications ajoutées
| Domaine | Vérification | Verdict |
|---|---|---|
| Bluetooth Web/API | endpoints discoverable/dial/redial/answer/hangup/calls/pbap_sync exposés | PASS |
| WebUI BT | actions UI alignées sur endpoints backend | PASS |
| WiFi | endpoints connect/disconnect/reconnect/scan inchangés et disponibles | PASS |
| ESP-NOW | contrat v1 documenté + compat legacy conservée | PASS |
| PBAP | retourne explicitement `unsupported` tant que migration non finalisée | PASS |
### Artefacts de référence
- `artifacts/route_parity_report.json`
- `artifacts/webui_bt_parity_report.json`
- `artifacts/wifi_stability_report.json`
- `artifacts/espnow_protocol_v1_report.json`
- `artifacts/hfp_operational_report.json`
## Exécution locale consolidée (ESP32 Audio Kit, 2026-02-21)
- Port test: `/dev/cu.usbserial-0001`
- Upload firmware: `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
- Gate local: `bash scripts/test_terminal.sh` -> `PASS`
- Validation HW: `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`
- Snapshot runtime: `artifacts/runtime_snapshot.json`
- WiFi stability (`artifacts/wifi_stability_report.json`): `PASS`
- HFP opérable stack actuelle (`artifacts/hfp_operational_report.json`): `PASS`
- ESP-NOW protocole v1 (`artifacts/espnow_protocol_v1_report.json`): `FAIL` sur bench 1 carte (absence de peer, `ESPNOW_SEND` en erreur contrôlée)
### Détails clés observés
- WiFi:
- `WIFI_DISCONNECT` force `state=ap_fallback` avec AP actif.
- `WIFI_RECONNECT` restaure `state=connected` en STA et coupe lAP fallback.
- SSID restauré: `Les cils`; credentials persistées.
- Bluetooth/HFP:
- `BT_DISCOVERABLE_ON/OFF`: `OK`.
- `BT_DIAL` sans SLC connecté: erreur contrôlée (attendue).
- `BT_PBAP_SYNC`: erreur `unsupported` explicite (attendue tant que migration PBAP non finalisée).
- ESP-NOW:
- `ESPNOW_STATUS.ready=true`.
- `ESPNOW_SEND` v1 et legacy en échec sur banc mono-carte (pas de pair receveur actif), à retester en configuration 2 cartes.
## Exécution batch autoradio (2026-02-22)
- Scope:
- reconnect HFP persistant type autoradio,
- dial queue + auto dial post-SLC,
- toggle runtime auto reconnect,
- SSE WebUI live + fallback polling,
- hardening coex ESP-NOW/WiFi/BT.
- Changements appliqués:
- commandes série: `BT_AUTO_RECONNECT_ON/OFF`.
- endpoint HTTP: `POST /api/bluetooth/hfp/auto`.
- statut BT enrichi: `auto_reconnect_enabled`.
- Validation manuelle attendue sur banc:
- `incoming GSM -> RTC ring -> décroché -> BT_ANSWER`.
- `dial len10 pulse` et `dial len10 dtmf` avec logs:
- `dial_trigger ... ok=true`
- `hfp_slc_connected`
- `hfp_dial_requested`.
## Exécution batch coex/runtime (2026-02-22, run 2)
- Firmware:
- sha256 `.pio/build/esp32dev/firmware.bin`:
- `2c345d5ac459e6aa914f47438cc35bbe33dda68e5a65f41eaf3d8f1efb53e833`
- Build/flash:
- `platformio run -e esp32dev` -> `PASS`
- `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
- Validation:
- `python3 scripts/hw_validation.py --port-a252 /dev/cu.usbserial-0001 --report-json artifacts/hw_validation_report.json --report-md artifacts/hw_validation_report.md` -> `PASS`
- `bash scripts/test_terminal.sh` -> `PASS`
- `python3 -m unittest scripts/test_check_web_route_parity.py` -> `PASS`
- `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` -> `PASS`
### Correctifs inclus
- Coex WiFi/BT:
- réimposition périodique modem sleep (`WIFI_PS_MIN_MODEM`) côté `WifiManager`.
- réimposition coex au bring-up stack BT (`BluetoothManager`).
- Stabilité BT callbacks:
- logs GAP lourds désactivés par défaut (`kVerboseGapLogs=false`).
- Pression RTOS/réseau:
- `CONFIG_ASYNC_TCP_STACK_SIZE=4096`
- `CONFIG_ASYNC_TCP_QUEUE_SIZE=12`
### Reste à valider manuellement (bench)
- endurance monitor 10 min sans `abort/assert`.
- appel entrant GSM: sonnerie RTC + décroché RTC -> `BT_ANSWER`.
+15 -11
View File
@@ -63,10 +63,12 @@ void setup() {
| Fonction | ESP32 Audio Kit V2.2 Pin | Direction | Remarque |
|--------------------|--------------------------|-----------|-------------------------------------------|
| hookSense | GPIO36 (ADC1_CH0) | IN | Crochet (INPUT_PULLUP) |
| ringCmd | GPIO21 | OUT | Commande sonnerie (GPIO dispo) |
| lineEnable | GPIO19 | OUT | Activation ligne (GPIO dispo) |
| led | GPIO22 | OUT | Debug LED (ou autre GPIO libre) |
| hookSense (SHK) | GPIO23 | IN | Crochet (INPUT_PULLUP, actif haut) |
| ringCmd (RM) | GPIO18 | OUT | Commande sonnerie |
| ringFreq (FR) | GPIO5 | OUT | Modulation sonnerie |
| powerDown (PD) | GPIO19 | OUT/OD | Pilotage power-down SLIC |
| lineEnable | non utilisé | - | Logique retirée du runtime (`-1`) |
| ampEnable (AMP_EN) | GPIO21 | OUT | Ampli audio, **actif bas** (`LOW=ON`) |
| I2S BCK | GPIO27 | OUT | I2S0_BCK_OUT (vers ES8388, casque, HP) |
| I2S WS | GPIO25 | OUT | I2S0_WS_OUT (vers ES8388) |
| I2S DIN | GPIO26 | OUT | I2S0_DO_OUT (vers ES8388) |
@@ -75,8 +77,8 @@ void setup() {
- **Aucun conflit de pin détecté** avec cette configuration sur ESP32 Audio Kit V2.2 A252.
- Les pins I2S sont câblés d'origine vers le codec ES8388 (sortie casque, HP, entrée micro, etc.).
- GPIO34/36 sont disponibles pour entrées analogiques (micro, hook, etc.).
- GPIO21/19/22 sont libres sur la carte pour la logique RTC.
- GPIO34/36 restent disponibles pour entrées analogiques supplémentaires.
- GPIO21 est réservé à `AMP_EN` (actif bas), ne pas l'utiliser pour `LINE`.
**À ajuster selon ton routage réel et la disponibilité des broches sur ta carte.**
## Options de câblage audio ESP32 <-> SLIC K50835F
@@ -171,10 +173,11 @@ void loop() {
+-------------------+ +---------------------+
| ESP32 | | SLIC K50835F |
| | | |
| GPIO4 <-------- |---HOOK--| > Hook sense |
| GPIO5 --------> |---RING--| < Ring control |
| GPIO6 --------> |---LINE--| < Line enable |
| GPIO48 --------> |---LED---| (debug, optionnel) |
| GPIO23 <-------- |---SHK---| > Hook sense |
| GPIO18 --------> |---RM----| < Ring control |
| GPIO5 --------> |---FR----| < Ring modulation |
| GPIO19 --------> |---PD----| < Power down ctrl |
| GPIO21 --------> |--AMP_EN-| (actif bas) |
| | | |
| I2S_OUT ------+ | | +-- AUDIO_IN |
| |--|---------|--| |
@@ -183,7 +186,8 @@ void loop() {
```
**Explications :**
- Les signaux HOOK, RING, LINE sont à adapter selon le schéma dapplication du SLIC K50835F (niveau logique, polarité, etc.).
- Les signaux SHK/RM/FR/PD sont à adapter selon le schéma dapplication du SLIC K50835F.
- La broche `LINE` n'est pas utilisée dans la config bench validée.
- Laudio analogique transite via un codec I2S (ex : PCM5102, ES8388) entre lESP32 et le SLIC K50835F.
- Prévoir adaptation dimpédance et filtrage sur les lignes audio.
- Les broches sont données à titre dexemple, à ajuster selon le routage réel.
+111
View File
@@ -0,0 +1,111 @@
# Spécification Bluetooth Téléphonie — HFP + PBAP + Numérotation (v1)
Date: 2026-02-21
Repo: `electron-rare/RTC_BL_PHONE`
## 1. Contexte
Le firmware expose des commandes Bluetooth (`BT_HFP_CONNECT`, `BT_STATUS`, etc.) mais la liaison HFP réelle n'est pas encore validée de bout en bout avec un téléphone.
Le besoin produit inclut explicitement:
- audio d'appel + états d'appel (HFP),
- synchronisation des contacts (PBAP),
- numérotation depuis le firmware (AT/HFP) avec session HFP active.
## 2. Objectif
Livrer une stack Bluetooth téléphonie réellement opérationnelle sur ESP32 Audio Kit:
1. HFP réel fonctionnel (pas seulement commandes firmware),
2. synchronisation contacts via PBAP,
3. numérotation sortante via commandes HFP/AT.
## 2-bis. État dimplémentation (snapshot 2026-02-21)
- HFP commandes firmware: implémenté (`BT_HFP_CONNECT`, `BT_HFP_DISCONNECT`, `BT_DISCOVERABLE_ON/OFF`, `BT_STATUS`).
- Politique de reconnexion: si un peer bondé est trouvé au boot, le firmware planifie la reconnexion HFP automatiquement.
- Politique runtime: `BT_AUTO_RECONNECT_ON/OFF` permet de forcer le mode autoradio en live.
- Numérotation/contrôle appel via HFP AT: implémenté côté firmware:
- `BT_DIAL <number>` / alias `DIAL <number>`
- `BT_REDIAL`
- `BT_ANSWER`
- `BT_HANGUP`
- `BT_CALLS` (query `AT+CLCC`)
- `BT_STATUS` expose maintenant `slc_connected`, `call_state`, `last_dialed_number`.
- PBAP (contacts): non disponible sur la stack actuelle `arduino-esp32` (Bluedroid exposé sans API PBAP côté firmware). La commande `BT_PBAP_SYNC` retourne explicitement `unsupported`.
- Audio local RTC: tonalité de numérotation calée à `425 Hz` (couleur France/Europe), niveau renforcé.
- Wiring bench verrouillé:
- `RM=GPIO18`, `FR=GPIO5`, `SHK=GPIO23`, `PD=GPIO19`
- `AMP_EN=GPIO21` actif bas (`LOW=ON`)
- `LINE` retiré du runtime (non utilisé).
## 3. Périmètre
In-scope:
- Profil HFP (connexion RFCOMM/SLC + audio link),
- Profil PBAP (récupération carnet/adresses utiles),
- Commandes de numérotation et contrôle d'appel via HFP/AT,
- Validation réelle sur téléphone (iOS/macOS/Android selon disponibilité banc).
Out-of-scope v1:
- streaming musique A2DP,
- UI avancée carnet (recherche/favoris), hors MVP téléphonie.
## 4. Exigences fonctionnelles
### EF-01 — HFP réel
- Le firmware DOIT établir une session HFP réelle avec un téléphone compatible.
- `BT_STATUS` DOIT refléter:
- `connected=true` après connexion RFCOMM/SLC,
- `hfp_active=true` lorsque l'audio HFP est actif.
- Les commandes `BT_HFP_CONNECT` et `BT_HFP_DISCONNECT` DOIVENT piloter la session réelle.
- Le firmware DOIT exposer un contrôle runtime du mode auto reconnect (`BT_AUTO_RECONNECT_ON/OFF` et statut via `BT_STATUS`).
### EF-02 — PBAP contacts
- Le firmware DOIT récupérer un sous-ensemble de contacts via PBAP.
- Les métadonnées minimales DOIVENT inclure: `display_name`, `phone_number`.
- La synchro DOIT gérer:
- premier chargement,
- rafraîchissement,
- erreur d'accès/pairing (message explicite).
### EF-03 — Numérotation
- Le firmware DOIT exposer une commande de numérotation sortante (ex: `DIAL <number>`).
- La numérotation DOIT accepter la demande même si SLC n'est pas encore monté:
- mettre le numéro en file d'attente,
- déclencher/reprendre la connexion HFP,
- émettre automatiquement le `dial` dès `SLC_CONNECTED`.
- Les transitions d'appel DOIVENT être visibles dans `STATUS`/`BT_STATUS` (idle, dialing, ringing, active, ended).
### EF-04 — Compatibilité pairing
- L'appareil DOIT être détectable quand requis pour jumelage.
- Le flux de jumelage DOIT être documenté et testable depuis iPhone/Mac.
- Les erreurs de profil non supporté DOIVENT être tracées avec cause exploitable.
### EF-05 — Appel entrant GSM vers sonnerie RTC
- En cas d'appel entrant détecté côté HFP (`call_state=ringing`), le téléphone RTC DOIT sonner.
- Au décroché RTC, le firmware DOIT déclencher `BT_ANSWER`.
- Précondition explicite: téléphone GSM appairé et liaison HFP active (ou reconnexion auto en cours).
- Si aucun peer n'est actif, le firmware DOIT rester découvrable pour restauration du lien.
## 5. Exigences non fonctionnelles
- Temps de connexion HFP cible: < 15s dans des conditions normales.
- Aucun crash du firmware en cas d'échec pairing/profil.
- Logs série actionnables pour chaque étape: connect, slc, audio, pbap sync, dial.
## 6. Critères d'acceptation (DoD)
- [ ] HFP réel validé sur hardware (preuve `connected=true` et `hfp_active=true`).
- [ ] PBAP contacts synchronisés avec au moins un contact lisible (bloqué stack actuelle, nécessite changement de stack BT).
- [ ] Numérotation sortante validée sur téléphone réel depuis commande firmware (`BT_DIAL`).
- [ ] Numérotation sortante validée en mode queue: `DIAL`/`BT_DIAL` avant SLC, puis émission auto après `SLC_CONNECTED`.
- [ ] Appel entrant GSM validé: sonnerie RTC, décroché RTC => `BT_ANSWER`.
- [ ] Rapport de test hardware mis à jour avec preuves (logs + JSON).
- [ ] Documentation opératoire de pairing/mise en service publiée.
- [ ] Stabilité appel: maintien HFP/SCO sans décrochage sur scénario appel sortant réel (preuve logs `hfp_*` + `sco_*`).
## 7. Preuves attendues
- Rapport JSON/Markdown de tests hardware Bluetooth téléphonie.
- Captures ou logs montrant la session HFP active.
- Extrait de contacts PBAP récupérés.
- Trace de numérotation et état d'appel.
@@ -66,6 +66,12 @@ Out-of-scope:
- espnow on/off/send/peer,
- control actions.
### Preuve d’état (mise à jour)
- Local (2026-02-21) : `scripts/check_web_route_parity.py` -> `backend routes: 29 | frontend routes: 29` et `parity check passed`.
- CI : le check parity sexécute via `.github/workflows/ci.yml` avec export (`python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json`).
- EF-02 : artefact dédié `route-parity-report` publié via `actions/upload-artifact`.
## 5. Exigences non fonctionnelles
- Exécution gate parity < 3 secondes en CI standard.
@@ -86,15 +92,14 @@ Phase B (coverage UI):
## 7. Critères d'acceptation (DoD)
- [ ] `scripts/check_web_route_parity.py` exécuté en CI.
- [ ] Échec CI confirmé quand une route frontend orpheline est injectée (test négatif).
- [ ] Les routes EF-03 sont accessibles depuis la WebUI.
- [ ] `docs/rapport_tests_fonctionnels.md` inclut la vérification de ces routes.
- [x] `scripts/check_web_route_parity.py` exécuté en CI.
- [x] Échec CI confirmé quand une route frontend orpheline est injectée (test négatif).
- [x] Les routes EF-03 sont accessibles depuis la WebUI.
- [x] `docs/rapport_tests_fonctionnels.md` inclut la vérification de ces routes.
- [ ] Issue `#11` mise à jour avec run CI de preuve.
- [ ] Issue `#10` mise à jour avec captures/JSON de couverture.
## 8. Coordination inter-repos
- Pattern source: `Kill_LIFE` PR #2 (route parity gate pattern).
- Companion Zacus: issue `le-mystere-professeur-zacus#94`.
- Contrat ESP-NOW inchangé: `cmd/raw/command/action` + `event/message/payload`.
+12
View File
@@ -2,6 +2,7 @@
[platformio]
default_envs = esp32dev
build_dir = .pio/build_live
[env]
platform = espressif32
@@ -10,6 +11,9 @@ monitor_speed = 115200
test_build_src = yes
build_flags =
-DCORE_DEBUG_LEVEL=1
-DCONFIG_ASYNC_TCP_STACK_SIZE=3072
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=8
-DCONFIG_ASYNC_TCP_PRIORITY=8
lib_deps =
bblanchon/ArduinoJson@^7.0.4
ESP32Async/AsyncTCP@^3.3.2
@@ -29,6 +33,14 @@ build_flags =
${env.build_flags}
-DBOARD_PROFILE_A252
[env:esp32dev-bt-migrate]
board = esp32dev
board_build.partitions = huge_app.csv
build_flags =
${env.build_flags}
-DBOARD_PROFILE_A252
-DBT_BACKEND_EXPERIMENTAL=1
[env:test]
platform = espressif32
board = esp32dev
+87
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
@@ -88,6 +89,36 @@ def format_routes(routes: Iterable[Route]) -> str:
return "\n".join(f" - {method} {path}" for method, path in ordered)
def routes_to_payload(routes: Iterable[Route]) -> list[dict[str, str]]:
ordered = sorted(routes, key=lambda route: (route[1], route[0]))
return [{"method": method, "path": path} for method, path in ordered]
def build_report(
backend_routes: set[Route],
frontend_routes: set[Route],
missing_in_backend: set[Route],
unused_backend: set[Route],
strict_unused_backend: bool,
status: str,
) -> dict[str, object]:
return {
"backend_count": len(backend_routes),
"frontend_count": len(frontend_routes),
"backend_routes": routes_to_payload(backend_routes),
"frontend_routes": routes_to_payload(frontend_routes),
"missing_in_backend": routes_to_payload(missing_in_backend),
"unused_backend": routes_to_payload(unused_backend),
"strict_unused_backend": strict_unused_backend,
"status": status,
}
def write_report_json(path: Path, report: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def load_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
@@ -115,6 +146,11 @@ def main() -> int:
action="store_true",
help="Fail if backend API routes are not used by the WebUI.",
)
parser.add_argument(
"--report-json",
default="",
help="Optional path to write a JSON parity report.",
)
args = parser.parse_args()
backend_path = Path(args.backend)
@@ -127,9 +163,29 @@ def main() -> int:
if not backend_routes:
print("[route-parity] no backend /api routes detected", file=sys.stderr)
if args.report_json:
report = build_report(
backend_routes,
frontend_routes,
missing_in_backend=frontend_routes - backend_routes,
unused_backend=backend_routes - frontend_routes,
strict_unused_backend=args.strict_unused_backend,
status="fail",
)
write_report_json(Path(args.report_json), report)
return 2
if not frontend_routes:
print("[route-parity] no frontend /api requestJson() calls detected", file=sys.stderr)
if args.report_json:
report = build_report(
backend_routes,
frontend_routes,
missing_in_backend=frontend_routes - backend_routes,
unused_backend=backend_routes - frontend_routes,
strict_unused_backend=args.strict_unused_backend,
status="fail",
)
write_report_json(Path(args.report_json), report)
return 2
missing_in_backend = frontend_routes - backend_routes
@@ -142,6 +198,16 @@ def main() -> int:
if missing_in_backend:
print("[route-parity] missing backend routes for frontend calls:", file=sys.stderr)
print(format_routes(missing_in_backend), file=sys.stderr)
if args.report_json:
report = build_report(
backend_routes,
frontend_routes,
missing_in_backend,
unused_backend,
args.strict_unused_backend,
status="fail",
)
write_report_json(Path(args.report_json), report)
return 1
if unused_backend:
@@ -150,10 +216,31 @@ def main() -> int:
if args.strict_unused_backend:
print(message, file=sys.stderr)
print(output, file=sys.stderr)
if args.report_json:
report = build_report(
backend_routes,
frontend_routes,
missing_in_backend,
unused_backend,
args.strict_unused_backend,
status="fail",
)
write_report_json(Path(args.report_json), report)
return 1
print(message)
print(output)
if args.report_json:
report = build_report(
backend_routes,
frontend_routes,
missing_in_backend,
unused_backend,
args.strict_unused_backend,
status="pass",
)
write_report_json(Path(args.report_json), report)
print("[route-parity] parity check passed")
return 0
+86 -1
View File
@@ -3,9 +3,12 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from scripts.check_web_route_parity import parse_frontend_routes
from scripts.check_web_route_parity import build_report, parse_frontend_routes, write_report_json
class ParseFrontendRoutesTest(unittest.TestCase):
@@ -53,5 +56,87 @@ class ParseFrontendRoutesTest(unittest.TestCase):
self.assertIn(("POST", "/api/network/mqtt/publish"), routes)
class ReportParityJsonTest(unittest.TestCase):
def test_build_report_has_expected_structure(self) -> None:
backend_routes = {("GET", "/api/status"), ("POST", "/api/control")}
frontend_routes = {("GET", "/api/status"), ("POST", "/api/control")}
report = build_report(
backend_routes=backend_routes,
frontend_routes=frontend_routes,
missing_in_backend=set(),
unused_backend=set(),
strict_unused_backend=False,
status="pass",
)
self.assertEqual(report["backend_count"], 2)
self.assertEqual(report["frontend_count"], 2)
self.assertEqual(report["status"], "pass")
self.assertIn("backend_routes", report)
self.assertIn("frontend_routes", report)
self.assertIn("missing_in_backend", report)
self.assertIn("unused_backend", report)
self.assertFalse(report["strict_unused_backend"])
def test_build_report_routes_are_stably_sorted(self) -> None:
backend_routes = {
("POST", "/api/network/mqtt/connect"),
("GET", "/api/status"),
("DELETE", "/api/network/espnow/peer"),
}
report = build_report(
backend_routes=backend_routes,
frontend_routes=set(),
missing_in_backend=set(),
unused_backend=backend_routes,
strict_unused_backend=True,
status="fail",
)
self.assertEqual(
report["backend_routes"],
[
{"method": "DELETE", "path": "/api/network/espnow/peer"},
{"method": "POST", "path": "/api/network/mqtt/connect"},
{"method": "GET", "path": "/api/status"},
],
)
def test_mismatch_report_marks_fail_and_lists_missing(self) -> None:
backend_routes = {("GET", "/api/status")}
frontend_routes = {("GET", "/api/status"), ("POST", "/api/control")}
missing = frontend_routes - backend_routes
report = build_report(
backend_routes=backend_routes,
frontend_routes=frontend_routes,
missing_in_backend=missing,
unused_backend=backend_routes - frontend_routes,
strict_unused_backend=False,
status="fail",
)
self.assertEqual(report["status"], "fail")
self.assertEqual(
report["missing_in_backend"],
[{"method": "POST", "path": "/api/control"}],
)
def test_write_report_json_writes_valid_json(self) -> None:
report = build_report(
backend_routes={("GET", "/api/status")},
frontend_routes={("GET", "/api/status")},
missing_in_backend=set(),
unused_backend=set(),
strict_unused_backend=False,
status="pass",
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "route_parity_report.json"
write_report_json(path, report)
loaded = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(loaded["status"], "pass")
self.assertEqual(loaded["backend_count"], 1)
if __name__ == "__main__":
unittest.main()
+453 -4
View File
@@ -1,7 +1,39 @@
#include "audio/AudioEngine.h"
#include <SPIFFS.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <limits>
namespace {
constexpr float kDialToneHz = 425.0f;
constexpr float kTwoPi = 6.28318530718f;
constexpr int16_t kDialToneAmplitude = 32000;
constexpr float kDialToneLinearGain = 1.14f;
constexpr size_t kDialToneChunkFrames = 160;
constexpr size_t kStereoChannels = 2;
constexpr float kDialToneAttackMs = 25.0f;
constexpr float kDialToneReleaseMs = 40.0f;
constexpr uint32_t kDialToneWavSeconds = 1;
constexpr char kDialToneWavPrefix[] = "/dialtone_425_";
constexpr bool kDialToneUseWav = false;
constexpr TickType_t kI2sWriteTimeoutTicks = pdMS_TO_TICKS(2);
}
namespace {
int16_t clampInt16(float value) {
if (value > static_cast<float>(std::numeric_limits<int16_t>::max())) {
return std::numeric_limits<int16_t>::max();
}
if (value < static_cast<float>(std::numeric_limits<int16_t>::min())) {
return std::numeric_limits<int16_t>::min();
}
return static_cast<int16_t>(value);
}
} // namespace
AudioConfig defaultAudioConfigForProfile(BoardProfile profile) {
AudioConfig cfg;
@@ -27,6 +59,7 @@ AudioConfig defaultAudioConfigForProfile(BoardProfile profile) {
AudioEngine::AudioEngine()
: driver_installed_(false),
capture_active_(false),
capture_clients_mask_(0),
playing_(false),
play_until_ms_(0),
features_(getFeatureMatrix(detectBoardProfile())) {}
@@ -35,6 +68,19 @@ AudioEngine::~AudioEngine() {
end();
}
bool AudioEngine::lockI2s() const {
if (i2s_io_mutex_ == nullptr) {
return true;
}
return xSemaphoreTake(i2s_io_mutex_, pdMS_TO_TICKS(1)) == pdTRUE;
}
void AudioEngine::unlockI2s() const {
if (i2s_io_mutex_ != nullptr) {
xSemaphoreGive(i2s_io_mutex_);
}
}
bool AudioEngine::begin(const AudioConfig& config) {
end();
_config = config;
@@ -76,10 +122,32 @@ bool AudioEngine::begin(const AudioConfig& config) {
return false;
}
if (i2s_io_mutex_ == nullptr) {
i2s_io_mutex_ = xSemaphoreCreateMutex();
if (i2s_io_mutex_ == nullptr) {
Serial.println("[AudioEngine] i2s mutex unavailable");
}
}
i2s_zero_dma_buffer(_config.port);
driver_installed_ = true;
portENTER_CRITICAL(&capture_lock_);
capture_clients_mask_ = 0U;
capture_active_ = false;
portEXIT_CRITICAL(&capture_lock_);
capture_active_ = false;
playing_ = false;
dial_tone_active_ = false;
dial_tone_gain_ = 0.0f;
dial_tone_phase_ = 0.0f;
next_dial_tone_push_ms_ = 0;
closeDialToneWav();
dial_tone_wav_ready_ = false;
dial_tone_wav_path_ = String(kDialToneWavPrefix) + String(_config.sample_rate) + ".wav";
if (kDialToneUseWav) {
ensureDialToneWav();
}
startTask();
Serial.printf("[AudioEngine] ready (full_duplex=%s)\n",
supportsFullDuplex() ? "true" : "false");
return true;
@@ -89,11 +157,67 @@ void AudioEngine::end() {
if (!driver_installed_) {
return;
}
stopCapture();
stopTask();
stopDialTone();
portENTER_CRITICAL(&capture_lock_);
capture_clients_mask_ = 0U;
capture_active_ = false;
portEXIT_CRITICAL(&capture_lock_);
closeDialToneWav();
if (i2s_io_mutex_ != nullptr) {
vSemaphoreDelete(i2s_io_mutex_);
i2s_io_mutex_ = nullptr;
}
i2s_driver_uninstall(_config.port);
driver_installed_ = false;
}
void AudioEngine::audioTaskFn(void* arg) {
auto* self = static_cast<AudioEngine*>(arg);
while (self != nullptr && self->running_task_) {
self->tick();
const bool audio_busy = self->capture_active_ || self->dial_tone_active_ ||
(self->dial_tone_gain_ > 0.0005f) || self->playing_;
vTaskDelay(pdMS_TO_TICKS(audio_busy ? 1U : 6U));
}
if (self != nullptr) {
self->task_handle_ = nullptr;
}
vTaskDelete(nullptr);
}
void AudioEngine::startTask() {
if (!driver_installed_ || task_handle_ != nullptr) {
return;
}
running_task_ = true;
const BaseType_t rc = xTaskCreatePinnedToCore(
audioTaskFn,
"audio_engine",
kAudioTaskStackWords,
this,
kAudioTaskPriority,
&task_handle_,
tskNO_AFFINITY);
if (rc != pdPASS) {
running_task_ = false;
task_handle_ = nullptr;
Serial.println("[AudioEngine] failed to start audio task");
}
}
void AudioEngine::stopTask() {
if (task_handle_ == nullptr) {
return;
}
running_task_ = false;
vTaskDelay(pdMS_TO_TICKS(10));
if (task_handle_ != nullptr) {
vTaskDelete(task_handle_);
task_handle_ = nullptr;
}
}
bool AudioEngine::playFile(const char* path) {
if (!driver_installed_ || path == nullptr || path[0] == '\0') {
return false;
@@ -105,21 +229,47 @@ bool AudioEngine::playFile(const char* path) {
return true;
}
bool AudioEngine::startCapture() {
bool AudioEngine::requestCapture(CaptureClient client) {
if (!driver_installed_) {
return false;
}
const uint8_t bit = static_cast<uint8_t>(client);
if (bit == 0U) {
return false;
}
if (!supportsFullDuplex() && playing_) {
return false;
}
capture_active_ = true;
portENTER_CRITICAL(&capture_lock_);
capture_clients_mask_ = static_cast<uint8_t>(capture_clients_mask_ | bit);
capture_active_ = (capture_clients_mask_ != 0U);
portEXIT_CRITICAL(&capture_lock_);
return true;
}
void AudioEngine::releaseCapture(CaptureClient client) {
const uint8_t bit = static_cast<uint8_t>(client);
if (bit == 0U) {
return;
}
portENTER_CRITICAL(&capture_lock_);
capture_clients_mask_ = static_cast<uint8_t>(capture_clients_mask_ & static_cast<uint8_t>(~bit));
capture_active_ = (capture_clients_mask_ != 0U);
portEXIT_CRITICAL(&capture_lock_);
}
bool AudioEngine::startCapture() {
return requestCapture(CAPTURE_CLIENT_GENERIC);
}
size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
if (!capture_active_ || !driver_installed_ || dst == nullptr || samples == 0) {
return 0;
}
if (!lockI2s()) {
return 0;
}
metrics_.frames_requested += static_cast<uint32_t>(samples);
const uint32_t start_ms = millis();
@@ -131,6 +281,7 @@ size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
metrics_.drop_frames += static_cast<uint32_t>(samples);
metrics_.last_latency_ms = millis() - start_ms;
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
unlockI2s();
return 0;
}
const size_t read_samples = bytes_read / sizeof(int16_t);
@@ -140,11 +291,81 @@ size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
}
metrics_.last_latency_ms = millis() - start_ms;
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
unlockI2s();
return read_samples;
}
size_t AudioEngine::readCaptureFrameNonBlocking(int16_t* dst, size_t samples) {
if (!capture_active_ || !driver_installed_ || dst == nullptr || samples == 0) {
return 0;
}
if (!lockI2s()) {
return 0;
}
metrics_.frames_requested += static_cast<uint32_t>(samples);
const size_t byte_count = samples * sizeof(int16_t);
size_t bytes_read = 0;
if (i2s_read(_config.port, dst, byte_count, &bytes_read, 0) != ESP_OK || bytes_read == 0) {
unlockI2s();
return 0;
}
const size_t read_samples = bytes_read / sizeof(int16_t);
metrics_.frames_read += static_cast<uint32_t>(read_samples);
if (read_samples < samples) {
metrics_.drop_frames += static_cast<uint32_t>(samples - read_samples);
}
unlockI2s();
return read_samples;
}
size_t AudioEngine::writePlaybackFrame(const int16_t* src, size_t samples) {
if (!driver_installed_ || src == nullptr || samples == 0) {
return 0;
}
if (!lockI2s()) {
return 0;
}
const size_t byte_count = samples * sizeof(int16_t);
size_t bytes_written = 0;
if (i2s_write(_config.port, src, byte_count, &bytes_written, kI2sWriteTimeoutTicks) != ESP_OK) {
unlockI2s();
return 0;
}
unlockI2s();
return bytes_written / sizeof(int16_t);
}
void AudioEngine::stopCapture() {
capture_active_ = false;
releaseCapture(CAPTURE_CLIENT_GENERIC);
}
bool AudioEngine::startDialTone() {
if (!driver_installed_) {
return false;
}
if (kDialToneUseWav) {
ensureDialToneWav();
}
const bool was_active = dial_tone_active_;
dial_tone_active_ = true;
if (!was_active && dial_tone_gain_ <= 0.0001f) {
dial_tone_phase_ = 0.0f;
}
next_dial_tone_push_ms_ = 0;
return true;
}
void AudioEngine::stopDialTone() {
dial_tone_active_ = false;
dial_tone_gain_ = 0.0f;
next_dial_tone_push_ms_ = 0;
}
bool AudioEngine::isDialToneActive() const {
return dial_tone_active_ || dial_tone_gain_ > 0.001f;
}
bool AudioEngine::supportsFullDuplex() const {
@@ -167,8 +388,236 @@ void AudioEngine::tick() {
if (playing_ && millis() >= play_until_ms_) {
playing_ = false;
}
if (!driver_installed_ || playing_) {
return;
}
const bool tone_requested = dial_tone_active_;
const bool tone_tail_active = dial_tone_gain_ > 0.0005f;
if (!tone_requested && !tone_tail_active) {
return;
}
const uint32_t now = millis();
if (next_dial_tone_push_ms_ != 0 && now < next_dial_tone_push_ms_) {
return;
}
int16_t frame[kDialToneChunkFrames * kStereoChannels] = {0};
const float phase_step = (kTwoPi * kDialToneHz) / static_cast<float>(_config.sample_rate);
const size_t chunk_samples = kDialToneChunkFrames * kStereoChannels;
const size_t chunk_bytes = chunk_samples * sizeof(int16_t);
uint8_t pcm_raw[kDialToneChunkFrames * kStereoChannels * sizeof(int16_t)] = {0};
bool wav_ready = false;
size_t wav_filled = 0;
if (kDialToneUseWav && ensureDialToneWav() && openDialToneWav()) {
while (wav_filled < chunk_bytes) {
const int got = dial_tone_file_.read(pcm_raw + wav_filled, chunk_bytes - wav_filled);
if (got > 0) {
wav_filled += static_cast<size_t>(got);
continue;
}
if (!dial_tone_file_.seek(dial_tone_wav_data_offset_)) {
break;
}
}
wav_ready = (wav_filled == chunk_bytes);
}
const float attack_step =
1.0f / std::max(1.0f, (static_cast<float>(_config.sample_rate) * (kDialToneAttackMs / 1000.0f)));
const float release_step =
1.0f / std::max(1.0f, (static_cast<float>(_config.sample_rate) * (kDialToneReleaseMs / 1000.0f)));
for (size_t i = 0; i < kDialToneChunkFrames; ++i) {
if (dial_tone_active_) {
dial_tone_gain_ = std::min(1.0f, dial_tone_gain_ + attack_step);
} else {
dial_tone_gain_ = std::max(0.0f, dial_tone_gain_ - release_step);
}
int16_t sample_l = 0;
int16_t sample_r = 0;
if (wav_ready) {
const size_t l_idx = i * kStereoChannels;
const size_t r_idx = l_idx + 1;
const size_t l_byte = l_idx * sizeof(int16_t);
const size_t r_byte = r_idx * sizeof(int16_t);
sample_l = static_cast<int16_t>(
static_cast<uint16_t>(pcm_raw[l_byte]) |
static_cast<uint16_t>(static_cast<uint16_t>(pcm_raw[l_byte + 1]) << 8));
sample_r = static_cast<int16_t>(
static_cast<uint16_t>(pcm_raw[r_byte]) |
static_cast<uint16_t>(static_cast<uint16_t>(pcm_raw[r_byte + 1]) << 8));
} else {
const int16_t sample = static_cast<int16_t>(std::sin(dial_tone_phase_) * static_cast<float>(kDialToneAmplitude));
sample_l = sample;
sample_r = sample;
dial_tone_phase_ += phase_step;
if (dial_tone_phase_ >= kTwoPi) {
dial_tone_phase_ -= kTwoPi;
}
}
const float gain = dial_tone_gain_ * kDialToneLinearGain;
frame[(i * kStereoChannels)] = clampInt16(static_cast<float>(sample_l) * gain);
frame[(i * kStereoChannels) + 1] = clampInt16(static_cast<float>(sample_r) * gain);
}
const size_t requested_samples = kDialToneChunkFrames * kStereoChannels;
const size_t written_samples = writePlaybackFrame(frame, requested_samples);
if (written_samples == 0U) {
// Retry fast when TX queue/mutex was temporarily unavailable.
next_dial_tone_push_ms_ = now + 1U;
return;
}
const size_t written_frames = written_samples / kStereoChannels;
const uint32_t chunk_ms = static_cast<uint32_t>((1000U * written_frames) / _config.sample_rate);
next_dial_tone_push_ms_ = now + (chunk_ms == 0U ? 1U : chunk_ms);
}
const AudioConfig& AudioEngine::config() const {
return _config;
}
bool AudioEngine::ensureSpiffsMounted() {
if (spiffs_mount_attempted_) {
return spiffs_ready_;
}
spiffs_mount_attempted_ = true;
spiffs_ready_ = SPIFFS.begin(false);
if (!spiffs_ready_) {
Serial.println("[AudioEngine] SPIFFS not available (dial tone WAV fallback)");
}
return spiffs_ready_;
}
bool AudioEngine::generateDialToneWav(const char* path) {
if (path == nullptr || path[0] == '\0') {
return false;
}
if (!ensureSpiffsMounted()) {
return false;
}
SPIFFS.remove(path);
File file = SPIFFS.open(path, FILE_WRITE);
if (!file) {
Serial.printf("[AudioEngine] cannot create %s\n", path);
return false;
}
const uint16_t channels = static_cast<uint16_t>(kStereoChannels);
const uint16_t bits_per_sample = 16;
const uint32_t bytes_per_sample = bits_per_sample / 8U;
const uint32_t frames = _config.sample_rate * kDialToneWavSeconds;
const uint32_t data_bytes = frames * channels * bytes_per_sample;
const uint32_t riff_size = 36U + data_bytes;
const uint32_t byte_rate = _config.sample_rate * channels * bytes_per_sample;
const uint16_t block_align = static_cast<uint16_t>(channels * bytes_per_sample);
auto write_u16 = [&](uint16_t v) {
uint8_t b[2] = {static_cast<uint8_t>(v & 0xFFU), static_cast<uint8_t>((v >> 8) & 0xFFU)};
return file.write(b, sizeof(b)) == sizeof(b);
};
auto write_u32 = [&](uint32_t v) {
uint8_t b[4] = {static_cast<uint8_t>(v & 0xFFU), static_cast<uint8_t>((v >> 8) & 0xFFU),
static_cast<uint8_t>((v >> 16) & 0xFFU), static_cast<uint8_t>((v >> 24) & 0xFFU)};
return file.write(b, sizeof(b)) == sizeof(b);
};
bool ok = true;
ok &= file.write(reinterpret_cast<const uint8_t*>("RIFF"), 4) == 4;
ok &= write_u32(riff_size);
ok &= file.write(reinterpret_cast<const uint8_t*>("WAVE"), 4) == 4;
ok &= file.write(reinterpret_cast<const uint8_t*>("fmt "), 4) == 4;
ok &= write_u32(16U);
ok &= write_u16(1U);
ok &= write_u16(channels);
ok &= write_u32(_config.sample_rate);
ok &= write_u32(byte_rate);
ok &= write_u16(block_align);
ok &= write_u16(bits_per_sample);
ok &= file.write(reinterpret_cast<const uint8_t*>("data"), 4) == 4;
ok &= write_u32(data_bytes);
if (!ok) {
file.close();
SPIFFS.remove(path);
return false;
}
int16_t pcm[kDialToneChunkFrames * kStereoChannels] = {0};
const float phase_step = (kTwoPi * kDialToneHz) / static_cast<float>(_config.sample_rate);
float phase = 0.0f;
uint32_t written_frames = 0;
while (written_frames < frames) {
const uint32_t remaining = frames - written_frames;
const uint32_t this_chunk = std::min<uint32_t>(static_cast<uint32_t>(kDialToneChunkFrames), remaining);
for (uint32_t i = 0; i < this_chunk; ++i) {
const int16_t s = static_cast<int16_t>(std::sin(phase) * static_cast<float>(kDialToneAmplitude));
pcm[(i * kStereoChannels)] = s;
pcm[(i * kStereoChannels) + 1] = s;
phase += phase_step;
if (phase >= kTwoPi) {
phase -= kTwoPi;
}
}
const size_t bytes = static_cast<size_t>(this_chunk * kStereoChannels * sizeof(int16_t));
if (file.write(reinterpret_cast<const uint8_t*>(pcm), bytes) != bytes) {
file.close();
SPIFFS.remove(path);
return false;
}
written_frames += this_chunk;
}
file.close();
return true;
}
bool AudioEngine::ensureDialToneWav() {
if (!dial_tone_wav_path_.isEmpty() && dial_tone_wav_ready_) {
return true;
}
if (dial_tone_wav_path_.isEmpty()) {
dial_tone_wav_path_ = String(kDialToneWavPrefix) + String(_config.sample_rate) + ".wav";
}
if (!ensureSpiffsMounted()) {
return false;
}
if (!SPIFFS.exists(dial_tone_wav_path_.c_str())) {
if (!generateDialToneWav(dial_tone_wav_path_.c_str())) {
return false;
}
}
dial_tone_wav_ready_ = SPIFFS.exists(dial_tone_wav_path_.c_str());
return dial_tone_wav_ready_;
}
bool AudioEngine::openDialToneWav() {
if (!dial_tone_wav_ready_) {
return false;
}
if (dial_tone_file_) {
return true;
}
dial_tone_file_ = SPIFFS.open(dial_tone_wav_path_.c_str(), FILE_READ);
if (!dial_tone_file_) {
dial_tone_wav_ready_ = false;
return false;
}
if (!dial_tone_file_.seek(dial_tone_wav_data_offset_)) {
dial_tone_file_.close();
dial_tone_wav_ready_ = false;
return false;
}
return true;
}
void AudioEngine::closeDialToneWav() {
if (dial_tone_file_) {
dial_tone_file_.close();
}
}
+45
View File
@@ -2,7 +2,11 @@
#define AUDIO_ENGINE_H
#include <Arduino.h>
#include <FS.h>
#include <driver/i2s.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
#include "core/PlatformProfile.h"
@@ -33,14 +37,27 @@ AudioConfig defaultAudioConfigForProfile(BoardProfile profile);
class AudioEngine {
public:
enum CaptureClient : uint8_t {
CAPTURE_CLIENT_GENERIC = 0x01,
CAPTURE_CLIENT_TELEPHONY = 0x02,
CAPTURE_CLIENT_BLUETOOTH = 0x04,
};
virtual ~AudioEngine();
AudioEngine();
virtual bool begin(const AudioConfig& config);
virtual void end();
virtual bool playFile(const char* path);
virtual bool requestCapture(CaptureClient client);
virtual void releaseCapture(CaptureClient client);
virtual bool startCapture();
virtual size_t readCaptureFrame(int16_t* dst, size_t samples);
virtual size_t readCaptureFrameNonBlocking(int16_t* dst, size_t samples);
virtual size_t writePlaybackFrame(const int16_t* src, size_t samples);
virtual void stopCapture();
virtual bool startDialTone();
virtual void stopDialTone();
virtual bool isDialToneActive() const;
virtual bool supportsFullDuplex() const;
virtual bool isPlaying() const;
virtual AudioRuntimeMetrics metrics() const;
@@ -49,15 +66,43 @@ public:
const AudioConfig& config() const;
private:
static void audioTaskFn(void* arg);
void startTask();
void stopTask();
bool lockI2s() const;
void unlockI2s() const;
bool ensureDialToneWav();
bool ensureSpiffsMounted();
bool generateDialToneWav(const char* path);
bool openDialToneWav();
void closeDialToneWav();
bool driver_installed_ = false;
bool capture_active_ = false;
uint8_t capture_clients_mask_ = 0;
bool playing_ = false;
bool dial_tone_active_ = false;
volatile bool running_task_ = false;
float dial_tone_gain_ = 0.0f;
float dial_tone_phase_ = 0.0f;
uint32_t next_dial_tone_push_ms_ = 0;
uint32_t play_until_ms_ = 0;
AudioConfig _config;
FeatureMatrix features_;
AudioRuntimeMetrics metrics_;
i2s_config_t _i2s_config{};
i2s_pin_config_t _i2s_pins{};
mutable SemaphoreHandle_t i2s_io_mutex_ = nullptr;
TaskHandle_t task_handle_ = nullptr;
static constexpr uint16_t kAudioTaskStackWords = 4096;
static constexpr uint8_t kAudioTaskPriority = 5;
bool spiffs_mount_attempted_ = false;
bool spiffs_ready_ = false;
bool dial_tone_wav_ready_ = false;
String dial_tone_wav_path_;
File dial_tone_file_;
uint32_t dial_tone_wav_data_offset_ = 44;
portMUX_TYPE capture_lock_ = portMUX_INITIALIZER_UNLOCKED;
};
#endif // AUDIO_ENGINE_H
+60 -17
View File
@@ -3,28 +3,65 @@
#include <Wire.h>
#include <algorithm>
#include <initializer_list>
namespace {
uint8_t clampVolumeToReg(uint8_t percent) {
const uint8_t clamped = std::min<uint8_t>(100, percent);
return static_cast<uint8_t>((clamped * 0x21U) / 100U);
// ES8388 DAC digital volume registers (0x1A/0x1B):
// 0x00 = 0 dB, 0xC0 = -96 dB.
return static_cast<uint8_t>(((100U - clamped) * 0xC0U) / 100U);
}
} // namespace
bool Es8388Driver::begin(int sda_pin, int scl_pin, uint8_t address) {
address_ = address;
Wire.begin(sda_pin, scl_pin);
Wire.setClock(100000);
// Minimal ES8388 init sequence for playback + capture paths.
const bool ok = writeReg(0x00, 0x80) && // reset
writeReg(0x01, 0x58) &&
writeReg(0x02, 0x50) &&
writeReg(0x04, 0xC0) &&
writeReg(0x08, 0x00) &&
writeReg(0x0A, 0x00) &&
writeReg(0x17, 0x18) &&
writeReg(0x19, 0x02);
const auto write_sequence = [&](std::initializer_list<std::pair<uint8_t, uint8_t>> seq) {
bool ok = true;
for (const auto& it : seq) {
ok &= writeReg(it.first, it.second);
}
return ok;
};
// ES8388 setup aligned with Espressif ADF defaults (codec in I2S slave mode).
// Register map: https://github.com/espressif/esp-adf (es8388 driver).
const bool ok = write_sequence(
{
{0x19, 0x04}, // DACCONTROL3: mute during init.
{0x01, 0x50}, // CONTROL2
{0x02, 0x00}, // CHIPPOWER normal mode
{0x35, 0xA0}, // Disable internal DLL for low sample rates stability.
{0x37, 0xD0},
{0x39, 0xD0},
{0x08, 0x00}, // MASTERMODE: codec slave
{0x04, 0xC0}, // DACPOWER: DAC outputs disabled while configuring
{0x00, 0x12}, // CONTROL1: play+record mode
{0x17, 0x18}, // DACCONTROL1: 16-bit I2S
{0x18, 0x02}, // DACCONTROL2: single speed, ratio 256
{0x26, 0x00}, // DACCONTROL16: DAC to mixer
{0x27, 0x90}, // DACCONTROL17: left DAC to left mixer
{0x2A, 0x90}, // DACCONTROL20: right DAC to right mixer
{0x2B, 0x80}, // DACCONTROL21
{0x2D, 0x00}, // DACCONTROL23
{0x2E, 0x24}, // DACCONTROL24: analog output boosted (bench tuning)
{0x2F, 0x24}, // DACCONTROL25: analog output boosted (bench tuning)
{0x30, 0x00}, // DACCONTROL26
{0x31, 0x00}, // DACCONTROL27
{0x04, 0x3C}, // DACPOWER: enable LOUT/ROUT
{0x03, 0xFF}, // ADCPOWER: power down before ADC config
{0x09, 0xBB}, // ADCCONTROL1: PGA gain defaults
{0x0A, 0x00}, // ADCCONTROL2: LIN1/RIN1
{0x0B, 0x02}, // ADCCONTROL3
{0x0C, 0x0C}, // ADCCONTROL4: 16-bit I2S
{0x0D, 0x02}, // ADCCONTROL5: single speed, ratio 256
{0x10, 0x00}, // ADCCONTROL8: 0 dB
{0x11, 0x00}, // ADCCONTROL9: 0 dB
{0x03, 0x09}, // ADCPOWER: enable ADC path
});
ready_ = ok;
if (!ready_) {
@@ -43,7 +80,8 @@ bool Es8388Driver::setVolume(uint8_t percent) {
return false;
}
const uint8_t reg = clampVolumeToReg(volume_);
return writeReg(0x2B, reg) && writeReg(0x2C, reg);
// DAC digital volume controls.
return writeReg(0x1A, reg) && writeReg(0x1B, reg);
}
bool Es8388Driver::setMute(bool enabled) {
@@ -51,7 +89,8 @@ bool Es8388Driver::setMute(bool enabled) {
if (!ready_) {
return false;
}
return writeReg(0x2F, enabled ? 0x01 : 0x00);
// DACCONTROL3 bit2 is mute.
return writeReg(0x19, enabled ? 0x04 : 0x00);
}
bool Es8388Driver::setRoute(const String& route) {
@@ -61,14 +100,18 @@ bool Es8388Driver::setRoute(const String& route) {
return false;
}
if (route_ == "bluetooth") {
return writeReg(0x30, 0x01);
// Current hardware path uses DAC->mixer->line output for both RTC and BT.
// Keep route as metadata and ensure output path is enabled.
if (route_ == "bluetooth" || route_ == "rtc") {
return writeReg(0x26, 0x00) && writeReg(0x27, 0x90) && writeReg(0x2A, 0x90) &&
writeReg(0x04, 0x3C);
}
if (route_ == "none") {
return writeReg(0x30, 0x02);
return writeReg(0x04, 0xC0);
}
route_ = "rtc";
return writeReg(0x30, 0x00);
return writeReg(0x26, 0x00) && writeReg(0x27, 0x90) && writeReg(0x2A, 0x90) &&
writeReg(0x04, 0x3C);
}
bool Es8388Driver::isReady() const {
File diff suppressed because it is too large Load Diff
+54
View File
@@ -7,10 +7,13 @@
#include "core/PlatformProfile.h"
class AudioEngine;
class BluetoothManager {
public:
BluetoothManager();
bool begin(BoardProfile profile);
void setAudioBridge(AudioEngine* audio);
bool connect(const char* mac);
bool disconnect();
bool isConnected() const;
@@ -18,6 +21,17 @@ public:
bool stopHFP();
bool startBLE();
bool stopBLE();
void tick();
bool setDiscoverable(bool enabled);
bool setAutoReconnectEnabled(bool enabled);
bool autoReconnectEnabled() const;
void suspendReconnect(uint32_t duration_ms);
bool dial(const String& number);
bool redial();
bool answerCall();
bool hangupCall();
bool queryCurrentCalls();
bool syncPbapContacts();
void logStatus() const;
void statusToJson(JsonObject obj) const;
void setSecurity(bool enabled);
@@ -25,37 +39,77 @@ public:
void publishBleStatus();
String executeBleCommand(const String& cmd);
void handleHfpEvent(int event, const void* param);
void onHfpIncomingAudio(const uint8_t* buf, uint32_t len);
uint32_t onHfpOutgoingAudio(uint8_t* buf, uint32_t len);
void onBleClientConnected(bool connected);
void onGapAuthComplete(const uint8_t remote_bda[6], bool success);
void onGapAclDisconnected(const uint8_t remote_bda[6], uint16_t reason);
bool isSecurityEnabled() const;
bool isHfpActive() const;
bool isBleActive() const;
bool isDiscoverable() const;
bool isPbapSupported() const;
String callState() const;
String peerMac() const;
private:
bool ensureBtStackReady();
bool ensureHfpClientReady();
bool requireCallControlReady(const char* operation);
bool requestHfpConnect(const char* reason);
bool issueDialRequest(const String& dialed, const char* event_name);
bool loadBondedPeerFromStack();
bool parseMac(const String& mac, uint8_t out[6]) const;
String formatMac(const uint8_t* mac) const;
void applyDiscoverablePolicy();
void onHfpAudioStateChanged(bool connected);
void requestAudioIfNeeded(const char* reason);
bool hasAnyBluetoothClientConnected() const;
FeatureMatrix features_;
bool stack_ready_;
bool hfp_initialized_;
bool hfp_requested_;
bool auto_reconnect_enabled_;
bool ble_stack_initialized_;
bool ble_service_ready_;
bool ble_client_connected_;
bool connected_;
bool hfp_active_;
bool slc_connected_;
bool call_setup_active_;
bool ble_active_;
bool discoverable_;
bool security_enabled_;
bool pbap_supported_;
bool pbap_synced_;
bool hfp_data_callback_registered_;
bool hfp_audio_link_up_;
String peer_mac_;
uint8_t peer_addr_[6];
bool peer_addr_valid_;
bool hfp_connect_inflight_;
uint8_t hfp_reconnect_attempts_;
uint32_t next_hfp_reconnect_ms_;
uint32_t reconnect_suspended_until_ms_;
uint32_t slc_wait_deadline_ms_;
uint32_t next_audio_retry_ms_;
uint32_t next_pending_dial_retry_ms_;
volatile bool pending_status_publish_;
volatile bool pending_discoverable_policy_;
String call_state_;
String pending_dial_number_;
String last_dialed_number_;
String pbap_last_error_;
String last_hfp_event_;
String last_ble_event_;
String last_error_;
String ble_last_command_;
String ble_last_response_;
uint32_t sco_rx_bytes_;
uint32_t sco_tx_bytes_;
uint32_t last_sco_activity_ms_;
AudioEngine* audio_bridge_;
std::function<String(const String&)> ble_command_handler_;
};
+32
View File
@@ -0,0 +1,32 @@
#ifndef BLUETOOTH_I_BLUETOOTH_BACKEND_H
#define BLUETOOTH_I_BLUETOOTH_BACKEND_H
#include <Arduino.h>
#include "core/PlatformProfile.h"
struct PbapContact {
String display_name;
String phone_number;
};
class IBluetoothBackend {
public:
virtual ~IBluetoothBackend() = default;
virtual bool begin(BoardProfile profile) = 0;
virtual bool connect(const String& mac) = 0;
virtual bool disconnect() = 0;
virtual bool setDiscoverable(bool enabled) = 0;
virtual bool dial(const String& number) = 0;
virtual bool redial() = 0;
virtual bool answer() = 0;
virtual bool hangup() = 0;
virtual bool queryCalls() = 0;
virtual bool syncPbap() = 0;
virtual bool isPbapSupported() const = 0;
};
#endif // BLUETOOTH_I_BLUETOOTH_BACKEND_H
+15 -3
View File
@@ -271,7 +271,7 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
std::vector<int> used;
used.reserve(11);
const int critical_pins[] = {
const int required_pins[] = {
cfg.i2s_bck,
cfg.i2s_ws,
cfg.i2s_dout,
@@ -281,11 +281,10 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
cfg.slic_rm,
cfg.slic_fr,
cfg.slic_shk,
cfg.slic_line,
cfg.slic_pd,
};
for (int pin : critical_pins) {
for (int pin : required_pins) {
if (pin < 0 || pin > 39) {
error = "invalid_pin_range";
return false;
@@ -297,6 +296,19 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
used.push_back(pin);
}
// Optional legacy line-enable pin, retired by default (-1).
if (cfg.slic_line != -1) {
if (cfg.slic_line < 0 || cfg.slic_line > 39) {
error = "invalid_pin_range";
return false;
}
if (std::find(used.begin(), used.end(), cfg.slic_line) != used.end()) {
error = "pin_conflict";
return false;
}
used.push_back(cfg.slic_line);
}
error = "";
return true;
}
+2 -2
View File
@@ -18,7 +18,7 @@ struct A252PinsConfig {
int slic_rm = 22;
int slic_fr = 19;
int slic_shk = 36;
int slic_line = 23;
int slic_line = -1;
int slic_pd = 18;
bool hook_active_high = false;
};
@@ -27,7 +27,7 @@ struct A252AudioConfig {
uint32_t sample_rate = 16000;
uint8_t bits_per_sample = 16;
bool enable_capture = true;
uint8_t volume = 80;
uint8_t volume = 90;
bool mute = false;
String route = "rtc";
};
+306 -20
View File
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include <esp_log.h>
#include "audio/AudioEngine.h"
#include "audio/Es8388Driver.h"
@@ -19,6 +20,10 @@
namespace {
constexpr uint32_t kSerialBaud = 115200;
constexpr int kAudioAmpEnablePin = 21;
constexpr char kBootLogTag[] = "RTC_BOOT";
constexpr uint32_t kBootWifiConnectTimeoutMs = 2500;
constexpr bool kPrintHelpOnBoot = false;
BoardProfile g_profile = BoardProfile::ESP32_A252;
FeatureMatrix g_features = getFeatureMatrix(BoardProfile::ESP32_A252);
@@ -151,6 +156,121 @@ bool extractBridgeCommand(JsonVariantConst payload, String& out_cmd, uint8_t dep
return false;
}
bool buildEspNowEnvelopeCommand(JsonVariantConst payload,
String& out_cmd,
String& out_msg_id,
uint32_t& out_seq,
bool& out_ack_requested) {
out_cmd = "";
out_msg_id = "";
out_seq = 0;
out_ack_requested = true;
if (!payload.is<JsonObjectConst>()) {
return false;
}
JsonObjectConst obj = payload.as<JsonObjectConst>();
if (!obj["type"].is<const char*>()) {
return false;
}
String type = obj["type"] | "";
type.toLowerCase();
if (type != "command" && type != "request" && type != "cmd") {
return false;
}
out_msg_id = obj["msg_id"] | "";
out_seq = obj["seq"] | 0;
out_ack_requested = obj["ack"] | true;
JsonVariantConst body = obj["payload"];
if (body.isNull()) {
return false;
}
if (body.is<const char*>()) {
out_cmd = body.as<const char*>();
out_cmd.trim();
return !out_cmd.isEmpty();
}
if (body.is<JsonObjectConst>()) {
JsonObjectConst body_obj = body.as<JsonObjectConst>();
const String cmd = body_obj["cmd"] | "";
if (!cmd.isEmpty()) {
out_cmd = cmd;
out_cmd.trim();
if (out_cmd.isEmpty()) {
return false;
}
if (!body_obj["args"].isNull()) {
String args;
serializeJson(body_obj["args"], args);
args.trim();
if (!args.isEmpty() && args != "null") {
out_cmd += " ";
out_cmd += args;
}
}
return true;
}
}
return extractBridgeCommand(body, out_cmd);
}
bool buildRtcBlV1BridgeCommand(JsonVariantConst payload,
String& out_cmd,
String& out_request_id,
bool& out_is_v1) {
out_is_v1 = false;
if (!payload.is<JsonObjectConst>()) {
return false;
}
JsonObjectConst obj = payload.as<JsonObjectConst>();
const String proto = obj["proto"] | "";
if (!proto.equalsIgnoreCase("rtcbl/1")) {
return false;
}
const String cmd = obj["cmd"] | "";
if (cmd.isEmpty()) {
return false;
}
out_cmd = cmd;
out_cmd.trim();
if (out_cmd.isEmpty()) {
return false;
}
out_request_id = obj["id"] | "";
out_is_v1 = true;
if (obj["args"].isNull()) {
return true;
}
String args;
serializeJson(obj["args"], args);
args.trim();
if (!args.isEmpty() && args != "null") {
out_cmd += " ";
out_cmd += args;
}
return true;
}
bool isMacAddressString(const String& value) {
uint8_t mac[6] = {0};
return A252ConfigStore::parseMac(value, mac);
}
AudioConfig buildI2sConfig(const A252PinsConfig& pins_cfg, const A252AudioConfig& audio_cfg) {
AudioConfig cfg;
cfg.port = I2S_NUM_0;
@@ -175,14 +295,13 @@ bool applyHardwareConfig() {
.pin_rm = static_cast<uint8_t>(g_pins_cfg.slic_rm),
.pin_fr = static_cast<uint8_t>(g_pins_cfg.slic_fr),
.pin_shk = static_cast<uint8_t>(g_pins_cfg.slic_shk),
.pin_line_enable = static_cast<int8_t>(g_pins_cfg.slic_line),
.pin_line_enable = static_cast<int8_t>(-1),
.pin_pd = static_cast<int8_t>(g_pins_cfg.slic_pd),
.hook_active_high = g_pins_cfg.hook_active_high,
};
const bool slic_ok = g_slic.begin(slic_pins);
g_slic.setPowerDown(false);
g_slic.setLineEnabled(true);
g_slic.setRing(false);
const bool codec_ok = g_codec.begin(g_pins_cfg.es8388_sda, g_pins_cfg.es8388_scl);
@@ -195,6 +314,12 @@ bool applyHardwareConfig() {
g_audio.resetMetrics();
g_telephony.begin(g_profile, g_slic, g_audio);
g_telephony.setDialCallback([](const String& number) {
return g_bt.dial(number);
});
g_telephony.setAnswerCallback([]() {
return g_bt.answerCall();
});
Serial.printf("[RTC_BL_PHONE] HW init slic=%s codec=%s audio=%s\n",
slic_ok ? "ok" : "fail",
@@ -216,6 +341,7 @@ void appendAudioMetrics(JsonObject root) {
JsonObject audio = root["audio"].to<JsonObject>();
audio["full_duplex"] = g_audio.supportsFullDuplex();
audio["dial_tone_active"] = g_audio.isDialToneActive();
audio["frames"] = metrics.frames_read;
audio["underrun"] = metrics.underrun_count;
audio["drop"] = metrics.drop_frames;
@@ -295,9 +421,6 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
if (patch["slic"]["shk"].is<int>()) {
next.slic_shk = patch["slic"]["shk"].as<int>();
}
if (patch["slic"]["line"].is<int>()) {
next.slic_line = patch["slic"]["line"].as<int>();
}
if (patch["slic"]["pd"].is<int>()) {
next.slic_pd = patch["slic"]["pd"].as<int>();
}
@@ -334,9 +457,6 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
if (patch["slic_shk"].is<int>()) {
next.slic_shk = patch["slic_shk"].as<int>();
}
if (patch["slic_line"].is<int>()) {
next.slic_line = patch["slic_line"].as<int>();
}
if (patch["slic_pd"].is<int>()) {
next.slic_pd = patch["slic_pd"].as<int>();
}
@@ -344,6 +464,9 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
next.hook_active_high = patch["hook_active_high"].as<bool>();
}
// LINE enable logic is retired on A252 bench wiring.
next.slic_line = -1;
if (!A252ConfigStore::validatePins(next, error)) {
return false;
}
@@ -476,6 +599,26 @@ void registerCommands() {
return makeResponse(true, "RESET_METRICS");
});
g_dispatcher.registerCommand("TONE_ON", [](const String&) {
return makeResponse(g_audio.startDialTone(), "TONE_ON");
});
g_dispatcher.registerCommand("TONE_OFF", [](const String&) {
g_audio.stopDialTone();
return makeResponse(true, "TONE_OFF");
});
g_dispatcher.registerCommand("AMP_ON", [](const String&) {
// Locked polarity: AMP_EN active LOW on GPIO21.
digitalWrite(kAudioAmpEnablePin, LOW);
return makeResponse(true, "AMP_ON");
});
g_dispatcher.registerCommand("AMP_OFF", [](const String&) {
digitalWrite(kAudioAmpEnablePin, HIGH);
return makeResponse(true, "AMP_OFF");
});
g_dispatcher.registerCommand("WIFI_CONNECT", [](const String& args) {
String ssid;
String password_raw;
@@ -508,6 +651,8 @@ void registerCommands() {
});
g_dispatcher.registerCommand("WIFI_SCAN", [](const String&) {
// Guard BT/HFP reconnect while WiFi allocates scan resources.
g_bt.suspendReconnect(8000U);
JsonDocument doc;
JsonObject root = doc.to<JsonObject>();
root["ok"] = true;
@@ -646,6 +791,44 @@ void registerCommands() {
return makeResponse(ok, "BT_HFP_DISCONNECT");
});
g_dispatcher.registerCommand("BT_DIAL", [](const String& args) {
String number = args;
number.trim();
if (number.isEmpty()) {
return makeResponse(false, "BT_DIAL invalid_number");
}
return makeResponse(g_bt.dial(number), "BT_DIAL");
});
g_dispatcher.registerCommand("DIAL", [](const String& args) {
String number = args;
number.trim();
if (number.isEmpty()) {
return makeResponse(false, "DIAL invalid_number");
}
return makeResponse(g_bt.dial(number), "DIAL");
});
g_dispatcher.registerCommand("BT_REDIAL", [](const String&) {
return makeResponse(g_bt.redial(), "BT_REDIAL");
});
g_dispatcher.registerCommand("BT_ANSWER", [](const String&) {
return makeResponse(g_bt.answerCall(), "BT_ANSWER");
});
g_dispatcher.registerCommand("BT_HANGUP", [](const String&) {
return makeResponse(g_bt.hangupCall(), "BT_HANGUP");
});
g_dispatcher.registerCommand("BT_CALLS", [](const String&) {
return makeResponse(g_bt.queryCurrentCalls(), "BT_CALLS");
});
g_dispatcher.registerCommand("BT_PBAP_SYNC", [](const String&) {
return makeResponse(g_bt.syncPbapContacts(), "BT_PBAP_SYNC unsupported");
});
g_dispatcher.registerCommand("BT_BLE_START", [](const String&) {
return makeResponse(g_bt.startBLE(), "BT_BLE_START");
});
@@ -654,6 +837,22 @@ void registerCommands() {
return makeResponse(g_bt.stopBLE(), "BT_BLE_STOP");
});
g_dispatcher.registerCommand("BT_DISCOVERABLE_ON", [](const String&) {
return makeResponse(g_bt.setDiscoverable(true), "BT_DISCOVERABLE_ON");
});
g_dispatcher.registerCommand("BT_DISCOVERABLE_OFF", [](const String&) {
return makeResponse(g_bt.setDiscoverable(false), "BT_DISCOVERABLE_OFF");
});
g_dispatcher.registerCommand("BT_AUTO_RECONNECT_ON", [](const String&) {
return makeResponse(g_bt.setAutoReconnectEnabled(true), "BT_AUTO_RECONNECT_ON");
});
g_dispatcher.registerCommand("BT_AUTO_RECONNECT_OFF", [](const String&) {
return makeResponse(g_bt.setAutoReconnectEnabled(false), "BT_AUTO_RECONNECT_OFF");
});
g_dispatcher.registerCommand("BT_STATUS", [](const String&) {
JsonDocument doc;
g_bt.statusToJson(doc.to<JsonObject>());
@@ -734,7 +933,16 @@ void registerCommands() {
void processInboundBridgeCommand(const String& source, const JsonVariantConst& payload) {
String cmd;
if (!extractBridgeCommand(payload, cmd)) {
String request_id;
uint32_t request_seq = 0;
bool request_ack = true;
bool is_envelope_v2 = false;
bool is_rtcbl_v1 = false;
if (buildEspNowEnvelopeCommand(payload, cmd, request_id, request_seq, request_ack)) {
is_envelope_v2 = true;
} else if (!buildRtcBlV1BridgeCommand(payload, cmd, request_id, is_rtcbl_v1) &&
!extractBridgeCommand(payload, cmd)) {
return;
}
@@ -748,6 +956,62 @@ void processInboundBridgeCommand(const String& source, const JsonVariantConst& p
String payload_json;
serializeJson(event, payload_json);
g_mqtt.publish("event", payload_json, false);
if (is_envelope_v2 && request_ack && isMacAddressString(source)) {
JsonDocument response;
response["msg_id"] = request_id.isEmpty() ? String(millis()) : request_id;
response["seq"] = request_seq;
response["type"] = "ack";
response["ack"] = true;
JsonObject ack_payload = response["payload"].to<JsonObject>();
ack_payload["ok"] = result.ok;
ack_payload["code"] = result.code;
ack_payload["error"] = result.ok ? "" : (result.code.isEmpty() ? result.raw : result.code);
if (!result.json.isEmpty()) {
JsonDocument parsed;
if (deserializeJson(parsed, result.json) == DeserializationError::Ok) {
ack_payload["data"].set(parsed.as<JsonVariantConst>());
} else {
ack_payload["data_raw"] = result.json;
}
} else if (!result.raw.isEmpty()) {
ack_payload["data_raw"] = result.raw;
}
String response_payload;
serializeJson(response, response_payload);
g_espnow.sendJson(source, response_payload);
return;
}
if (!is_rtcbl_v1 || !isMacAddressString(source)) {
return;
}
JsonDocument response;
response["proto"] = "rtcbl/1";
response["id"] = request_id;
response["ok"] = result.ok;
response["code"] = result.code;
response["error"] = result.ok ? "" : (result.code.isEmpty() ? result.raw : result.code);
if (!result.json.isEmpty()) {
JsonDocument parsed;
if (deserializeJson(parsed, result.json) == DeserializationError::Ok) {
JsonVariant data = response["data"];
data.set(parsed.as<JsonVariantConst>());
} else {
response["data_raw"] = result.json;
}
} else if (!result.raw.isEmpty()) {
response["data_raw"] = result.raw;
}
String response_payload;
serializeJson(response, response_payload);
g_espnow.sendJson(source, response_payload);
}
void printHelp() {
@@ -792,12 +1056,28 @@ void pollSerial() {
void setup() {
Serial.begin(kSerialBaud);
delay(200);
delay(80);
// Warm up ESP-IDF log/stdout locks from the main task context.
ESP_LOGI(kBootLogTag, "log lock warmup");
printf("[RTC_BL_PHONE] stdio lock warmup\n");
fflush(stdout);
g_profile = BoardProfile::ESP32_A252;
g_features = getFeatureMatrix(g_profile);
A252ConfigStore::loadPins(g_pins_cfg);
// Hard-wired SLIC mapping for live bench tests.
g_pins_cfg.slic_rm = 18;
g_pins_cfg.slic_fr = 5;
g_pins_cfg.slic_shk = 23;
// LINE enable is not used on this wiring.
g_pins_cfg.slic_line = -1;
g_pins_cfg.slic_pd = 19;
g_pins_cfg.hook_active_high = true;
pinMode(kAudioAmpEnablePin, OUTPUT);
digitalWrite(kAudioAmpEnablePin, LOW);
A252ConfigStore::loadAudio(g_audio_cfg);
A252ConfigStore::loadMqtt(g_mqtt_cfg);
A252ConfigStore::loadEspNowPeers(g_peer_store);
@@ -808,23 +1088,17 @@ void setup() {
g_bt.setBleCommandHandler([](const String& cmd) {
return responseToText(executeCommandLine(cmd));
});
g_bt.begin(g_profile);
g_bt.setAudioBridge(&g_audio);
g_mqtt.begin(g_mqtt_cfg);
g_mqtt.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
processInboundBridgeCommand(source, payload);
});
g_espnow.begin(g_peer_store);
g_espnow.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
processInboundBridgeCommand(source, payload);
});
String ssid;
String password;
if (WifiCredentialsStorage::load(ssid, password)) {
g_wifi.connect(ssid, password, 10000, false);
g_wifi.connect(ssid, password, kBootWifiConnectTimeoutMs, false);
if (g_wifi.isConnected() && g_mqtt_cfg.enabled) {
g_mqtt.connectNow();
}
@@ -832,6 +1106,13 @@ void setup() {
g_wifi.ensureFallbackAp();
}
g_espnow.begin(g_peer_store);
g_espnow.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
processInboundBridgeCommand(source, payload);
});
g_bt.begin(g_profile);
g_web.setRateLimitMs(250);
g_web.setAuthEnabled(false);
g_web.setStatusCallback(fillStatusSnapshot);
@@ -842,16 +1123,21 @@ void setup() {
boardProfileToString(g_profile),
g_features.has_bt_classic ? "true" : "false",
g_features.has_full_duplex_i2s ? "true" : "false");
printHelp();
if (kPrintHelpOnBoot) {
printHelp();
}
publishStatusIfConnected();
}
void loop() {
g_telephony.setIncomingRing(g_bt.callState() == "ringing");
g_telephony.tick();
g_wifi.loop();
g_bt.tick();
g_mqtt.tick();
g_espnow.tick();
g_web.handle();
pollSerial();
delay(10);
delay(1);
}
#endif // UNIT_TEST
+22 -1
View File
@@ -2,11 +2,23 @@
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>
#include <algorithm>
EspNowBridge* EspNowBridge::instance_ = nullptr;
namespace {
void enforceEspNowCoexPolicy() {
WiFi.setSleep(true);
const esp_err_t err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT && err != ESP_ERR_WIFI_NOT_STARTED) {
Serial.printf("[EspNowBridge] warn: esp_wifi_set_ps(min_modem) failed err=0x%04x\n",
static_cast<unsigned>(err));
}
}
}
EspNowBridge::EspNowBridge() {
instance_ = this;
}
@@ -18,11 +30,20 @@ bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) {
store_ = initial_peers;
WiFi.mode(WIFI_STA);
const wifi_mode_t current_mode = WiFi.getMode();
if (current_mode == WIFI_MODE_NULL) {
WiFi.mode(WIFI_STA);
delay(5);
} else if (current_mode == WIFI_MODE_AP) {
WiFi.mode(WIFI_AP_STA);
delay(5);
}
enforceEspNowCoexPolicy();
if (esp_now_init() != ESP_OK) {
ready_ = false;
return false;
}
enforceEspNowCoexPolicy();
esp_now_register_recv_cb(onDataRecv);
esp_now_register_send_cb(onDataSent);
+311 -5
View File
@@ -1,5 +1,20 @@
#include "telephony/TelephonyService.h"
namespace {
constexpr uint16_t kDtmfFrameSamples = 160U;
constexpr uint32_t kHookHangupMs = 800U;
constexpr uint32_t kHookStabilizeMs = 40U;
constexpr uint32_t kPulseInterDigitGapMs = 420U;
constexpr uint32_t kPulseEdgeDebounceMs = 18U;
constexpr uint32_t kPulseDtmfGuardMs = 900U;
constexpr size_t kDialDigitsTarget = 10U;
constexpr uint32_t kDtmfCaptureStartDelayMs = 0U;
constexpr uint32_t kDtmfReadPeriodMs = 12U;
constexpr uint8_t kDialSourceNone = 0U;
constexpr uint8_t kDialSourceDtmf = 1U;
constexpr uint8_t kDialSourcePulse = 2U;
}
const char* telephonyStateToString(TelephonyState state) {
switch (state) {
case TelephonyState::IDLE:
@@ -20,10 +35,30 @@ TelephonyService::TelephonyService()
features_(getFeatureMatrix(BoardProfile::ESP32_A252)),
slic_(nullptr),
audio_(nullptr),
dial_callback_(nullptr),
answer_callback_(nullptr),
dtmf_(8000U, kDtmfFrameSamples),
state_(TelephonyState::IDLE),
incoming_ring_(false),
ring_phase_on_(false),
ring_cycle_start_ms_(0),
capture_active_(false),
pulse_hook_initialized_(false),
pulse_last_hook_off_(false),
pulse_collecting_(false),
pulse_count_(0),
last_hook_edge_ms_(0),
last_pulse_ms_(0),
dtmf_capture_start_ms_(0),
next_dtmf_read_ms_(0),
off_hook_enter_ms_(0),
last_pulse_edge_ms_(0),
suppress_dial_tone_(false),
dialing_started_(false),
dial_source_(kDialSourceNone),
dial_buffer_(""),
last_digit_ms_(0),
last_dial_error_(""),
message_path_("/welcome.wav") {}
bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEngine& audio) {
@@ -35,25 +70,200 @@ bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEn
incoming_ring_ = false;
ring_phase_on_ = false;
ring_cycle_start_ms_ = millis();
capture_active_ = false;
pulse_hook_initialized_ = false;
pulse_last_hook_off_ = false;
pulse_collecting_ = false;
pulse_count_ = 0;
last_hook_edge_ms_ = 0;
last_pulse_ms_ = 0;
dtmf_capture_start_ms_ = 0;
next_dtmf_read_ms_ = 0;
off_hook_enter_ms_ = 0;
last_pulse_edge_ms_ = 0;
suppress_dial_tone_ = false;
dialing_started_ = false;
dial_source_ = kDialSourceNone;
dial_buffer_ = "";
last_digit_ms_ = 0;
last_dial_error_ = "";
dtmf_.setDigitCallback([this](char digit) {
onDialDigit(digit, false);
});
slic_->setRing(false);
slic_->setLineEnabled(true);
return true;
}
void TelephonyService::setDialCallback(DialCallback cb) {
dial_callback_ = cb;
}
void TelephonyService::setAnswerCallback(AnswerCallback cb) {
answer_callback_ = cb;
}
void TelephonyService::triggerIncomingRing() {
incoming_ring_ = true;
}
void TelephonyService::setIncomingRing(bool active) {
incoming_ring_ = active;
}
void TelephonyService::onDialDigit(char digit, bool from_pulse) {
if (digit < '0' || digit > '9') {
return;
}
const uint32_t now = millis();
if (!from_pulse) {
// Rotary pulse has priority: suppress DTMF captures while pulse edges are active/recent.
const bool pulse_recent =
pulse_collecting_ || pulse_count_ > 0U ||
(last_pulse_edge_ms_ != 0U && (now - last_pulse_edge_ms_) < kPulseDtmfGuardMs);
if (pulse_recent) {
return;
}
}
const uint8_t source = from_pulse ? kDialSourcePulse : kDialSourceDtmf;
if (dial_source_ == kDialSourceNone) {
dial_source_ = source;
} else if (dial_source_ != source) {
// Allow pulse to override an early DTMF false-start (typically tone bleed).
if (from_pulse && dial_source_ == kDialSourceDtmf && dial_buffer_.length() <= 1U) {
dial_buffer_ = "";
last_digit_ms_ = 0;
dial_source_ = source;
} else {
// Keep strict ordering by ignoring mixed-source digits in the same session.
return;
}
}
if (audio_ != nullptr && dial_buffer_.isEmpty() && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
dialing_started_ = true;
if (dial_buffer_.length() >= kDialDigitsTarget) {
dial_buffer_ = "";
}
dial_buffer_ += digit;
last_digit_ms_ = now;
Serial.printf("[Telephony] digit=%c source=%s buffer=%s\n",
digit,
from_pulse ? "pulse" : "dtmf",
dial_buffer_.c_str());
if (dial_buffer_.length() == kDialDigitsTarget) {
commitDialBuffer("len10");
}
}
void TelephonyService::updatePulseDecode(bool hook_off, uint32_t now) {
if (!pulse_hook_initialized_) {
pulse_hook_initialized_ = true;
pulse_last_hook_off_ = hook_off;
last_hook_edge_ms_ = now;
return;
}
if (hook_off == pulse_last_hook_off_) {
return;
}
if ((now - last_pulse_edge_ms_) < kPulseEdgeDebounceMs) {
return;
}
last_pulse_edge_ms_ = now;
// Any valid hook edge during OFF_HOOK indicates dialing activity start.
if (audio_ != nullptr && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
dialing_started_ = true;
if (pulse_last_hook_off_ && !hook_off) {
if (!pulse_collecting_) {
pulse_collecting_ = true;
pulse_count_ = 0;
// Stop dial tone as soon as rotary dialing starts (first pulse edge),
// not only after the first full decoded digit.
if (audio_ != nullptr && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
}
} else if (!pulse_last_hook_off_ && hook_off) {
if (pulse_collecting_ && pulse_count_ < 20U) {
++pulse_count_;
last_pulse_ms_ = now;
}
}
pulse_last_hook_off_ = hook_off;
last_hook_edge_ms_ = now;
}
void TelephonyService::commitDialBuffer(const char* reason) {
if (dial_buffer_.length() != kDialDigitsTarget) {
return;
}
if (audio_ != nullptr && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
const String number = dial_buffer_;
const bool ok = dial_callback_ ? dial_callback_(number) : false;
last_dial_error_ = ok ? "" : "bt_dial_failed";
Serial.printf("[Telephony] dial_trigger reason=%s number=%s ok=%s\n",
reason != nullptr ? reason : "unknown",
number.c_str(),
ok ? "true" : "false");
dial_buffer_ = "";
last_digit_ms_ = 0;
}
void TelephonyService::clearDialSession() {
if (audio_ != nullptr && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
if (audio_ != nullptr && capture_active_) {
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
}
capture_active_ = false;
dtmf_capture_start_ms_ = 0;
next_dtmf_read_ms_ = 0;
off_hook_enter_ms_ = 0;
pulse_hook_initialized_ = false;
pulse_collecting_ = false;
pulse_count_ = 0;
last_hook_edge_ms_ = 0;
last_pulse_ms_ = 0;
last_pulse_edge_ms_ = 0;
dial_source_ = kDialSourceNone;
dialing_started_ = false;
suppress_dial_tone_ = false;
dial_buffer_ = "";
last_digit_ms_ = 0;
}
void TelephonyService::tick() {
if (slic_ == nullptr || audio_ == nullptr) {
return;
}
slic_->tick();
audio_->tick();
const bool hook_off = slic_->isHookOff();
const uint32_t now = millis();
const TelephonyState prev_state = state_;
switch (state_) {
case TelephonyState::IDLE:
if (incoming_ring_ && !hook_off) {
@@ -71,8 +281,12 @@ void TelephonyService::tick() {
incoming_ring_ = false;
ring_phase_on_ = false;
slic_->setRing(false);
audio_->playFile(message_path_);
state_ = TelephonyState::PLAYING_MESSAGE;
const bool answered = answer_callback_ ? answer_callback_() : false;
// While transitioning from incoming ring to call answer, keep dial tone muted
// even if BT answer fails transiently.
suppress_dial_tone_ = true;
last_dial_error_ = answered ? "" : "bt_answer_failed";
state_ = TelephonyState::OFF_HOOK;
break;
}
@@ -99,12 +313,104 @@ void TelephonyService::tick() {
break;
case TelephonyState::OFF_HOOK:
if ((now - off_hook_enter_ms_) >= kHookStabilizeMs) {
updatePulseDecode(hook_off, now);
}
if (!hook_off) {
incoming_ring_ = false;
state_ = TelephonyState::IDLE;
// Stop audible dial tone immediately on hangup, even if we keep
// a short debounce before transitioning back to IDLE.
if (audio_ != nullptr && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
if (audio_ != nullptr && capture_active_) {
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
capture_active_ = false;
}
// Reset dialing session immediately on hangup.
if (!dial_buffer_.isEmpty() || dial_source_ != kDialSourceNone || pulse_collecting_ || pulse_count_ > 0U) {
dial_buffer_ = "";
last_digit_ms_ = 0;
dial_source_ = kDialSourceNone;
dialing_started_ = false;
pulse_collecting_ = false;
pulse_count_ = 0;
last_pulse_ms_ = 0;
}
if ((now - last_hook_edge_ms_) >= kHookHangupMs) {
incoming_ring_ = false;
state_ = TelephonyState::IDLE;
}
break;
}
if (pulse_collecting_ && pulse_count_ > 0U && (now - last_pulse_ms_) >= kPulseInterDigitGapMs) {
const uint8_t count = pulse_count_;
pulse_collecting_ = false;
pulse_count_ = 0;
const char digit = (count == 10U) ? '0' : ((count >= 1U && count <= 9U) ? static_cast<char>('0' + count)
: '\0');
if (digit != '\0') {
onDialDigit(digit, true);
}
}
if (!capture_active_ && now >= dtmf_capture_start_ms_) {
capture_active_ = audio_->requestCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
}
if (capture_active_ && now >= next_dtmf_read_ms_) {
int16_t frame[kDtmfFrameSamples] = {0};
const size_t samples_read = audio_->readCaptureFrameNonBlocking(frame, kDtmfFrameSamples);
if (samples_read > 0U) {
dtmf_.feedAudioSamples(frame, samples_read);
}
next_dtmf_read_ms_ = now + kDtmfReadPeriodMs;
}
if (suppress_dial_tone_ && audio_->isDialToneActive()) {
audio_->stopDialTone();
}
const bool pulse_dial_in_progress =
pulse_collecting_ || pulse_count_ > 0U ||
(last_pulse_edge_ms_ != 0U && (now - last_pulse_edge_ms_) < kPulseInterDigitGapMs);
if (!suppress_dial_tone_ && !dialing_started_ && dial_buffer_.isEmpty() && !audio_->isDialToneActive() &&
!pulse_dial_in_progress) {
audio_->startDialTone();
}
if (!dial_buffer_.isEmpty() && (now - last_digit_ms_) >= 10000U) {
// Drop stale partial numbers instead of dialing an incomplete value.
dial_buffer_ = "";
last_digit_ms_ = 0;
}
break;
}
if (prev_state != state_) {
if (state_ == TelephonyState::OFF_HOOK) {
off_hook_enter_ms_ = now;
pulse_hook_initialized_ = false;
pulse_collecting_ = false;
pulse_count_ = 0;
last_hook_edge_ms_ = now;
last_pulse_ms_ = 0;
last_pulse_edge_ms_ = 0;
dial_source_ = kDialSourceNone;
dialing_started_ = false;
dial_buffer_ = "";
last_digit_ms_ = 0;
dtmf_capture_start_ms_ = now + kDtmfCaptureStartDelayMs;
next_dtmf_read_ms_ = now;
if (audio_ != nullptr && !suppress_dial_tone_) {
audio_->startDialTone();
}
}
if (prev_state == TelephonyState::OFF_HOOK && state_ != TelephonyState::OFF_HOOK) {
clearDialSession();
}
}
}
TelephonyState TelephonyService::state() const {
+34
View File
@@ -1,9 +1,12 @@
#ifndef TELEPHONY_SERVICE_H
#define TELEPHONY_SERVICE_H
#include <functional>
#include "audio/AudioEngine.h"
#include "core/PlatformProfile.h"
#include "slic/SlicController.h"
#include "telephony/DtmfDecoder.h"
enum class TelephonyState : uint8_t {
IDLE = 0,
@@ -16,21 +19,52 @@ const char* telephonyStateToString(TelephonyState state);
class TelephonyService {
public:
using DialCallback = std::function<bool(const String&)>;
using AnswerCallback = std::function<bool()>;
TelephonyService();
bool begin(BoardProfile profile, SlicController& slic, AudioEngine& audio);
void setDialCallback(DialCallback cb);
void setAnswerCallback(AnswerCallback cb);
void triggerIncomingRing();
void setIncomingRing(bool active);
void tick();
TelephonyState state() const;
private:
void onDialDigit(char digit, bool from_pulse);
void updatePulseDecode(bool hook_off, uint32_t now);
void commitDialBuffer(const char* reason);
void clearDialSession();
BoardProfile profile_;
FeatureMatrix features_;
SlicController* slic_;
AudioEngine* audio_;
DialCallback dial_callback_;
AnswerCallback answer_callback_;
DtmfDecoder dtmf_;
TelephonyState state_;
bool incoming_ring_;
bool ring_phase_on_;
uint32_t ring_cycle_start_ms_;
bool capture_active_;
bool pulse_hook_initialized_;
bool pulse_last_hook_off_;
bool pulse_collecting_;
uint8_t pulse_count_;
uint32_t last_hook_edge_ms_;
uint32_t last_pulse_ms_;
uint32_t dtmf_capture_start_ms_;
uint32_t next_dtmf_read_ms_;
uint32_t off_hook_enter_ms_;
uint32_t last_pulse_edge_ms_;
bool suppress_dial_tone_;
bool dialing_started_;
uint8_t dial_source_;
String dial_buffer_;
uint32_t last_digit_ms_;
String last_dial_error_;
const char* message_path_;
};
+200 -20
View File
@@ -4,6 +4,7 @@
namespace {
constexpr bool kForceAuthDisabled = true;
constexpr bool kEnableRealtimeEvents = true;
String quoteArg(const String& value) {
String escaped = value;
@@ -14,7 +15,16 @@ String quoteArg(const String& value) {
}
WebServerManager::WebServerManager(uint16_t port)
: server_(port), rate_limit_ms_(250), auth_enabled_(false), auth_user_("admin"), auth_pass_("admin") {}
: server_(port),
events_("/api/events"),
rate_limit_ms_(250),
last_status_push_ms_(0),
status_cache_json_(""),
status_cache_ready_(false),
status_cache_mux_(portMUX_INITIALIZER_UNLOCKED),
auth_enabled_(false),
auth_user_("admin"),
auth_pass_("admin") {}
void WebServerManager::begin() {
if (!SPIFFS.begin(true)) {
@@ -29,7 +39,12 @@ void WebServerManager::begin() {
}
void WebServerManager::handle() {
// ESPAsyncWebServer is event-driven.
const uint32_t now = millis();
if (now - last_status_push_ms_ >= 1000U) {
last_status_push_ms_ = now;
refreshStatusCache();
publishRealtimeStatus();
}
}
void WebServerManager::setAuthCredentials(const String& user, const String& pass, bool persist_to_nvs) {
@@ -66,13 +81,35 @@ void WebServerManager::setCommandExecutor(std::function<DispatchResponse(const S
}
void WebServerManager::registerRoutes() {
if (kEnableRealtimeEvents) {
events_.onConnect([this](AsyncEventSourceClient* client) {
JsonDocument hello;
hello["transport"] = "sse";
hello["connected"] = true;
hello["ts"] = millis();
const String payload = toJsonString(hello);
client->send(payload.c_str(), "hello", millis());
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (ready) {
client->send(cached.c_str(), "status", millis());
}
});
server_.addHandler(&events_);
}
server_.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
doc["auth_enabled"] = isAuthEnabled();
if (status_callback_) {
status_callback_(doc.to<JsonObject>());
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (ready) {
request->send(200, "application/json", cached);
return;
}
request->send(200, "application/json", toJsonString(doc));
JsonDocument warmup;
warmup["auth_enabled"] = isAuthEnabled();
warmup["state"] = "status_warmup";
request->send(200, "application/json", toJsonString(warmup));
});
server_.on("/api/control", HTTP_POST, [this](AsyncWebServerRequest* request) {
@@ -218,10 +255,27 @@ void WebServerManager::registerRoutes() {
}
const String mac = doc["mac"] | "broadcast";
String payload;
if (doc["payload"].is<JsonVariant>()) {
serializeJson(doc["payload"], payload);
JsonVariantConst payload_variant = doc["payload"].as<JsonVariantConst>();
bool already_enveloped = false;
if (payload_variant.is<JsonObjectConst>()) {
JsonObjectConst payload_obj = payload_variant.as<JsonObjectConst>();
already_enveloped = payload_obj["msg_id"].is<const char*>() && payload_obj["type"].is<const char*>();
}
if (already_enveloped) {
serializeJson(payload_variant, payload);
} else {
payload = doc["payload"] | "{}";
JsonDocument envelope;
envelope["msg_id"] = String("web-") + String(millis());
envelope["seq"] = millis();
envelope["type"] = "command";
envelope["ack"] = true;
if (!payload_variant.isNull()) {
envelope["payload"].set(payload_variant);
} else {
envelope["payload"].to<JsonObject>();
}
serializeJson(envelope, payload);
}
handleDispatch(request, "ESPNOW_SEND " + mac + " " + payload);
});
@@ -244,6 +298,44 @@ void WebServerManager::registerRoutes() {
});
server_.on("/api/bluetooth/hfp/disconnect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_HFP_DISCONNECT"); });
server_.on("/api/bluetooth/hfp/auto", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_STATUS"); });
server_.on("/api/bluetooth/hfp/auto", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const bool enabled = doc["enabled"] | true;
handleDispatch(request, enabled ? "BT_AUTO_RECONNECT_ON" : "BT_AUTO_RECONNECT_OFF");
});
server_.on("/api/bluetooth/discoverable/on", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_DISCOVERABLE_ON"); });
server_.on("/api/bluetooth/discoverable/off", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_DISCOVERABLE_OFF"); });
server_.on("/api/bluetooth/hfp/dial", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String number = doc["number"] | "";
if (!isValidInput(number, 32)) {
request->send(400, "application/json", "{\"error\":\"invalid number\"}");
return;
}
handleDispatch(request, "BT_DIAL " + number);
});
server_.on("/api/bluetooth/hfp/redial", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_REDIAL"); });
server_.on("/api/bluetooth/hfp/answer", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_ANSWER"); });
server_.on("/api/bluetooth/hfp/hangup", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_HANGUP"); });
server_.on("/api/bluetooth/hfp/calls", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_CALLS"); });
server_.on("/api/bluetooth/pbap/sync", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_PBAP_SYNC"); });
server_.on("/api/bluetooth/ble/start", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_BLE_START"); });
server_.on("/api/bluetooth/ble/stop", HTTP_POST,
@@ -288,6 +380,93 @@ bool WebServerManager::isValidInput(const String& value, size_t max_len) {
return true;
}
bool WebServerManager::isEffectCommand(const String& command_line) {
String token = command_line;
const int sep = token.indexOf(' ');
if (sep > 0) {
token = token.substring(0, sep);
}
token.trim();
token.toUpperCase();
return token == "CALL" || token == "PLAY" || token == "CAPTURE_START" || token == "CAPTURE_STOP" ||
token == "BT_DIAL" || token == "DIAL" || token == "BT_REDIAL" || token == "BT_ANSWER" ||
token == "BT_HANGUP";
}
void WebServerManager::refreshStatusCache() {
if (!status_callback_) {
portENTER_CRITICAL(&status_cache_mux_);
status_cache_ready_ = false;
status_cache_json_ = "";
portEXIT_CRITICAL(&status_cache_mux_);
return;
}
JsonDocument doc;
doc["auth_enabled"] = isAuthEnabled();
status_callback_(doc.to<JsonObject>());
const String payload = toJsonString(doc);
portENTER_CRITICAL(&status_cache_mux_);
status_cache_json_ = payload;
status_cache_ready_ = true;
portEXIT_CRITICAL(&status_cache_mux_);
}
String WebServerManager::snapshotStatusCache(bool* ready) {
portENTER_CRITICAL(&status_cache_mux_);
const bool has_data = status_cache_ready_;
const String payload = status_cache_json_;
portEXIT_CRITICAL(&status_cache_mux_);
if (ready != nullptr) {
*ready = has_data;
}
return payload;
}
void WebServerManager::publishRealtimeEvent(const char* event_name, const String& payload_json) {
if (!kEnableRealtimeEvents) {
return;
}
events_.send(payload_json.c_str(), event_name, millis());
}
void WebServerManager::publishRealtimeStatus() {
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (!ready) {
return;
}
publishRealtimeEvent("status", cached);
}
void WebServerManager::publishDispatchEvent(const String& command_line, const DispatchResponse& res) {
JsonDocument doc;
doc["command"] = command_line;
doc["ok"] = res.ok;
if (!res.code.isEmpty()) {
doc["code"] = res.code;
}
if (!res.raw.isEmpty()) {
doc["raw"] = res.raw;
}
if (!res.json.isEmpty()) {
JsonDocument parsed;
if (deserializeJson(parsed, res.json) == DeserializationError::Ok) {
doc["json"].set(parsed.as<JsonVariantConst>());
} else {
doc["json_raw"] = res.json;
}
}
const String payload = toJsonString(doc);
publishRealtimeEvent("dispatch", payload);
if (isEffectCommand(command_line)) {
publishRealtimeEvent("effect", payload);
}
}
void WebServerManager::handleDispatch(AsyncWebServerRequest* request,
const String& command_line,
uint16_t success_code,
@@ -304,16 +483,17 @@ void WebServerManager::handleDispatch(AsyncWebServerRequest* request,
if (!res.json.isEmpty()) {
request->send(res.ok ? success_code : error_code, "application/json", res.json);
return;
} else {
JsonDocument doc;
doc["ok"] = res.ok;
if (!res.code.isEmpty()) {
doc["code"] = res.code;
}
if (!res.raw.isEmpty()) {
doc["raw"] = res.raw;
}
request->send(res.ok ? success_code : error_code, "application/json", toJsonString(doc));
}
JsonDocument doc;
doc["ok"] = res.ok;
if (!res.code.isEmpty()) {
doc["code"] = res.code;
}
if (!res.raw.isEmpty()) {
doc["raw"] = res.raw;
}
request->send(res.ok ? success_code : error_code, "application/json", toJsonString(doc));
publishDispatchEvent(command_line, res);
}
+12
View File
@@ -4,6 +4,7 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <freertos/FreeRTOS.h>
#include <functional>
@@ -25,7 +26,12 @@ public:
private:
AsyncWebServer server_;
AsyncEventSource events_;
uint32_t rate_limit_ms_;
uint32_t last_status_push_ms_;
String status_cache_json_;
bool status_cache_ready_;
portMUX_TYPE status_cache_mux_;
bool auth_enabled_;
String auth_user_;
String auth_pass_;
@@ -37,6 +43,12 @@ private:
static bool extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc);
static String toJsonString(const JsonDocument& doc);
static bool isValidInput(const String& value, size_t max_len);
static bool isEffectCommand(const String& command_line);
String snapshotStatusCache(bool* ready = nullptr);
void refreshStatusCache();
void publishRealtimeEvent(const char* event_name, const String& payload_json);
void publishRealtimeStatus();
void publishDispatchEvent(const String& command_line, const DispatchResponse& res);
void handleDispatch(AsyncWebServerRequest* request,
const String& command_line,
uint16_t success_code = 200,
+51 -5
View File
@@ -4,6 +4,7 @@
#include "wifi/WifiCredentialsStorage.h"
#include <Arduino.h>
#include <esp_wifi.h>
namespace {
constexpr char kFallbackApPrefix[] = "RTC_BL_A252";
@@ -60,6 +61,15 @@ String buildFallbackApSsid() {
return String(name);
}
void enforceBtCoexModemSleep() {
WiFi.setSleep(true);
const esp_err_t err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT && err != ESP_ERR_WIFI_NOT_STARTED) {
Serial.printf("[WifiManager] warn: esp_wifi_set_ps(min_modem) failed err=0x%04x\n",
static_cast<unsigned>(err));
}
}
} // namespace
WifiManager::WifiManager()
@@ -70,7 +80,12 @@ WifiManager::WifiManager()
ap_ssid_(buildFallbackApSsid()),
ap_password_(kFallbackApPassword),
next_auto_reconnect_ms_(0),
reconnect_backoff_ms_(3000) {}
reconnect_backoff_ms_(3000),
next_coex_reassert_ms_(0) {}
void WifiManager::enforceCoexPolicy() const {
enforceBtCoexModemSleep();
}
bool WifiManager::begin(const char* ssid, const char* password, uint32_t timeout_ms) {
return connect(ssid ? String(ssid) : "", password ? String(password) : "", timeout_ms, true);
@@ -89,12 +104,26 @@ bool WifiManager::connect(const String& ssid, const String& password, uint32_t t
stopFallbackAp();
WiFi.mode(WIFI_STA);
// Required by ESP32 WiFi+BT coexistence.
// Keep reconnect policy manual to avoid repeated WiFi timer churn under BT load.
WiFi.setAutoReconnect(false);
enforceCoexPolicy(); // Re-assert after mode switch to avoid BT coex abort.
WiFi.disconnect(false, true);
enforceCoexPolicy();
delay(100);
WiFi.begin(ssid_.c_str(), password_.c_str());
enforceCoexPolicy();
connected_ = waitForConnection(timeout_ms);
if (connected_) {
const String link_bssid = WiFi.BSSIDstr();
const int32_t link_channel = static_cast<int32_t>(WiFi.channel());
Serial.printf("[WifiManager] STA connected: ssid=%s ip=%s rssi=%d ch=%ld bssid=%s\n",
WiFi.SSID().c_str(),
WiFi.localIP().toString().c_str(),
static_cast<int>(WiFi.RSSI()),
static_cast<long>(link_channel),
link_bssid.c_str());
if (persist) {
WifiCredentialsStorage::save(ssid_, password_);
}
@@ -102,8 +131,10 @@ bool WifiManager::connect(const String& ssid, const String& password, uint32_t t
next_auto_reconnect_ms_ = 0;
stopFallbackAp();
} else {
// Clear partial STA state/timers before switching to fallback.
WiFi.disconnect(false, true);
notifyWifi("connect_failed");
next_auto_reconnect_ms_ = millis() + reconnect_backoff_ms_;
next_auto_reconnect_ms_ = 0;
startFallbackAp();
}
return connected_;
@@ -137,6 +168,15 @@ void WifiManager::disconnect(bool erase_credentials) {
}
void WifiManager::loop() {
const uint32_t now = millis();
if (now >= next_coex_reassert_ms_) {
const wifi_mode_t mode = WiFi.getMode();
if (mode != WIFI_MODE_NULL) {
enforceCoexPolicy();
}
next_coex_reassert_ms_ = now + 5000U;
}
connected_ = (WiFi.status() == WL_CONNECTED);
if (connected_) {
next_auto_reconnect_ms_ = 0;
@@ -148,9 +188,7 @@ void WifiManager::loop() {
startFallbackAp();
}
if (!ssid_.isEmpty() && next_auto_reconnect_ms_ != 0 && millis() >= next_auto_reconnect_ms_) {
reconnect(5000);
}
// Manual reconnect only (WIFI_RECONNECT command).
}
void WifiManager::ensureFallbackAp() {
@@ -178,6 +216,8 @@ WifiStatusSnapshot WifiManager::status() const {
snap.ssid = connected ? WiFi.SSID() : ssid_;
snap.ip = connected ? WiFi.localIP().toString() : String("0.0.0.0");
snap.rssi = connected ? WiFi.RSSI() : 0;
snap.channel = connected ? static_cast<int32_t>(WiFi.channel()) : 0;
snap.bssid = connected ? WiFi.BSSIDstr() : String("");
snap.ap_active = ap_active_;
snap.ap_ssid = ap_active_ ? ap_ssid_ : String("");
snap.ap_ip = ap_active_ ? WiFi.softAPIP().toString() : String("0.0.0.0");
@@ -199,6 +239,8 @@ void WifiManager::statusToJson(JsonObject obj) const {
obj["ssid"] = snap.ssid;
obj["ip"] = snap.ip;
obj["rssi"] = snap.rssi;
obj["channel"] = snap.channel;
obj["bssid"] = snap.bssid;
obj["state"] = snap.state;
obj["ap_active"] = snap.ap_active;
obj["ap_ssid"] = snap.ap_ssid;
@@ -243,12 +285,16 @@ bool WifiManager::startFallbackAp() {
}
WiFi.mode(WIFI_AP_STA);
// Required by ESP32 WiFi+BT coexistence in AP+STA mode.
WiFi.setAutoReconnect(false);
enforceCoexPolicy();
const bool ok = WiFi.softAP(
ap_ssid_.c_str(),
ap_password_.c_str(),
kFallbackApChannel,
false,
kFallbackApMaxConnections);
enforceCoexPolicy();
ap_active_ = ok;
if (ok) {
+5
View File
@@ -10,6 +10,8 @@ struct WifiStatusSnapshot {
String ssid;
String ip;
int32_t rssi = 0;
int32_t channel = 0;
String bssid;
String state;
bool ap_active = false;
String ap_ssid;
@@ -36,6 +38,8 @@ public:
void scanToJson(JsonArray arr, int max_networks = 20) const;
private:
void enforceCoexPolicy() const;
bool connected_;
String ssid_;
String password_;
@@ -44,6 +48,7 @@ private:
String ap_password_;
mutable uint32_t next_auto_reconnect_ms_;
uint32_t reconnect_backoff_ms_;
uint32_t next_coex_reassert_ms_;
bool startFallbackAp();
void stopFallbackAp();