diff --git a/data/webui/index.html b/data/webui/index.html index bef7f97..88dc947 100644 --- a/data/webui/index.html +++ b/data/webui/index.html @@ -116,8 +116,21 @@ +
+ + +
+ + + + +
+
+ + + diff --git a/data/webui/script.js b/data/webui/script.js index 5fc4943..65ed287 100644 --- a/data/webui/script.js +++ b/data/webui/script.js @@ -80,9 +80,12 @@ async function refreshStatus() { 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 pbapSupported = status.bluetooth?.pbap_supported ? "yes" : "no"; line.textContent = `board=${status.board_profile || "n/a"} telephony=${telephonyState} hook=${hook} ` + - `wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers}`; + `wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers} bt=${btConnected} bt_call=${btCallState} pbap=${pbapSupported}`; setJson("statusJson", status); } catch (error) { line.textContent = `Erreur statut: ${error.message}`; @@ -424,6 +427,120 @@ 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("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", { diff --git a/docs/README.md b/docs/README.md index 6cf75c0..9fdb839 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,8 @@ Chaque fiche détaille : rôle, API, notifications, points de validation, synerg | 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 diff --git a/docs/SPECS_STATE.md b/docs/SPECS_STATE.md index 9d99bda..15f4bac 100644 --- a/docs/SPECS_STATE.md +++ b/docs/SPECS_STATE.md @@ -44,3 +44,47 @@ - É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é d’une 2e carte diff --git a/docs/bt_pbap_migration_feasibility.md b/docs/bt_pbap_migration_feasibility.md new file mode 100644 index 0000000..9bef5a7 --- /dev/null +++ b/docs/bt_pbap_migration_feasibility.md @@ -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é. diff --git a/docs/espnow_api_v1.md b/docs/espnow_api_v1.md new file mode 100644 index 0000000..224b464 --- /dev/null +++ b/docs/espnow_api_v1.md @@ -0,0 +1,76 @@ +# ESP-NOW API v1 (`rtcbl/1`) + +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 + +```json +{ + "proto": "rtcbl/1", + "id": "req-001", + "ts": 1730000000, + "source": "a252", + "target": "peer|broadcast", + "cmd": "STATUS|CALL|WIFI_STATUS|BT_STATUS|...", + "args": {} +} +``` + +Règles: +- `proto` obligatoire pour activer le mode v1. +- `id` recommandé (corrélation réponse). +- `cmd` obligatoire. +- `args` optionnel. S'il est présent, il est sérialisé et transmis au dispatcher. + +## 3. Réponse v1 + +```json +{ + "proto": "rtcbl/1", + "id": "req-001", + "ok": true, + "code": "STATUS", + "data": {}, + "error": "" +} +``` + +Règles: +- `id` reprend la requête. +- `ok=false` => `error` non vide. +- `data` contient le JSON de la commande si disponible. +- fallback possible `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` + +## 5. Commandes recommandées v1 + +- `STATUS` +- `CALL` +- `WIFI_STATUS` +- `MQTT_STATUS` +- `ESPNOW_STATUS` +- `BT_STATUS` + +## 6. Intégration second repo + +Checklist minimum côté seconde carte: +1. Émettre `proto=rtcbl/1` + `id` unique par requête. +2. Implémenter timeout de réponse (2-5s). +3. Corréler sur `id`. +4. Prévoir fallback legacy si `proto` absent en réponse. diff --git a/docs/rapport_tests_fonctionnels.md b/docs/rapport_tests_fonctionnels.md index 6304487..541d9be 100644 --- a/docs/rapport_tests_fonctionnels.md +++ b/docs/rapport_tests_fonctionnels.md @@ -46,3 +46,51 @@ | `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 l’AP 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. diff --git a/platformio.ini b/platformio.ini index 40ef130..db1ee2a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,6 +29,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 diff --git a/src/bluetooth/IBluetoothBackend.h b/src/bluetooth/IBluetoothBackend.h new file mode 100644 index 0000000..7ed0bdc --- /dev/null +++ b/src/bluetooth/IBluetoothBackend.h @@ -0,0 +1,32 @@ +#ifndef BLUETOOTH_I_BLUETOOTH_BACKEND_H +#define BLUETOOTH_I_BLUETOOTH_BACKEND_H + +#include + +#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 diff --git a/src/main.cpp b/src/main.cpp index de129d3..d8db072 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -151,6 +151,55 @@ bool extractBridgeCommand(JsonVariantConst payload, String& out_cmd, uint8_t dep return false; } +bool buildRtcBlV1BridgeCommand(JsonVariantConst payload, + String& out_cmd, + String& out_request_id, + bool& out_is_v1) { + out_is_v1 = false; + if (!payload.is()) { + return false; + } + + JsonObjectConst obj = payload.as(); + 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; @@ -780,7 +829,10 @@ void registerCommands() { void processInboundBridgeCommand(const String& source, const JsonVariantConst& payload) { String cmd; - if (!extractBridgeCommand(payload, cmd)) { + String request_id; + bool is_rtcbl_v1 = false; + if (!buildRtcBlV1BridgeCommand(payload, cmd, request_id, is_rtcbl_v1) && + !extractBridgeCommand(payload, cmd)) { return; } @@ -794,6 +846,33 @@ void processInboundBridgeCommand(const String& source, const JsonVariantConst& p String payload_json; serializeJson(event, payload_json); g_mqtt.publish("event", payload_json, false); + + 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()); + } 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() { diff --git a/src/web/WebServerManager.cpp b/src/web/WebServerManager.cpp index 191cb99..35db57d 100644 --- a/src/web/WebServerManager.cpp +++ b/src/web/WebServerManager.cpp @@ -244,6 +244,33 @@ void WebServerManager::registerRoutes() { }); server_.on("/api/bluetooth/hfp/disconnect", HTTP_POST, [this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_HFP_DISCONNECT"); }); + 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,