feat: chain wifi webui espnow and bt track execution

This commit is contained in:
Clément SAILLANT
2026-02-21 23:58:52 +01:00
parent 841fabd4bf
commit fcaef73c59
11 changed files with 509 additions and 2 deletions
+13
View File
@@ -116,8 +116,21 @@
<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="btPbapSyncBtn">PBAP Sync</button>
<button id="btBleStartBtn">BLE Start</button>
<button id="btBleStopBtn">BLE Stop</button>
<button id="btRefreshBtn">Rafraîchir BT</button>
+118 -1
View File
@@ -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", {
+2
View File
@@ -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
+44
View File
@@ -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é dune 2e carte
+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é.
+76
View File
@@ -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.
+48
View File
@@ -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 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.
+8
View File
@@ -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
+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
+80 -1
View File
@@ -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<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;
@@ -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<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() {
+27
View File
@@ -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,