Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7da6ea37e7 | |||
| f20b12e77a | |||
| 7c9e6fe189 | |||
| fcaef73c59 | |||
| 841fabd4bf | |||
| 52c4aba36b | |||
| 1f277f8598 | |||
| 16d0f29f3f | |||
| 373891a7cc | |||
| db5c6049be | |||
| e83c5af059 | |||
| b477a4ea76 | |||
| 2dc2a80664 | |||
| 3438253087 | |||
| 482e993660 | |||
| 3d588007da | |||
| 973bcaa81c | |||
| 6525400436 | |||
| 176eb8272a | |||
| 4e75899208 | |||
| 9b2ae316aa | |||
| f6a45771fb | |||
| d2925d2521 | |||
| 3aa5ee2795 | |||
| f7c816fad0 | |||
| f25bc30fca | |||
| 45d20f32b8 | |||
| 4f857b4dd4 | |||
| 0f71cf24b2 | |||
| 3f112d75cc | |||
| 8ef445eac3 | |||
| c653d18ffd | |||
| 05867cca03 | |||
| a91734b2db | |||
| 9c10a1c3b0 | |||
| 6a7fd2be5a | |||
| 997e3e6e0a | |||
| 4d551acc75 |
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: AGENT_BRIEFINGS.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: CODEX_SCRIPT_EXPERT.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: FIRMWARE_EMBEDDED_EXPERT.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: FIRMWARE_EMBEDDED_EXPERT_SUMMARY.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: PHASE_1_BACKEND.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: PHASE_2B_FIRMWARE_RTOS.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: PHASE_2_ESP_HTTP_WS.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: PHASE_3_FRONTEND_WEBUI.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: PHASE_4_QA.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -1,2 +0,0 @@
|
||||
ARCHIVE: PHASE_5_RELEASE.md
|
||||
Ce fichier est archivé car il ne correspond plus à l'architecture RTC_BL_PHONE.
|
||||
@@ -17,6 +17,7 @@ Gestion de l’audio (I2S, ES8388, SLIC K50835F, ADC/DAC internes) pour le tél
|
||||
|
||||
## Références
|
||||
- docs/solutions_rtc_phone_esp32.md
|
||||
- docs/fiche_agent_audio_tools.md
|
||||
- Schémas de câblage dans docs/
|
||||
- Exemples d’initialisation I2S dans le firmware (src/main.cpp)
|
||||
|
||||
@@ -50,3 +51,11 @@ Gestion de l’audio (I2S, ES8388, SLIC K50835F, ADC/DAC internes) pour le tél
|
||||
- Mapping pins selon ESP32/ESP32-S3.
|
||||
- Filtrage alimentation, protection ESD.
|
||||
|
||||
### Limitations et recommandations
|
||||
- ES8388 : nécessite une initialisation I2C en plus d’I2S (utiliser une librairie dédiée, ex. ESP-ADF).
|
||||
- La gestion du volume/routage dépend du hardware (ES8388, PCM5102, GenericCodec).
|
||||
- Toujours tester le loopback audio sur chaque plateforme.
|
||||
- Vérifier la protection ESD et l’alimentation.
|
||||
- Documenter toute divergence hardware/firmware dans README et docs/solutions_rtc_phone_esp32.md.
|
||||
- Logs série obligatoires pour tout bug ou anomalie.
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Custom Agent – Game Content
|
||||
|
||||
## Scope
|
||||
`game/scenarios/**`, `game/prompts/**`, and derived documents regenerated from YAML sources.
|
||||
|
||||
## Do
|
||||
- Treat `game/scenarios/*.yaml` as the single source of truth for story points and content.
|
||||
- Run `python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v1.yaml` and `python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml` after edits.
|
||||
- Re-run audio/printable manifest validators when scenario IDs or references change.
|
||||
|
||||
## Must Not
|
||||
- Edit generated docs without first updating the scenario YAML.
|
||||
- Change IDs without syncing every reference (audio, docs, printables).
|
||||
|
||||
## References
|
||||
- `game/AGENTS.md`
|
||||
|
||||
## Plan d’action
|
||||
1. Valider le scénario source et régénérer les docs.
|
||||
- run: python3 tools/scenario/validate_scenario.py game/scenarios/zacus_v1.yaml
|
||||
- run: python3 tools/scenario/export_md.py game/scenarios/zacus_v1.yaml
|
||||
2. Revalider les manifestes audio et printables après toute mise à jour.
|
||||
- run: python3 tools/audio/validate_manifest.py audio/manifests/zacus_v1_audio.yaml
|
||||
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Custom Agent – Kit maître du jeu
|
||||
|
||||
## Scope
|
||||
`kit-maitre-du-jeu/stations/**`, `kit-maitre-du-jeu/export/**`, and textual instructions linked from game/printables sources.
|
||||
|
||||
## Do
|
||||
- Validate stations via `rg --files kit-maitre-du-jeu` and `rg -n "station|indice|enigme|zacus" kit-maitre-du-jeu`.
|
||||
- Run `python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml` when updates touch exported assets.
|
||||
|
||||
## Must Not
|
||||
- Rename station identifiers without syncing references across game and printables.
|
||||
- Regenerate bulk binary exports unless explicitly requested.
|
||||
|
||||
## References
|
||||
- `kit-maitre-du-jeu/AGENTS.md`
|
||||
|
||||
## Plan d’action
|
||||
1. Scanner les stations et exports.
|
||||
- run: rg --files kit-maitre-du-jeu
|
||||
- run: rg -n 'station|indice|enigme|zacus' kit-maitre-du-jeu
|
||||
2. Valider tout asset imprimable référencé.
|
||||
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Custom Agent – Printables
|
||||
|
||||
## Scope
|
||||
`printables/manifests/**` and `printables/src/**`.
|
||||
|
||||
## Do
|
||||
- Validate `printables/manifests/zacus_v1_printables.yaml` via `python3 tools/printables/validate_manifest.py` after updates.
|
||||
- Preserve deterministic naming and export references for every asset.
|
||||
|
||||
## Must Not
|
||||
- Regenerate large binary exports unless explicitly requested.
|
||||
- Introduce ad-hoc artifact folders into git.
|
||||
|
||||
## References
|
||||
- `printables/AGENTS.md`
|
||||
|
||||
## Plan d’action
|
||||
1. Valider le manifeste printables.
|
||||
- run: python3 tools/printables/validate_manifest.py printables/manifests/zacus_v1_printables.yaml
|
||||
2. Refaire les exports synchronisés si nécessaire (RG).
|
||||
- run: rg -n 'file:' printables/manifests/zacus_v1_printables.yaml
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
# Story V2 Spec Review Template
|
||||
|
||||
**Purpose:** Validate the Story V2 architecture with client before implementation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture: Story_n_CodePackApp
|
||||
|
||||
### Definition
|
||||
|
||||
**Story_n_CodePackApp** is a **complete, autonomous app** (not a wrapper):
|
||||
|
||||
- Manages app lifecycle (begin, start, update, handleEvent, stop)
|
||||
- Coordinates resources (screens, audio, custom bindings)
|
||||
- Handles event routing (unlock, audio_done, timer, serial, key)
|
||||
- Configurable via JSON (hold_ms, timeout_ms, key_sequence, etc.)
|
||||
- Extensible for future AI-generated apps
|
||||
|
||||
**Approval:**
|
||||
- [ ] Definition OK
|
||||
- [ ] Not a wrapper (autonomous) — APPROVED
|
||||
- [ ] Extensible for AI — APPROVED
|
||||
- [ ] Event-driven approach — APPROVED
|
||||
|
||||
---
|
||||
|
||||
## 2. Filesystem Storage (NEW)
|
||||
|
||||
### FS Structure: /story/
|
||||
|
||||
```
|
||||
/story/
|
||||
├── scenarios/ # JSON scenario definitions
|
||||
├── apps/ # App config JSON files (reusable)
|
||||
├── screens/ # Screen metadata JSON (device-specific)
|
||||
├── actions/ # Binary blobs (optional, custom hardware)
|
||||
└── audit.log # Runtime event log (text)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **YAML → JSON conversion** (`story_gen.py deploy`)
|
||||
- **No C++ recompile** for new scenarios
|
||||
- **Checksum validation** (detect corruption)
|
||||
- **Reusable configs** (apps, screens, actions)
|
||||
|
||||
**Approval:**
|
||||
- [ ] FS structure OK
|
||||
- [ ] YAML→JSON conversion OK
|
||||
- [ ] Checksum validation OK
|
||||
- [ ] No C++ recompile — APPROVED
|
||||
|
||||
**See:** [STORY_V2_APP_STORAGE.md](../protocols/STORY_V2_APP_STORAGE.md)
|
||||
|
||||
---
|
||||
|
||||
## 3. WebUI (NEW)
|
||||
|
||||
### Features
|
||||
|
||||
#### Story Selector
|
||||
- Browse scenarios on ESP /story/
|
||||
- Click "Play" to start
|
||||
- Estimated duration + metadata
|
||||
|
||||
#### Live Orchestration
|
||||
- Pause/Resume/Skip buttons
|
||||
- Real-time WebSocket stream (step changes + events)
|
||||
- Audit log viewer (scrollable event history)
|
||||
|
||||
#### Story Designer
|
||||
- YAML editor textarea
|
||||
- "Validate" button (instant feedback)
|
||||
- "Deploy" button (write to ESP FS in seconds)
|
||||
- "Test Run" button (30s preview)
|
||||
|
||||
### API
|
||||
|
||||
**REST Endpoints (11 total):**
|
||||
- GET /api/story/list
|
||||
- POST /api/story/select/:id
|
||||
- POST /api/story/start
|
||||
- GET /api/story/status
|
||||
- POST /api/story/pause
|
||||
- POST /api/story/resume
|
||||
- POST /api/story/skip
|
||||
- POST /api/story/validate
|
||||
- POST /api/story/deploy
|
||||
- GET /api/audit/log
|
||||
- GET /api/story/fs-info
|
||||
|
||||
**WebSocket Stream:**
|
||||
- ws://esp:8080/api/story/stream (real-time events)
|
||||
|
||||
**Approval:**
|
||||
- [ ] Story Selector OK
|
||||
- [ ] Live Orchestration OK
|
||||
- [ ] Story Designer OK
|
||||
- [ ] 11 REST endpoints OK
|
||||
- [ ] WebSocket streaming OK
|
||||
|
||||
**See:** [STORY_V2_WEBUI.md](../protocols/STORY_V2_WEBUI.md)
|
||||
|
||||
---
|
||||
|
||||
## 4. Event Types
|
||||
|
||||
| Event | Trigger | Use |
|
||||
|-------|---------|-----|
|
||||
| `unlock` | LA (60s timeout, >3s cumul) OR key sequence | Transition from locked |
|
||||
| `audio_done` | Audio pack finished | Next step |
|
||||
| `timer` | After N milliseconds | Pause/delays |
|
||||
| `serial` | Command (FORCE_STEP, SKIP) | Testing |
|
||||
| `key` | Key press (K1, K2, K3) | Puzzles |
|
||||
|
||||
**Approval:**
|
||||
- [ ] All 5 event types — APPROVED
|
||||
- [ ] LA behavior (60s / >3s) — APPROVED
|
||||
- [ ] Serial commands OK
|
||||
- [ ] Key sequences OK
|
||||
|
||||
---
|
||||
|
||||
## 5. Constraints & Limits
|
||||
|
||||
| Constraint | Value | Reason |
|
||||
|-----------|-------|--------|
|
||||
| Max steps/scenario | 100 | Memory bloat prevention |
|
||||
| Max transitions/step | 5 | Branching reasonableness |
|
||||
| Max audio packs/step | 1 | State simplicity |
|
||||
| LA hold_ms range | 1s–10s | User intentionality |
|
||||
| LA timeout_ms range | 30s–5min | Prevent hangs |
|
||||
|
||||
**Recommended defaults:** hold_ms=3000, timeout_ms=60000
|
||||
|
||||
**Approval:**
|
||||
- [ ] All constraints OK
|
||||
- [ ] Defaults suitable
|
||||
- [ ] REVISIONS (describe below)
|
||||
|
||||
---
|
||||
|
||||
## 6. Extensibility Model
|
||||
|
||||
New apps should follow **Story_n_CodePackApp pattern**:
|
||||
|
||||
```cpp
|
||||
class CustomApp : public StoryApp {
|
||||
void begin(context);
|
||||
void start(stepContext);
|
||||
void update(nowMs, sink);
|
||||
void stop(reason);
|
||||
void handleEvent(event, sink);
|
||||
String snapshot();
|
||||
};
|
||||
```
|
||||
|
||||
**Configuration via YAML:**
|
||||
```yaml
|
||||
app_bindings:
|
||||
- id: "APP_CUSTOM"
|
||||
app: "CustomPuzzleApp"
|
||||
config:
|
||||
puzzle_type: "color_sequence"
|
||||
difficulty: "medium"
|
||||
timeout_ms: 60000
|
||||
```
|
||||
|
||||
**AI can generate:** New apps with custom config + logic, reusing the pattern.
|
||||
|
||||
**Approval:**
|
||||
- [ ] Pattern is extensible — APPROVED
|
||||
- [ ] AI can generate apps — APPROVED
|
||||
- [ ] YAML-driven approach — APPROVED
|
||||
|
||||
---
|
||||
|
||||
## 7. Client Validation Checklist
|
||||
|
||||
### Architecture
|
||||
- [ ] Story_n_CodePackApp is autonomous (not wrapper)
|
||||
- [ ] Pattern extensible for AI-generated apps
|
||||
- [ ] Event-driven transitions
|
||||
- [ ] No C++ changes per scenario
|
||||
|
||||
### Filesystem
|
||||
- [ ] /story/ structure makes sense
|
||||
- [ ] YAML→JSON conversion acceptable
|
||||
- [ ] Checksum validation adds safety
|
||||
- [ ] FS deployment workflow is practical
|
||||
|
||||
### WebUI
|
||||
- [ ] Story Selector useful
|
||||
- [ ] Live Orchestration covers needs
|
||||
- [ ] Story Designer enables rapid iteration
|
||||
- [ ] REST API + WebSocket sufficient
|
||||
|
||||
### Events
|
||||
- [ ] All 5 event types needed
|
||||
- [ ] LA behavior (60s / >3s) correct
|
||||
- [ ] Serial commands for testing sufficient
|
||||
- [ ] Key sequences supported
|
||||
|
||||
### Extensibility
|
||||
- [ ] Future apps can follow pattern
|
||||
- [ ] AI-generated apps are feasible
|
||||
- [ ] No blocker to extensibility
|
||||
|
||||
---
|
||||
|
||||
## 8. Sign-Off
|
||||
|
||||
| Role | Name | Date | Signature |
|
||||
|------|------|------|-----------|
|
||||
| Client | ______ | __/__/__ | ______ |
|
||||
| Dev Lead | ______ | __/__/__ | ______ |
|
||||
| QA | ______ | __/__/__ | ______ |
|
||||
|
||||
---
|
||||
|
||||
## 9. Next Steps (Post-Approval)
|
||||
|
||||
1. [ ] Implement story_gen.py deploy (YAML → JSON)
|
||||
2. [ ] Build StoryFsManager (ESP loader)
|
||||
3. [ ] Implement 11 REST API endpoints
|
||||
4. [ ] Build WebUI React/Vue app
|
||||
5. [ ] Test WebSocket streaming
|
||||
6. [ ] Validate with AI-generated scenario
|
||||
7. [ ] Begin RC firmware build
|
||||
|
||||
---
|
||||
|
||||
**Reference Documents:**
|
||||
- [STORY_V2_APP_STORAGE.md](../protocols/STORY_V2_APP_STORAGE.md)
|
||||
- [STORY_V2_WEBUI.md](../protocols/STORY_V2_WEBUI.md)
|
||||
- [example_story_n_codepack.yaml](../protocols/story_specs/scenarios/example_story_n_codepack.yaml)
|
||||
@@ -1,18 +1,17 @@
|
||||
## Plan: Align Repo Intelligence
|
||||
|
||||
TL;DR: align docs and prompts with the agreed source of truth (docs/protocols/story_specs), remove duplications, and update Story V2 references (default flow + CI workflow). Keep agent contracts intact unless we decide to consolidate. This will tighten the “intelligence” surface without changing code.
|
||||
## Plan : Intelligence documentaire RTC_BL_PHONE
|
||||
|
||||
**Steps**
|
||||
1. Normalize Story V2 spec source-of-truth wording across docs: update docs/protocols/README.md, docs/protocols/INDEX.md, docs/protocols/story_specs/README.md, and docs/INDEX.md to point to docs/protocols/story_specs and avoid legacy paths.
|
||||
2. Deduplicate story prompts/specs: keep canonical prompts under docs/protocols/story_specs/prompts/ and turn duplicates into “moved” stubs or remove (e.g., story_generator/story_specs/prompts/spectre_radio_lab.prompt.md and docs/protocols/spectre_radio_lab.prompt.md). Keep story_generator/story_specs/README.md as a redirect only.
|
||||
3. Update Story V2 documentation to the agreed default flow and CI workflow: align docs/protocols/story_README.md and esp32_audio/src/story/README.md to UNLOCK→U_SON_PROTO→WAIT_ETAPE2→ETAPE2→DONE and mention firmware-story-v2.yml.
|
||||
4. Clarify prompt taxonomy: note in README.md and docs/protocols/story_README.md that Story authoring prompts are separate from ops/codex prompts but can be used by Codex tooling if desired.
|
||||
5. Optional: confirm whether to keep both agent contracts as-is (AGENTS.md, tools/dev/AGENTS.md); if you want consolidation, add a short pointer in one to the other instead of merging.
|
||||
TL;DR : Centraliser et maintenir toute la documentation technique, QA et agents sous `docs/`. intelligence purement firmware/hardware/QA.
|
||||
|
||||
**Verification**
|
||||
- Manual doc consistency scan of the updated files for path correctness and consistent Story V2 flow/CI references.
|
||||
**Étapes**
|
||||
1. Centraliser : toute la documentation (fiches agents, plans, procédures, rapports, changelog) est sous `docs/`.
|
||||
2. Mettre à jour l’index documentaire (`docs/README.md`) pour référencer tous les documents clés et la synergie agents.
|
||||
3. Vérifier que chaque fiche agent, plan, procédure et rapport est à jour et bien référencé.
|
||||
4. Archiver les rapports d’audit, de validation, de CI dans un sous-dossier si besoin.
|
||||
5. Documenter toute évolution majeure dans le changelog.
|
||||
|
||||
**Decisions**
|
||||
- Prompts: Story authoring prompts are a separate category but may be used by Codex.
|
||||
- Default flow: UNLOCK → U_SON_PROTO → WAIT_ETAPE2 → ETAPE2 → DONE.
|
||||
- CI workflow: firmware-story-v2.yml.
|
||||
**Vérification**
|
||||
- Audit manuel régulier de la cohérence, de l’actualité et de la clarté de la documentation technique et QA.
|
||||
|
||||
**Décisions**
|
||||
- L’intelligence documentaire est centrée sur la maintenance, la QA, la validation hardware/firmware, et la traçabilité agents.
|
||||
|
||||
@@ -2,21 +2,10 @@
|
||||
|
||||
### 1. ESP32
|
||||
- **Build/Flash** : Scripts et gates déjà factorisés, structure conforme. Priorité : maintenir la reproductibilité, surveiller les évolutions PlatformIO.
|
||||
- **Tests/Smoke** : Vérifier la couverture des tests hardware (smoke, reboot, panic). Ajouter des tests de drivers spécifiques si besoin (UART, I2C, SPI, GPIO).
|
||||
- **Tests/Smoke** : Vérifier la couverture des tests hardware (smoke, reboot, panic). Ajouter des tests de drivers spécifiques si besoin (UART, I2C, SPI, GPIO, SLIC).
|
||||
- **Drivers** : S’assurer que tous les capteurs/actuateurs nécessaires sont intégrés et documentés. Priorité aux drivers critiques (communication, sécurité, alimentation).
|
||||
- **CI/CD** : Maintenir la compatibilité avec la CI, artefacts reproductibles, logs clairs.
|
||||
|
||||
### 2. ESP8266
|
||||
- **Build/Flash** : Scripts harmonisés, mais attention à la compatibilité avec les nouveaux firmwares. Priorité : stabilité du flash et du lien SoftwareSerial.
|
||||
- **Tests/Smoke** : Ajouter ou renforcer les tests de communication avec l’ESP32 (SoftwareSerial, UI link).
|
||||
- **Drivers** : Vérifier la présence de tous les drivers nécessaires (afficheur OLED, relais, etc.).
|
||||
- **Logs** : S’assurer que les logs d’erreur sont bien capturés et exploitables.
|
||||
|
||||
### 3. RP2040 (TFT/OLED)
|
||||
- **Build/Flash** : Vérifier la reproductibilité des builds pour les variantes ILI9488/ILI9486.
|
||||
- **Tests/Smoke** : Ajouter des tests de rendu écran, de réactivité UI, et de communication avec l’ESP32/ESP8266.
|
||||
- **Drivers** : Priorité à la stabilité des drivers d’affichage et à la gestion mémoire.
|
||||
- **Documentation** : Compléter la doc sur le wiring, les dépendances, et les limitations connues.
|
||||
|
||||
### 4. Codex/Auto-fix
|
||||
- **Prompts** : Centraliser et documenter tous les prompts dans codex_prompts/.
|
||||
@@ -25,14 +14,12 @@
|
||||
|
||||
### 5. Commun (logs, artefacts, onboarding)
|
||||
- **Logs/Artefacts** : Maintenir la centralisation, la rotation, et la clarté des logs/artefacts pour chaque plateforme.
|
||||
- **Onboarding** : Adapter les instructions pour chaque cible (esp32, esp8266, rp2040).
|
||||
- **Onboarding** : Adapter les instructions pour chaque cible
|
||||
- **CI** : Vérifier que chaque plateforme est bien couverte par la CI (build, smoke, artefacts, logs).
|
||||
|
||||
---
|
||||
|
||||
**Décisions**
|
||||
- Priorité à la robustesse ESP32 (build, drivers, tests), puis ESP8266 (communication, logs), puis RP2040 (affichage, tests UI).
|
||||
- Priorité à la robustesse ESP32 (build, drivers, tests), p
|
||||
- Harmonisation et documentation continue pour chaque plateforme.
|
||||
- Audit et automatisation réguliers pour garantir la conformité AGENTS.md.
|
||||
|
||||
Souhaitez-vous démarrer par l’audit/optimisation ESP32, ESP8266, ou RP2040 ?
|
||||
- Audit et automatisation réguliers pour garantir la conformité AGENTS.md.
|
||||
@@ -2,17 +2,13 @@ name: Firmware CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, dev]
|
||||
branches: [main, release/stable]
|
||||
pull_request:
|
||||
branches: [main, develop, dev]
|
||||
branches: [main, release/stable]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
env: [esp32dev, esp32-s3-devkitc-1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -23,13 +19,30 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Test Web Route Parity Parser
|
||||
run: python3 -m unittest scripts/test_check_web_route_parity.py
|
||||
|
||||
- name: Check Web Route Parity
|
||||
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: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install platformio
|
||||
|
||||
- name: Validate hw runner syntax
|
||||
run: python -m py_compile scripts/hw_validation.py
|
||||
|
||||
- name: Build firmware
|
||||
run: platformio run -e ${{ matrix.env }}
|
||||
run: platformio run -e esp32dev
|
||||
|
||||
- name: Build unit tests (no upload)
|
||||
run: platformio test --without-uploading --without-testing -e ${{ matrix.env }}
|
||||
run: platformio test --without-uploading --without-testing -e esp32dev
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Intégration Makefile : flash universel
|
||||
|
||||
# Variables de base
|
||||
FLASH_SCRIPT = tools/dev/autoflash.py
|
||||
FLASH_CONFIG = tools/dev/flash_config.json
|
||||
|
||||
# Liste des rôles connus (adapter selon ton flash_config.json)
|
||||
FLASH_ROLES = esp32 esp8266 esp32s3 rp2040 stm32 arduino
|
||||
|
||||
# Cible générique : make flash ROLE=esp32
|
||||
flash:
|
||||
@if [ -z "$(ROLE)" ]; then \
|
||||
echo "Usage : make flash ROLE=<$(FLASH_ROLES)>"; exit 1; \
|
||||
fi; \
|
||||
$(FLASH_SCRIPT) flash --config $(FLASH_CONFIG) --role $(ROLE)
|
||||
|
||||
# Cible pour lister les devices et rôles
|
||||
flash-list:
|
||||
$(FLASH_SCRIPT) list --config $(FLASH_CONFIG)
|
||||
|
||||
# Cible dry-run (voir ce qu'il ferait)
|
||||
flash-dry:
|
||||
@if [ -z "$(ROLE)" ]; then \
|
||||
echo "Usage : make flash-dry ROLE=<$(FLASH_ROLES)>"; exit 1; \
|
||||
fi; \
|
||||
$(FLASH_SCRIPT) flash --config $(FLASH_CONFIG) --role $(ROLE) --dry-run
|
||||
|
||||
.PHONY: flash flash-list flash-dry
|
||||
@@ -108,6 +108,15 @@ _Agent Repo & GitHub – README généré automatiquement._
|
||||
- Si décroché pendant sonnerie : `answer` automatique.
|
||||
- Si raccroché pendant appel : `end/reject` automatique.
|
||||
|
||||
## Wiring A252 validé (bench courant)
|
||||
- `SLIC RM` -> `GPIO18`
|
||||
- `SLIC FR` -> `GPIO5`
|
||||
- `SLIC SHK` -> `GPIO23` (`INPUT_PULLUP`, hook actif haut)
|
||||
- `SLIC PD` -> `GPIO19`
|
||||
- `SLIC LINE` -> non utilisé (`-1`, logique retirée du runtime)
|
||||
- `AMP_EN` carte audio -> `GPIO21`, polarité active bas (`LOW=ON`, `HIGH=OFF`)
|
||||
- tonalité locale: `425 Hz` (couleur France/Europe)
|
||||
|
||||
## Choix de cartes ESP32
|
||||
Voir `docs/solutions_rtc_phone_esp32.md` pour la shortlist des DevKit utilisables (ESP32-DevKitC, ESP32-S3-DevKitC-1, NodeMCU-32S, LOLIN32), les liens de référence web, et les solutions d’interface (direct combiné/clavier, SLIC/FXS, ATA externe), dont une variante AG1171S (Silvertel).
|
||||
|
||||
@@ -266,6 +275,21 @@ bt.sendData(buffer, length);
|
||||
|
||||
---
|
||||
|
||||
## Contrôle MQTT, ESP-NOW et DTMF logiciel
|
||||
|
||||
- Contrôle distant via MQTT (ArduinoProps) : topics `rtc_bl_phone/<device_id>/in` (commandes), `rtc_bl_phone/<device_id>/out` (événements).
|
||||
- Contrôle local via ESP-NOW (même schéma JSON).
|
||||
- Détection DTMF logicielle (Goertzel) : les chiffres détectés sont publiés dans les événements.
|
||||
- Limitations ESP32-S3 : pas de Bluetooth Classic, uniquement BLE (les fonctions BT Classic sont désactivées sur S3).
|
||||
|
||||
**Exemples** :
|
||||
- Publier une commande MQTT :
|
||||
`mosquitto_pub -t rtc_bl_phone/mondevice/in -m '{"cmd":"CALL"}'`
|
||||
- Écouter les événements :
|
||||
`mosquitto_sub -t rtc_bl_phone/mondevice/out`
|
||||
|
||||
Voir aussi `docs/props.md` pour le schéma détaillé.
|
||||
|
||||
## Résumé des fichiers modifiés/créés
|
||||
- README.md : ajout de la documentation technique détaillée des modules AudioManager, RTOSManager, BluetoothManager.
|
||||
|
||||
@@ -307,10 +331,14 @@ Le rapport HTML sera disponible dans `coverage/html`.
|
||||
Lancez les tests avec PlatformIO :
|
||||
|
||||
```bash
|
||||
pio test
|
||||
bash scripts/test_terminal.sh
|
||||
```
|
||||
|
||||
Puis générez le rapport de couverture.
|
||||
Ce script enchaîne:
|
||||
- Build des tests embarqués sans upload matériel.
|
||||
- Tests hôte DTMF (génération de tonalités synthétiques) en terminal.
|
||||
|
||||
Puis, si besoin, générez le rapport de couverture.
|
||||
|
||||
### Objectif
|
||||
Ces ajouts permettent de valider la robustesse, la gestion mémoire, la sécurité multithread et les interactions entre modules, tout en mesurant la couverture des tests.
|
||||
|
||||
+130
-35
@@ -3,63 +3,158 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RTC_BL_PHONE WebUI</title>
|
||||
<title>RTC_BL_PHONE Web UI</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>RTC_BL_PHONE Dashboard</h1>
|
||||
<p id="status">Chargement...</p>
|
||||
<h1>RTC_BL_PHONE Web UI</h1>
|
||||
<p id="statusLine">Chargement...</p>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<button data-section="contacts">Annuaire</button>
|
||||
<button data-section="dashboard">Dashboard</button>
|
||||
<button data-section="config">Configuration</button>
|
||||
<button data-section="logs">Logs</button>
|
||||
<button data-section="network">Réseau</button>
|
||||
<button data-section="control">Contrôle</button>
|
||||
<button id="refreshStatusBtn">Rafraîchir statut</button>
|
||||
<button id="refreshAllBtn">Rafraîchir tout</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section id="contactsSection" class="active">
|
||||
<h2>Annuaire Téléphone</h2>
|
||||
<input id="searchContact" type="text" placeholder="Recherche..." />
|
||||
<div id="contactsList"></div>
|
||||
<h3>Ajouter / Modifier un contact</h3>
|
||||
<form id="contactForm">
|
||||
<input type="text" name="nom" placeholder="Nom" required>
|
||||
<input type="text" name="numero" placeholder="Numéro" required>
|
||||
<select name="type">
|
||||
<option value="mobile">Mobile</option>
|
||||
<option value="fixe">Fixe</option>
|
||||
</select>
|
||||
<button type="submit">Enregistrer</button>
|
||||
</form>
|
||||
<div id="contactFeedback"></div>
|
||||
<section id="dashboardSection" class="active">
|
||||
<h2>Statut runtime</h2>
|
||||
<pre id="statusJson"></pre>
|
||||
</section>
|
||||
|
||||
<section id="configSection">
|
||||
<h2>Configuration</h2>
|
||||
<pre id="config"></pre>
|
||||
<form id="configForm">
|
||||
<input type="text" name="param1" placeholder="Paramètre 1">
|
||||
<input type="text" name="param2" placeholder="Paramètre 2">
|
||||
<button type="submit">Enregistrer</button>
|
||||
</form>
|
||||
<div class="inline-actions">
|
||||
<button id="refreshConfigBtn">Rafraîchir configuration</button>
|
||||
</div>
|
||||
<pre id="configJson"></pre>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<h3>Audio config (JSON)</h3>
|
||||
<textarea id="audioConfigInput" rows="8" placeholder='{"volume":80}'></textarea>
|
||||
<div class="inline-actions">
|
||||
<button id="applyAudioConfigBtn">Appliquer audio</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>MQTT config (JSON)</h3>
|
||||
<textarea id="mqttConfigInput" rows="8" placeholder='{"enabled":false}'></textarea>
|
||||
<div class="inline-actions">
|
||||
<button id="applyMqttConfigBtn">Appliquer MQTT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Pins config (JSON)</h3>
|
||||
<textarea id="pinsConfigInput" rows="8" placeholder='{"slic":{"line":23}}'></textarea>
|
||||
<div class="inline-actions">
|
||||
<button id="applyPinsConfigBtn">Appliquer pins</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="logsSection">
|
||||
<h2>Logs</h2>
|
||||
<pre id="logs"></pre>
|
||||
<button id="refreshLogsBtn">Rafraîchir logs</button>
|
||||
<section id="networkSection">
|
||||
<h2>Réseau et bridges</h2>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<h3>WiFi</h3>
|
||||
<form id="wifiConnectForm">
|
||||
<input type="text" id="wifiSsid" placeholder="SSID" required>
|
||||
<input type="password" id="wifiPass" placeholder="Mot de passe">
|
||||
<button type="submit">Connecter WiFi</button>
|
||||
</form>
|
||||
<div class="inline-actions">
|
||||
<button id="wifiDisconnectBtn">Déconnecter WiFi</button>
|
||||
<button id="wifiReconnectBtn">Reconnecter WiFi</button>
|
||||
<button id="wifiScanBtn">Scanner WiFi</button>
|
||||
</div>
|
||||
<pre id="wifiJson"></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>MQTT</h3>
|
||||
<div class="inline-actions">
|
||||
<button id="mqttConnectBtn">Connecter MQTT</button>
|
||||
<button id="mqttDisconnectBtn">Déconnecter MQTT</button>
|
||||
</div>
|
||||
<form id="mqttPublishForm">
|
||||
<input type="text" id="mqttTopic" placeholder="Topic (ex: rtc_bl_phone/a252/test)" required>
|
||||
<textarea id="mqttPayload" rows="3" placeholder='Payload JSON ou texte'>{"ping":true}</textarea>
|
||||
<button type="submit">Publier MQTT</button>
|
||||
</form>
|
||||
<pre id="mqttJson"></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>ESP-NOW</h3>
|
||||
<form id="espnowPeerForm">
|
||||
<input type="text" id="espnowMac" placeholder="AA:BB:CC:DD:EE:FF" required>
|
||||
<button type="submit">Ajouter pair</button>
|
||||
<button type="button" id="espnowDelBtn">Supprimer pair</button>
|
||||
</form>
|
||||
<form id="espnowSendForm">
|
||||
<input type="text" id="espnowTarget" placeholder="broadcast ou MAC" value="broadcast" required>
|
||||
<textarea id="espnowPayload" rows="3" placeholder="Payload (texte ou JSON)">{\"cmd\":\"STATUS\"}</textarea>
|
||||
<button type="submit">Envoyer</button>
|
||||
</form>
|
||||
<div class="inline-actions">
|
||||
<button id="espnowOnBtn">ESPNOW_ON</button>
|
||||
<button id="espnowOffBtn">ESPNOW_OFF</button>
|
||||
<button id="espnowRefreshBtn">Rafraîchir ESP-NOW</button>
|
||||
</div>
|
||||
<pre id="espnowJson"></pre>
|
||||
<pre id="espnowPeersJson"></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Bluetooth</h3>
|
||||
<form id="btHfpConnectForm">
|
||||
<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>
|
||||
</div>
|
||||
<pre id="btJson"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="controlSection">
|
||||
<h2>Contrôle</h2>
|
||||
<button data-action="call">Déclencher sonnerie</button>
|
||||
<button data-action="capture_start">Démarrer capture</button>
|
||||
<button data-action="capture_stop">Arrêter capture</button>
|
||||
<pre id="controlResult"></pre>
|
||||
<div class="inline-actions">
|
||||
<button data-action="CALL">CALL</button>
|
||||
<button data-action="CAPTURE_START">CAPTURE_START</button>
|
||||
<button data-action="CAPTURE_STOP">CAPTURE_STOP</button>
|
||||
<button data-action="RESET_METRICS">RESET_METRICS</button>
|
||||
</div>
|
||||
<form id="rawCommandForm">
|
||||
<input type="text" id="rawCommandInput" placeholder="Commande série (ex: ESPNOW_STATUS)" required>
|
||||
<button type="submit">Exécuter</button>
|
||||
</form>
|
||||
<pre id="actionResult"></pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
+661
-168
@@ -1,155 +1,258 @@
|
||||
let contactsData = [];
|
||||
const SECTION_MAP = {
|
||||
dashboard: "dashboardSection",
|
||||
config: "configSection",
|
||||
network: "networkSection",
|
||||
control: "controlSection",
|
||||
};
|
||||
let realtimeSource = null;
|
||||
let realtimeConnected = false;
|
||||
let fallbackPollingTimer = null;
|
||||
|
||||
function showSection(section) {
|
||||
const map = {
|
||||
contacts: "contactsSection",
|
||||
config: "configSection",
|
||||
logs: "logsSection",
|
||||
control: "controlSection",
|
||||
};
|
||||
Object.values(map).forEach((id) => {
|
||||
Object.values(SECTION_MAP).forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.classList.remove("active");
|
||||
}
|
||||
});
|
||||
const sectionEl = document.getElementById(map[section]);
|
||||
const sectionEl = document.getElementById(SECTION_MAP[section]);
|
||||
if (sectionEl) {
|
||||
sectionEl.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeFetchJson(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
function setJson(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
return response.json();
|
||||
if (typeof value === "string") {
|
||||
el.textContent = value;
|
||||
return;
|
||||
}
|
||||
el.textContent = JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const text = await response.text();
|
||||
let parsed = {};
|
||||
if (text) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (_) {
|
||||
parsed = { raw: text };
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${text || ""}`.trim());
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function jsonHeaders() {
|
||||
return { "Content-Type": "application/json" };
|
||||
}
|
||||
|
||||
function parseJsonInput(id) {
|
||||
const raw = document.getElementById(id).value.trim();
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function parsePayloadValue(rawPayload) {
|
||||
const trimmed = rawPayload.trim();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (_) {
|
||||
return rawPayload;
|
||||
}
|
||||
}
|
||||
return rawPayload;
|
||||
}
|
||||
|
||||
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() {
|
||||
const status = document.getElementById("status");
|
||||
try {
|
||||
const data = await safeFetchJson("/api/status");
|
||||
status.textContent =
|
||||
`state=${data.state} board=${data.board_profile || "n/a"} ` +
|
||||
`telephony=${data.telephony || "n/a"} hook=${data.hook || "n/a"} ` +
|
||||
`full_duplex=${data.full_duplex} underrun=${data.audio_underrun_count || 0} ` +
|
||||
`drop=${data.audio_drop_frames || 0}`;
|
||||
const status = await requestJson("/api/status");
|
||||
applyStatusSnapshot(status);
|
||||
} catch (error) {
|
||||
status.textContent = `Erreur statut: ${error.message}`;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContacts() {
|
||||
async function refreshConfig() {
|
||||
try {
|
||||
contactsData = await safeFetchJson("/api/contacts");
|
||||
renderContacts();
|
||||
const [pins, audio, mqtt] = await Promise.all([
|
||||
requestJson("/api/config/pins"),
|
||||
requestJson("/api/config/audio"),
|
||||
requestJson("/api/config/mqtt"),
|
||||
]);
|
||||
setJson("configJson", { pins, audio, mqtt });
|
||||
const audioInput = document.getElementById("audioConfigInput");
|
||||
const mqttInput = document.getElementById("mqttConfigInput");
|
||||
const pinsInput = document.getElementById("pinsConfigInput");
|
||||
if (audioInput) {
|
||||
audioInput.value = JSON.stringify(audio, null, 2);
|
||||
}
|
||||
if (mqttInput) {
|
||||
mqttInput.value = JSON.stringify(mqtt.config || mqtt, null, 2);
|
||||
}
|
||||
if (pinsInput) {
|
||||
pinsInput.value = JSON.stringify(pins, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById("contactFeedback").textContent = `Erreur contacts: ${error.message}`;
|
||||
setJson("configJson", { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
function renderContacts() {
|
||||
const list = document.getElementById("contactsList");
|
||||
const searchInput = document.getElementById("searchContact");
|
||||
const search = (searchInput?.value || "").toLowerCase();
|
||||
list.innerHTML = "";
|
||||
|
||||
contactsData
|
||||
.filter((c) => c.nom.toLowerCase().includes(search) || c.numero.includes(search))
|
||||
.forEach((c, idx) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "contact-card";
|
||||
card.innerHTML = `<b>${c.nom}</b><br><span>${c.numero}</span><br><span>${c.type}</span>`;
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "contact-actions";
|
||||
actions.innerHTML =
|
||||
`<button data-call="${c.numero}">Appeler</button>` +
|
||||
`<button data-edit="${idx}">Modifier</button>` +
|
||||
`<button data-delete="${idx}">Supprimer</button>`;
|
||||
card.appendChild(actions);
|
||||
list.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function editContact(idx) {
|
||||
const c = contactsData[idx];
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
const form = document.getElementById("contactForm");
|
||||
form.nom.value = c.nom;
|
||||
form.numero.value = c.numero;
|
||||
form.type.value = c.type;
|
||||
form.dataset.editIdx = String(idx);
|
||||
}
|
||||
|
||||
async function deleteContact(idx) {
|
||||
const response = await fetch("/api/contacts", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ idx }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
await loadContacts();
|
||||
document.getElementById("contactFeedback").textContent = "Contact supprimé";
|
||||
}
|
||||
|
||||
async function callContact(numero) {
|
||||
await sendControl("call", { numero });
|
||||
document.getElementById("contactFeedback").textContent = `Appel lancé vers ${numero}`;
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
async function refreshNetwork() {
|
||||
try {
|
||||
const data = await safeFetchJson("/api/config");
|
||||
document.getElementById("config").textContent = JSON.stringify(data, null, 2);
|
||||
const [wifi, mqtt, espnow, peers] = await Promise.all([
|
||||
requestJson("/api/network/wifi"),
|
||||
requestJson("/api/network/mqtt"),
|
||||
requestJson("/api/network/espnow"),
|
||||
requestJson("/api/network/espnow/peer"),
|
||||
]);
|
||||
setJson("wifiJson", wifi);
|
||||
setJson("mqttJson", mqtt);
|
||||
setJson("espnowJson", espnow);
|
||||
setJson("espnowPeersJson", peers);
|
||||
} catch (error) {
|
||||
document.getElementById("config").textContent = `Erreur config: ${error.message}`;
|
||||
const err = { error: error.message };
|
||||
setJson("wifiJson", err);
|
||||
setJson("mqttJson", err);
|
||||
setJson("espnowJson", err);
|
||||
setJson("espnowPeersJson", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const payload = {
|
||||
param1: form.param1.value || "valeur1",
|
||||
param2: form.param2.value || "valeur2",
|
||||
};
|
||||
const response = await fetch("/api/config", {
|
||||
async function refreshBluetooth() {
|
||||
try {
|
||||
const bt = await requestJson("/api/bluetooth");
|
||||
setJson("btJson", bt);
|
||||
} catch (error) {
|
||||
setJson("btJson", { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function sendControl(action) {
|
||||
const result = await requestJson("/api/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
document.getElementById("config").textContent = `Erreur config: HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
await loadConfig();
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
const response = await fetch("/api/logs");
|
||||
const logs = response.ok ? await response.text() : `Erreur logs: HTTP ${response.status}`;
|
||||
document.getElementById("logs").textContent = logs;
|
||||
}
|
||||
|
||||
async function sendControl(action, extraPayload = {}) {
|
||||
const response = await fetch("/api/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, ...extraPayload }),
|
||||
});
|
||||
const body = await response.text();
|
||||
document.getElementById("controlResult").textContent = body;
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return true;
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
@@ -157,58 +260,436 @@ function bindEvents() {
|
||||
button.addEventListener("click", () => showSection(button.dataset.section));
|
||||
});
|
||||
|
||||
document.getElementById("refreshStatusBtn").addEventListener("click", refreshStatus);
|
||||
document.getElementById("refreshLogsBtn").addEventListener("click", refreshLogs);
|
||||
document.getElementById("searchContact").addEventListener("input", renderContacts);
|
||||
document.getElementById("configForm").addEventListener("submit", saveConfig);
|
||||
document.getElementById("refreshAllBtn").addEventListener("click", async () => {
|
||||
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork()]);
|
||||
});
|
||||
document.getElementById("refreshConfigBtn").addEventListener("click", refreshConfig);
|
||||
document.getElementById("espnowRefreshBtn").addEventListener("click", refreshNetwork);
|
||||
document.getElementById("btRefreshBtn").addEventListener("click", refreshBluetooth);
|
||||
|
||||
document.getElementById("contactForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const editIdxRaw = form.dataset.editIdx;
|
||||
const hasEditIdx = typeof editIdxRaw !== "undefined";
|
||||
const payload = {
|
||||
nom: form.nom.value,
|
||||
numero: form.numero.value,
|
||||
type: form.type.value,
|
||||
};
|
||||
const method = hasEditIdx ? "PUT" : "POST";
|
||||
const body = hasEditIdx ? { ...payload, idx: Number(editIdxRaw) } : payload;
|
||||
|
||||
const response = await fetch("/api/contacts", {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
document.getElementById("contactFeedback").textContent = `Erreur contact: HTTP ${response.status}`;
|
||||
return;
|
||||
document.getElementById("applyAudioConfigBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = parseJsonInput("audioConfigInput");
|
||||
const result = await requestJson("/api/config/audio", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshConfig(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
delete form.dataset.editIdx;
|
||||
form.reset();
|
||||
document.getElementById("contactFeedback").textContent = hasEditIdx
|
||||
? "Contact modifié"
|
||||
: "Contact ajouté";
|
||||
await loadContacts();
|
||||
});
|
||||
|
||||
document.getElementById("contactsList").addEventListener("click", async (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
document.getElementById("applyMqttConfigBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
if (target.dataset.call) {
|
||||
await callContact(target.dataset.call);
|
||||
}
|
||||
if (target.dataset.edit) {
|
||||
editContact(Number(target.dataset.edit));
|
||||
}
|
||||
if (target.dataset.delete) {
|
||||
await deleteContact(Number(target.dataset.delete));
|
||||
}
|
||||
const payload = parseJsonInput("mqttConfigInput");
|
||||
const result = await requestJson("/api/config/mqtt", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshConfig(), refreshNetwork(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
document.getElementById("contactFeedback").textContent = error.message;
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("applyPinsConfigBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = parseJsonInput("pinsConfigInput");
|
||||
const result = await requestJson("/api/config/pins", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshConfig(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wifiConnectForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const ssid = document.getElementById("wifiSsid").value.trim();
|
||||
const pass = document.getElementById("wifiPass").value;
|
||||
try {
|
||||
const result = await requestJson("/api/network/wifi/connect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ ssid, pass }),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wifiDisconnectBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/wifi/disconnect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wifiReconnectBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/wifi/reconnect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wifiScanBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/wifi/scan", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await refreshNetwork();
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("mqttConnectBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/mqtt/connect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("mqttDisconnectBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/mqtt/disconnect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("mqttPublishForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const topic = document.getElementById("mqttTopic").value.trim();
|
||||
const payload = document.getElementById("mqttPayload").value;
|
||||
try {
|
||||
const result = await requestJson("/api/network/mqtt/publish", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ topic, payload }),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("espnowOnBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/espnow/on", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("espnowOffBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/network/espnow/off", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshStatus(), refreshNetwork()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("espnowPeerForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const mac = document.getElementById("espnowMac").value.trim();
|
||||
try {
|
||||
const result = await requestJson("/api/network/espnow/peer", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ mac }),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await refreshNetwork();
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("espnowDelBtn").addEventListener("click", async () => {
|
||||
const mac = document.getElementById("espnowMac").value.trim();
|
||||
try {
|
||||
const result = await requestJson("/api/network/espnow/peer", {
|
||||
method: "DELETE",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ mac }),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await refreshNetwork();
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("espnowSendForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const target = document.getElementById("espnowTarget").value.trim();
|
||||
const payloadRaw = document.getElementById("espnowPayload").value;
|
||||
const payload = parsePayloadValue(payloadRaw);
|
||||
try {
|
||||
const result = await requestJson("/api/network/espnow/send", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ mac: target, payload }),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await refreshNetwork();
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btHfpConnectForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const addr = document.getElementById("btHfpAddr").value.trim();
|
||||
try {
|
||||
const result = await requestJson("/api/bluetooth/hfp/connect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ addr }),
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshBluetooth(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btHfpDisconnectBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/bluetooth/hfp/disconnect", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshBluetooth(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshBluetooth(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btBleStopBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await requestJson("/api/bluetooth/ble/stop", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: "{}",
|
||||
});
|
||||
setJson("actionResult", result);
|
||||
await Promise.all([refreshBluetooth(), refreshStatus()]);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -217,14 +698,26 @@ function bindEvents() {
|
||||
try {
|
||||
await sendControl(button.dataset.action);
|
||||
} catch (error) {
|
||||
document.getElementById("controlResult").textContent = error.message;
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("rawCommandForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const action = document.getElementById("rawCommandInput").value.trim();
|
||||
try {
|
||||
await sendControl(action);
|
||||
} catch (error) {
|
||||
setJson("actionResult", { error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
bindEvents();
|
||||
await Promise.all([refreshStatus(), loadContacts(), loadConfig(), refreshLogs()]);
|
||||
showSection("contacts");
|
||||
connectRealtime();
|
||||
ensureFallbackPolling();
|
||||
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork(), refreshBluetooth()]);
|
||||
showSection("dashboard");
|
||||
});
|
||||
|
||||
+25
-18
@@ -6,6 +6,7 @@
|
||||
--accent-strong: #084a81;
|
||||
--ok: #19753d;
|
||||
--border: #d8dee4;
|
||||
--warn: #9a5f00;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -61,31 +62,28 @@ section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#contactsList {
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.contact-actions {
|
||||
margin-top: 8px;
|
||||
.inline-actions {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
form {
|
||||
@@ -101,9 +99,18 @@ pre {
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#contactFeedback {
|
||||
margin-top: 8px;
|
||||
color: var(--ok);
|
||||
@media (max-width: 760px) {
|
||||
body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
+49
-19
@@ -1,24 +1,54 @@
|
||||
# Gates & objectifs — Phase suivante RTC_BL_PHONE
|
||||
# RTC_BL_PHONE Kanban (Live Execution)
|
||||
|
||||
## 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é
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
## 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é.
|
||||
|
||||
## Actions à lancer
|
||||
- Développement endpoints manquants
|
||||
- Implémentation sécurité et validation
|
||||
- Création/extension des tests fonctionnels
|
||||
- Mise à jour documentation et scripts CI
|
||||
## 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.
|
||||
|
||||
---
|
||||
## 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`
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Cross-Repo Intelligence Base
|
||||
|
||||
Date: 2026-02-20
|
||||
|
||||
## Repos de référence
|
||||
|
||||
- `electron-rare/RTC_BL_PHONE`:
|
||||
- firmware téléphonie RTC + Bluetooth + WiFi + MQTT + ESP-NOW.
|
||||
- repo d’exécution pour la stack A252.
|
||||
- `electron-rare/le-mystere-professeur-zacus`:
|
||||
- repo scénario/UI avec bridge WiFi/ESP-NOW orienté événements story.
|
||||
- référence pour formats de payload entrants hétérogènes.
|
||||
- `electron-rare/Kill_LIFE`:
|
||||
- repo base méthodologique (agents, gates, evidence pack, standards firmware).
|
||||
- référence process qualité/traçabilité.
|
||||
|
||||
## Standards repris depuis Kill_LIFE
|
||||
|
||||
- Gates minimales obligatoires sur changements firmware:
|
||||
- `Spec` -> `Build` -> `Test` -> `Release`.
|
||||
- Evidence minimum à conserver:
|
||||
- logs build/test,
|
||||
- résultat CI,
|
||||
- artefacts de validation.
|
||||
- Standards firmware:
|
||||
- PlatformIO + Unity,
|
||||
- wrappers autour des drivers,
|
||||
- timeouts/watchdogs pour IO bloquants.
|
||||
|
||||
## Contrat d’interop RTC <-> Zacus
|
||||
|
||||
- Commandes série ESP-NOW alignées:
|
||||
- `ESPNOW_ON`
|
||||
- `ESPNOW_OFF`
|
||||
- `ESPNOW_STATUS`
|
||||
- `ESPNOW_PEER_ADD <mac>`
|
||||
- `ESPNOW_PEER_DEL <mac>`
|
||||
- `ESPNOW_PEER_LIST`
|
||||
- `ESPNOW_SEND <mac|broadcast> <payload>`
|
||||
- Payload entrant bridge accepté par `RTC_BL_PHONE`:
|
||||
- direct: `cmd`, `raw`, `command`, `action`
|
||||
- imbriqué: `event.*`, `message.*`, `payload.*`
|
||||
|
||||
## Règle opérationnelle
|
||||
|
||||
- Toute évolution protocolaire ESP-NOW doit:
|
||||
- être documentée ici + `docs/props.md`,
|
||||
- être testée au minimum par build terminal + smoke host,
|
||||
- être reflétée dans une PR liée côté repo partenaire si impact croisé.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Documentation technique & intelligence agents — RTC_BL_PHONE
|
||||
|
||||
Ce dossier centralise toute l'intelligence documentaire et la synergie agents du projet :
|
||||
|
||||
## Fiches agents
|
||||
| Agent | Fiche |
|
||||
|----------------------|-------------------------------|
|
||||
| AudioManager | fiche_AudioManager.md |
|
||||
| BluetoothManager | fiche_BluetoothManager.md |
|
||||
| RTOSManager | fiche_RTOSManager.md |
|
||||
| SLICManager | fiche_agent_embarque_stack.md |
|
||||
| WifiManager | roles_agents.md |
|
||||
| Web | plan_webui.md |
|
||||
| Energie | plan_stack_audio.md |
|
||||
| Lecture Audio | fiche_agent_audio_tools.md |
|
||||
| Téléphone SFP | plan_stack_telephone_sfp.md |
|
||||
| Repo & GitHub | plan_gestion_repo_github.md |
|
||||
| Tests Hardware | fiche_agents_test_hardware.md |
|
||||
| QA | procedure_tests_hardware.md |
|
||||
|
||||
Chaque fiche détaille : rôle, API, notifications, points de validation, synergie avec les autres agents.
|
||||
|
||||
## Plans d'architecture et de test
|
||||
| Plan | Fichier |
|
||||
|-----------------------------|-------------------------------|
|
||||
| Annuaire | plan_annuaire.md |
|
||||
| Chef projet ESP32S3 | plan_chef_projet_esp32s3_ag1171s.md |
|
||||
| Délégation agents | plan_delegation_agents.md |
|
||||
| Dev web | plan_dev_web.md |
|
||||
| Gestion repo GitHub | plan_gestion_repo_github.md |
|
||||
| Multitâche RTOS | plan_multitache_rtos.md |
|
||||
| RTOS | plan_rtos.md |
|
||||
| Stack audio | plan_stack_audio.md |
|
||||
| Stack lecture audio | plan_stack_lecture_audio.md |
|
||||
| Stack SLIC | plan_stack_slic.md |
|
||||
| Stack téléphone SFP | plan_stack_telephone_sfp.md |
|
||||
| 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 |
|
||||
|-----------------------------|-------------------------------|
|
||||
| Tests hardware | procedure_tests_hardware.md |
|
||||
| QA moniteur série | protocole_test_qa_moniteur_serie.md |
|
||||
| Robustesse RTOS | audit_robustesse_rtos.md |
|
||||
| Sécurité web | audit_securite_web.md |
|
||||
|
||||
## Rapports
|
||||
| Rapport | Fichier |
|
||||
|-----------------------------|-------------------------------|
|
||||
| CI Web | rapport_ci_web.md |
|
||||
| Exécution TODO | rapport_execution_todo.md |
|
||||
| Validation hardware | rapport_validation_hardware.md|
|
||||
| Synthèse validation | rapport_synthese_validation.md|
|
||||
| Tests fonctionnels | rapport_tests_fonctionnels.md |
|
||||
| Tests web | rapport_tests_web.md |
|
||||
| HW JSON | rapport_hw.json |
|
||||
|
||||
### Archives
|
||||
Les rapports d’audit, de validation et de CI anciens sont archivés dans [docs/archives/](docs/archives).
|
||||
|
||||
## Changelog
|
||||
Historique des évolutions : [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## Synergie agents
|
||||
- Les agents collaborent pour garantir la robustesse, la qualité et la traçabilité du firmware et du hardware.
|
||||
- Exemples :
|
||||
- Audio ↔ RTOS ↔ Web ↔ WiFi ↔ SLIC ↔ Téléphonie ↔ Energie ↔ Bluetooth
|
||||
- Repo & GitHub ↔ CI/CD ↔ Documentation
|
||||
|
||||
## Règles
|
||||
- Toute évolution majeure doit être documentée ici
|
||||
- Les fiches agents servent de référence pour la maintenance, la QA et la validation terrain
|
||||
- L'index (ce fichier) doit pointer vers tous les documents clés
|
||||
|
||||
## Pas de prompts story
|
||||
Ce projet n'utilise pas de prompts narratifs ou de story specs, mais une documentation technique et QA exhaustive.
|
||||
@@ -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é d’une 2e carte
|
||||
|
||||
### Addendum (2026-02-22)
|
||||
|
||||
- Firmware reflash `esp32dev` sur `/dev/cu.usbserial-0001` effectué.
|
||||
- Validation BT ready-for-pairing: `artifacts/bt_hfp_readiness.json`
|
||||
- `discoverable=true` sans client connecté.
|
||||
- `BT_CALLS`/`BT_DIAL` en erreur contrôlée hors SLC.
|
||||
- `BT_PBAP_SYNC` retourne `ERR ... unsupported` (attendu).
|
||||
- Validation discoverable forcé: `artifacts/bt_discoverable_policy_check.json`
|
||||
- `BT_DISCOVERABLE_OFF` accepté mais état final reste `discoverable=true` tant que non connecté.
|
||||
- ESP-NOW (tests minimisés): `artifacts/espnow_peer91_probe_clean.json`
|
||||
- peer `10:20:BA:58:C7:48` ajouté.
|
||||
- envoi unitaire observé avec `tx_fail=1` côté A252 (non bloquant pour le track Bluetooth).
|
||||
|
||||
### Addendum (2026-02-22 — wiring/polarité/audio)
|
||||
|
||||
- Polarité `GPIO21` verrouillée: `AMP_EN` actif bas (`LOW=ON`, `HIGH=OFF`).
|
||||
- `SLIC LINE` retiré du runtime (broche non utilisée en bench courant).
|
||||
- Mapping bench confirmé:
|
||||
- `RM=GPIO18`, `FR=GPIO5`, `SHK=GPIO23`, `PD=GPIO19`.
|
||||
- Tonalité locale fixée à `425 Hz` et niveau augmenté (boost léger) sans changement de timbre.
|
||||
- Durcissement Bluetooth en cours:
|
||||
- reprise explicite sur `ACL_DISCONN`,
|
||||
- retry audio SCO piloté par état d'appel (`dialing/alerting/ringing/active`).
|
||||
|
||||
### Addendum (2026-02-22 — dial/ring policy BT)
|
||||
|
||||
- Spécification ajoutée:
|
||||
- `DIAL`/`BT_DIAL` doit accepter une demande même sans SLC immédiat:
|
||||
- queue numéro,
|
||||
- déclenche/reprend connexion HFP,
|
||||
- compose automatiquement après `SLC_CONNECTED`.
|
||||
- Appel entrant GSM via HFP (`call_state=ringing`) doit déclencher la sonnerie RTC.
|
||||
- Décroché RTC en phase sonnerie doit déclencher `BT_ANSWER`.
|
||||
- Précondition produit explicitée:
|
||||
- sans lien HFP actif, la sonnerie RTC sur appel GSM n'est pas possible;
|
||||
- mitigation firmware: auto-reconnect sur peer bondé + discoverable forcé si non connecté.
|
||||
- Statut:
|
||||
- implémentation firmware: `in_progress` (queue dial + auto reconnect renforcés),
|
||||
- validation hardware E2E appel entrant/sortant: `pending`.
|
||||
|
||||
### Addendum (2026-02-22 — autonomous execution batch)
|
||||
|
||||
- Runtime policy applied:
|
||||
- HFP auto reconnect remains enabled by default (`auto_reconnect_enabled=true`).
|
||||
- New runtime toggles available:
|
||||
- `BT_AUTO_RECONNECT_ON`
|
||||
- `BT_AUTO_RECONNECT_OFF`
|
||||
- `BT_STATUS` now exposes `auto_reconnect_enabled`.
|
||||
- Web/API alignment:
|
||||
- endpoint added: `POST /api/bluetooth/hfp/auto` with body `{"enabled":true|false}`.
|
||||
- endpoint readback: `GET /api/bluetooth/hfp/auto` via `BT_STATUS`.
|
||||
- SSE realtime path re-enabled (`/api/events`) with client polling fallback.
|
||||
- Coexistence hardening:
|
||||
- ESP-NOW init now preserves current WiFi mode and applies modem sleep policy for WiFi/BT coexistence.
|
||||
- setup order adjusted: WiFi + ESP-NOW before BT startup.
|
||||
- Tracking:
|
||||
- `docs/AGENT_TODO.md` converted to live kanban with run ledger.
|
||||
|
||||
### Addendum (2026-02-22 — coex/runtime hardening batch 2)
|
||||
|
||||
- Build + flash:
|
||||
- `platformio run -e esp32dev` -> `PASS`
|
||||
- `platformio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> `PASS`
|
||||
- Runtime/bench:
|
||||
- `python3 scripts/hw_validation.py --port-a252 /dev/cu.usbserial-0001 --report-json artifacts/hw_validation_report.json --report-md artifacts/hw_validation_report.md` -> `PASS`
|
||||
- firmware hash (sha256): `2c345d5ac459e6aa914f47438cc35bbe33dda68e5a65f41eaf3d8f1efb53e833`
|
||||
- Coexistence hardening applied:
|
||||
- réimposition périodique `WIFI_PS_MIN_MODEM` dans `WifiManager::loop()`.
|
||||
- réimposition coex avant/après `btStart()` dans `BluetoothManager::ensureBtStackReady()`.
|
||||
- logs GAP en callback réduits (`kVerboseGapLogs=false`) pour éviter contention newlib/locks en task BT.
|
||||
- pression mémoire AsyncTCP réduite (`STACK=4096`, `QUEUE=12`).
|
||||
- Qualif scripts:
|
||||
- `bash scripts/test_terminal.sh` -> `PASS`
|
||||
- `python3 -m unittest scripts/test_check_web_route_parity.py` -> `PASS`
|
||||
- `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` -> `PASS`
|
||||
- Résiduel:
|
||||
- endurance 10 min sans assert à confirmer en monitor live.
|
||||
- validation E2E entrant GSM -> ring RTC -> `BT_ANSWER` encore `pending`.
|
||||
- Gouvernance:
|
||||
- Issues mises à jour avec preuves:
|
||||
- `#10`: `https://github.com/electron-rare/RTC_BL_PHONE/issues/10#issuecomment-3941685856`
|
||||
- `#11`: `https://github.com/electron-rare/RTC_BL_PHONE/issues/11#issuecomment-3941685860`
|
||||
@@ -0,0 +1,2 @@
|
||||
# Audit initial audio
|
||||
...existing code...
|
||||
@@ -0,0 +1,2 @@
|
||||
# Audit initial lecture audio
|
||||
...existing code...
|
||||
@@ -0,0 +1,2 @@
|
||||
# Audit initial SLIC
|
||||
...existing code...
|
||||
@@ -0,0 +1,2 @@
|
||||
# Audit initial téléphone SFP
|
||||
...existing code...
|
||||
@@ -0,0 +1,2 @@
|
||||
# Rapport CI Web
|
||||
...existing code...
|
||||
@@ -0,0 +1,2 @@
|
||||
# Rapport exécution TODO
|
||||
...existing code...
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"rapport": "HW initial"
|
||||
// ...existing code...
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Rapport synthèse validation
|
||||
...existing code...
|
||||
@@ -0,0 +1,2 @@
|
||||
# Rapport tests fonctionnels
|
||||
...existing code...
|
||||
@@ -0,0 +1,2 @@
|
||||
# Rapport tests web
|
||||
...existing code...
|
||||
@@ -0,0 +1,2 @@
|
||||
# Rapport validation hardware
|
||||
...existing code...
|
||||
+78
-10
@@ -1,14 +1,82 @@
|
||||
# Audit initial Audio
|
||||
# Audit Audio / BT / RTOS — état et plan d'enchaînement
|
||||
|
||||
## Robustesse
|
||||
- Instanciation, configuration codec OK
|
||||
- Aucun crash lors des appels de méthodes
|
||||
## État validé (run local USB `/dev/cu.usbserial-0001`)
|
||||
- Boot stable: audio + codec + web server OK.
|
||||
- Capture audio: arbitrage multi-clients en place (`TELEPHONY`, `BLUETOOTH`, `GENERIC`).
|
||||
- Bluetooth HFP:
|
||||
- plus de crash immédiat au boot.
|
||||
- plus d'auto-connect agressif au boot (pair bond restauré, connexion sur demande).
|
||||
- dial peut rester en queue (`pending_dial_number`) si SLC non prêt.
|
||||
- Coexistence WiFi/BT:
|
||||
- erreurs fatales/abort au boot réduites après déport d'actions callback vers `tick()`.
|
||||
|
||||
## Fiabilité
|
||||
- Méthodes stables, pas d’erreur détectée
|
||||
## Correctifs clés appliqués
|
||||
- `AudioEngine`:
|
||||
- `requestCapture/releaseCapture` thread-safe.
|
||||
- task audio avec backoff idle (charge RTOS réduite).
|
||||
- `TelephonyService` / `BluetoothManager`:
|
||||
- plus de `startCapture/stopCapture` concurrents.
|
||||
- utilisation des clients de capture dédiés.
|
||||
- `BluetoothManager`:
|
||||
- déport de `applyDiscoverablePolicy/publishBleStatus` hors callbacks.
|
||||
- suppression d’auto-reconnect HFP au boot.
|
||||
- timeout SLC en mode backoff (sans disconnect agressif).
|
||||
- désactivation `AgentSupervisor` côté BT pour retirer allocations C++ en callback.
|
||||
|
||||
## Points à surveiller
|
||||
- Tests hardware à approfondir
|
||||
## Plan exécutable (skills coordonnés)
|
||||
|
||||
---
|
||||
_Audit généré automatiquement._
|
||||
### Track 1 — `esp32-rtos-coex-triage` (en cours)
|
||||
- Objectif: zéro reboot/assert sur séquence boot + WiFi STA + BT activé.
|
||||
- Actions:
|
||||
- garder le BT callback-safe (déjà fait).
|
||||
- surveiller `wifi: fail to alloc timer` sur endurance.
|
||||
- livrer un rapport de stabilité sur 10 min.
|
||||
|
||||
### Track 2 — `esp32-bt-hfp-stability` (prochaine étape)
|
||||
- Objectif: connexion GSM stable, SLC up, dial auto depuis buffer len10.
|
||||
- Actions:
|
||||
- test live: `BT_HFP_CONNECT`, `BT_STATUS`, appel sortant/entrant.
|
||||
- valider transition `pending_dial_number -> dial requested` quand SLC connecté.
|
||||
- verrouiller comportement discoverable auto si non connecté.
|
||||
|
||||
### Track 3 — `esp32-audio-runtime-gating` (prochaine étape)
|
||||
- Objectif: tonalité et audio call sans glitch ni coupure.
|
||||
- Actions:
|
||||
- vérifier arrêt instant tonalité au 1er digit.
|
||||
- vérifier arrêt tonalité immédiat au raccroché.
|
||||
- valider chemin HFP audio I2S en appel actif.
|
||||
|
||||
## Critères de sortie
|
||||
- Aucun `abort/assert` pendant test run.
|
||||
- Aucun `vQueueDelete queue.c` observé.
|
||||
- Numérotation 10 chiffres pulse/DTMF déclenche `BT_DIAL` avec succès quand SLC monte.
|
||||
- Appel entrant GSM: ring, décroché, audio duplex.
|
||||
|
||||
## Plan autonome coordonné (skills + agents)
|
||||
|
||||
- Skill utilisé pour cadrage spec->implémentation: `notion-spec-to-implementation` (adapté en mode repo local).
|
||||
- Agent `bt-hfp-stability`:
|
||||
- garantir `DIAL => queue + connect HFP + auto-dial post-SLC`.
|
||||
- garder `discoverable=true` si aucun client BT connecté.
|
||||
- Agent `rtos-coex-wifi-bt`:
|
||||
- forcer `WIFI_PS_MIN_MODEM` en STA/APSTA pour coexistence WiFi+BT.
|
||||
- supprimer les reboots `Should enable WiFi modem sleep ...`.
|
||||
- Agent `telephony-call-routing`:
|
||||
- appel entrant HFP (`ringing`) => sonnerie RTC.
|
||||
- décroché RTC en sonnerie => `BT_ANSWER`.
|
||||
- Gate de validation utilisateur (bench USB):
|
||||
- confirmer logs `dial_trigger ... ok=true` puis `hfp_slc_connected`/`hfp_dial_requested`.
|
||||
- confirmer appel entrant GSM => sonnerie RTC immédiate.
|
||||
|
||||
## Exécution autonome (batch courant)
|
||||
- `main.cpp`:
|
||||
- ordre de boot ajusté pour réduire contention WiFi/BT (`WiFi/ESP-NOW` avant `BT begin`).
|
||||
- commandes runtime ajoutées: `BT_AUTO_RECONNECT_ON`, `BT_AUTO_RECONNECT_OFF`.
|
||||
- `BluetoothManager`:
|
||||
- flag runtime `auto_reconnect_enabled` exposé dans `BT_STATUS`.
|
||||
- reconnexion auto conditionnée par policy + queue dial.
|
||||
- `WebServerManager` + WebUI:
|
||||
- SSE réactivé (`/api/events`) + fallback polling navigateur.
|
||||
- endpoint ajouté: `/api/bluetooth/hfp/auto`.
|
||||
- `EspNowBridge`:
|
||||
- init WiFi coex durci (mode préservé + modem sleep policy).
|
||||
|
||||
@@ -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é.
|
||||
@@ -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.
|
||||
@@ -1,43 +1,45 @@
|
||||
# Fiche agent — Stack embarquée (Web, RTOS, Energie, Bluetooth, WiFi)
|
||||
|
||||
## Objectif
|
||||
Décrire l’intégration, la gestion et la validation des stacks web (HTTP), RTOS (FreeRTOS), gestion d’énergie (batterie), Bluetooth et WiFi pour ESP32.
|
||||
# Spécifications des stacks agents RTC_BL_PHONE
|
||||
|
||||
## AudioManager
|
||||
- Initialisation audio, lecture de fichiers, capture, métriques runtime.
|
||||
- Méthodes : `begin`, `playFile`, `startCapture`, `stopCapture`, `metrics`, `resetMetrics`.
|
||||
- Doit notifier le superviseur à chaque changement d’état ou erreur critique.
|
||||
|
||||
## BluetoothManager
|
||||
- Gestion BT Classic et BLE, connexion/déconnexion, sécurité, logs d’état.
|
||||
- Méthodes : `begin`, `connect`, `disconnect`, `isConnected`, `startHFP`, `stopHFP`, `startBLE`, `stopBLE`, `logStatus`, `setSecurity`.
|
||||
- Doit notifier le superviseur à chaque changement d’état ou erreur critique.
|
||||
|
||||
## RTOSManager
|
||||
- Gestion des tâches FreeRTOS, watchdog, audit, logs d’état.
|
||||
- Méthodes : `begin`, `createTask`, `startScheduler`, `auditTasks`, `logStatus`, `enableWatchdog`, `feedWatchdog`.
|
||||
- Doit notifier le superviseur à chaque événement critique (ex : watchdog déclenché).
|
||||
|
||||
## SLICManager
|
||||
- Encapsulation du contrôleur SLIC, gestion des états ligne, transitions, monitoring.
|
||||
- Méthodes : `begin`, `attachController`, `monitorLine`, `controlCall`, `state`, `isHookOff`.
|
||||
- Doit notifier le superviseur à chaque changement d’état ou erreur critique.
|
||||
|
||||
## WifiManager
|
||||
- Gestion connexion WiFi, état, SSID, reconnexion, logs.
|
||||
- Méthodes : `begin`, `loop`, `isConnected`, `scan`, `reconnect`, `logStatus`.
|
||||
- Doit notifier le superviseur à chaque changement d’état ou erreur critique.
|
||||
|
||||
---
|
||||
|
||||
### 1. Stack Web (HTTP)
|
||||
- Librairie : ESPAsyncWebServer
|
||||
- Serveur HTTP asynchrone, endpoints REST, pages dynamiques.
|
||||
- Exemple : monitoring, configuration, logs, streaming audio.
|
||||
|
||||
### 2. Stack RTOS (FreeRTOS)
|
||||
- ESP32 embarque FreeRTOS nativement (multi-tâches, priorités, synchronisation).
|
||||
- Utilisation : tâches audio, gestion hook, serveur web, gestion batterie.
|
||||
- API : xTaskCreate, vTaskDelay, queues, mutex.
|
||||
|
||||
### 3. Gestion d’énergie (batterie)
|
||||
- Surveillance tension batterie (ADC), gestion deep sleep, wakeup, logs.
|
||||
- API : analogRead, esp_sleep_enable_ext0_wakeup, esp_deep_sleep_start.
|
||||
- Scénarios : mode économie, coupure audio, notification web.
|
||||
|
||||
### 4. Stack Bluetooth (Classic/BLE)
|
||||
- Librairie : esp_bt, esp_hf_client_api (HFP), BLE (esp32 BLE Arduino)
|
||||
- Utilisation : appels HFP, pairing, notifications, streaming audio.
|
||||
- API : esp_bt_device, esp_bt_main, BLEDevice, BLEServer.
|
||||
|
||||
### 5. Stack WiFi
|
||||
- Librairie : WiFi (Arduino), esp_wifi (ESP-IDF)
|
||||
- Utilisation : connexion réseau, serveur web, OTA, logs.
|
||||
- API : WiFi.begin, WiFi.status, esp_wifi_set_mode.
|
||||
# Améliorations évidentes
|
||||
- Ajout du pattern de notification vers AgentSupervisor dans chaque stack (méthode notify à chaque changement d’état ou erreur).
|
||||
- Ajout d’un bus d’événements pour permettre la réaction croisée (ex : coupure WiFi → pause audio).
|
||||
- Exposition de l’état global via web API et logs synthétiques.
|
||||
- Ajout de tests de coordination dans la validation Python.
|
||||
|
||||
---
|
||||
|
||||
## Extension : SLIC, Audio, Lecture Audio, Téléphone SFP
|
||||
|
||||
Ajout des stacks SLIC (K50835F, AG1171S), audio (ES8388, PCM5102), lecture audio (MP3/WAV), et stack téléphone SFP pour une gestion complète du hardware téléphonique.
|
||||
|
||||
### 6. Stack SLIC (K50835F, AG1171S)
|
||||
- Gestion interface ligne téléphonique, détection hook, ring, signalisation.
|
||||
# Audit
|
||||
- Toutes les stacks sont présentes et conformes à leur rôle métier.
|
||||
- La coordination dynamique (supervision, réaction croisée) est en cours d’amélioration avec AgentSupervisor.
|
||||
- Les spécifications sont désormais explicites et à jour.
|
||||
- API : contrôle SLIC, monitoring ligne, gestion appels.
|
||||
|
||||
### 7. Stack Audio (ES8388, PCM5102)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Fiche agent — Test hardware RTC_BL_PHONE
|
||||
|
||||
## Objectif
|
||||
Décrire le rôle, la méthodologie et la validation de chaque agent de test hardware pour la livraison RTC_BL_PHONE.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Téléphonie
|
||||
- Valide l’émission/réception d’appels, la détection hook/ring, la gestion DTMF.
|
||||
- Vérifie la robustesse des transitions d’état (idle, appel, raccroché, décroché).
|
||||
- Documente les logs et les scénarios d’erreur.
|
||||
|
||||
## 2. Agent Audio
|
||||
- Valide la lecture MP3/WAV, le routage audio, le volume, le mute.
|
||||
- Vérifie la qualité audio sur haut-parleur et combiné.
|
||||
- Teste la gestion des erreurs audio (fichier absent, SD non montée).
|
||||
|
||||
## 3. Agent Web
|
||||
- Valide l’accessibilité de l’interface web embarquée.
|
||||
- Vérifie les endpoints (`/`, `/status`, `/config`, `/logs`).
|
||||
- Teste la réactivité et la robustesse sous charge.
|
||||
|
||||
## 4. Agent MQTT
|
||||
- Valide la réception/émission de commandes et d’événements via MQTT.
|
||||
- Vérifie la conformité du schéma JSON, la gestion des erreurs réseau.
|
||||
- Teste la reconnexion automatique et la persistance des états.
|
||||
|
||||
## 5. Agent ESP-NOW
|
||||
- Valide la réception/émission de commandes et d’événements via ESP-NOW.
|
||||
- Vérifie le broadcast, la robustesse en cas de perte de pair.
|
||||
|
||||
## 6. Agent WiFi
|
||||
- Valide la connexion/déconnexion, la gestion des coupures, le fallback config.
|
||||
- Teste la robustesse en cas de reboot ou de perte de réseau.
|
||||
|
||||
## 7. Agent SLIC
|
||||
- Valide la gestion hardware SLIC, la détection de ligne, la signalisation.
|
||||
- Vérifie la fiabilité sur plusieurs cycles d’appel.
|
||||
|
||||
## 8. Agent Energie
|
||||
- Valide la gestion batterie, le deep sleep, le wakeup.
|
||||
- Vérifie la robustesse en cas de coupure d’alimentation.
|
||||
|
||||
## 9. Agent Logs
|
||||
- Surveille et collecte tous les logs série, web, MQTT, ESP-NOW.
|
||||
- Documente les erreurs, warnings, et comportements inattendus.
|
||||
|
||||
---
|
||||
|
||||
**Synergie agents** :
|
||||
- Les agents collaborent pour valider les scénarios croisés (ex : appel via web, événement MQTT, coupure WiFi, etc.).
|
||||
- Chaque agent documente ses résultats dans le rapport de synthèse.
|
||||
|
||||
**Version :** 2026-02-18
|
||||
@@ -0,0 +1,56 @@
|
||||
# Plan de tests — RTC_BL_PHONE (livraison finale)
|
||||
|
||||
## 1. Objectif
|
||||
Valider la robustesse, la couverture fonctionnelle et la conformité de la livraison RTC_BL_PHONE : téléphonie, web, MQTT, ESP-NOW, DTMF, tests, CI/CD, documentation.
|
||||
|
||||
## 2. Environnements
|
||||
- ESP32 DevKitC (esp32dev)
|
||||
- ESP32-S3-DevKitC-1 (esp32-s3-devkitc-1)
|
||||
|
||||
## 3. Tests unitaires (PlatformIO/Unity)
|
||||
- Lancer `pio test` sur chaque environnement
|
||||
- Vérifier le passage de tous les tests (DTMF, props routing, AudioManager, SLIC, etc.)
|
||||
- Générer le rapport de couverture (`scripts/gen_coverage.sh`)
|
||||
|
||||
## 4. Tests fonctionnels
|
||||
- Appels téléphoniques (émission, réception, raccrochage, décrochage)
|
||||
- Contrôle via web UI (initier appel, voir statut, contacts)
|
||||
- Contrôle via MQTT (topics in/out, payload JSON)
|
||||
- Contrôle via ESP-NOW (commande locale, broadcast)
|
||||
- Détection DTMF logicielle (Goertzel)
|
||||
- Routage audio (lecture MP3, volume, mute)
|
||||
- Gestion WiFi (connexion, déconnexion, fallback)
|
||||
|
||||
## 5. Tests web/HTTP
|
||||
- Endpoints `/`, `/status`, `/config`, `/logs` : code 200, format JSON
|
||||
- Tests de charge (requêtes multiples)
|
||||
- Tests d’accès non autorisé
|
||||
- Vérification sécurisation API : accès POST sans authentification (401/403 attendu), test CORS, automatisé par `scenario_api_security`
|
||||
|
||||
## 6. Robustesse & sécurité
|
||||
- Scénarios de coupure WiFi, reboot, perte agent MQTT/ESP-NOW
|
||||
- Validation fallback config SPIFFS/NVS
|
||||
- Logs d’erreur et de récupération
|
||||
|
||||
### Couverture automatisée réseau (WiFi/BLE)
|
||||
- Scan WiFi/BLE : détection des réseaux et périphériques à proximité
|
||||
- Connexion WiFi/BLE : test explicite de connexion (SSID, mot de passe, nom BLE)
|
||||
- Coupure/rétablissement WiFi : test de déconnexion/reconnexion automatique, validation de la reprise de service
|
||||
- Coupure WiFi + fallback BLE : test de bascule automatique sur BLE si WiFi indisponible
|
||||
|
||||
## 7. Limitations connues
|
||||
- Bluetooth Classic non supporté sur ESP32-S3 (fallback BLE)
|
||||
- Warnings `DynamicJsonDocument` (non bloquant, documenté)
|
||||
|
||||
## 8. Documentation
|
||||
- Vérifier la complétude des guides README, fiches agents, rapports CI, plans de test
|
||||
|
||||
## 9. Critères de succès
|
||||
- Tous les tests passent sur les deux cibles
|
||||
- Fonctionnalités principales validées (téléphonie, web, MQTT, ESP-NOW, DTMF)
|
||||
- Documentation complète et à jour
|
||||
- Aucun blocage critique non documenté
|
||||
|
||||
---
|
||||
|
||||
**Version :** 2026-02-18
|
||||
@@ -0,0 +1,45 @@
|
||||
# Procédure de tests hardware — RTC_BL_PHONE
|
||||
|
||||
## 1. Préparation
|
||||
- Flasher le firmware compilé sur chaque carte cible :
|
||||
- ESP32 DevKitC (esp32dev)
|
||||
- ESP32-S3-DevKitC-1 (esp32-s3-devkitc-1)
|
||||
- Connecter le combiné, SLIC, AG1171S, haut-parleur, micro, carte SD (si lecture audio).
|
||||
- Ouvrir le moniteur série à 115200 bauds.
|
||||
|
||||
## 2. Tests téléphonie
|
||||
- Émettre un appel via la web UI, MQTT, ESP-NOW, ou commande série.
|
||||
- Décrocher/raccrocher physiquement (hook), vérifier la détection.
|
||||
- Recevoir un appel entrant, vérifier la sonnerie et la détection.
|
||||
- Tester la numérotation DTMF (clavier ou synthétique).
|
||||
|
||||
## 3. Tests audio
|
||||
- Lire un fichier MP3/WAV depuis la carte SD.
|
||||
- Vérifier le routage audio, le volume, le mute.
|
||||
- Tester la qualité audio sur haut-parleur et combiné.
|
||||
|
||||
## 4. Tests web et MQTT
|
||||
- Accéder à l’interface web embarquée, vérifier les endpoints (`/`, `/status`, `/config`).
|
||||
- Envoyer des commandes MQTT (mosquitto_pub), vérifier la réception et l’exécution.
|
||||
- Vérifier la publication des événements MQTT (mosquitto_sub).
|
||||
|
||||
## 5. Tests ESP-NOW
|
||||
- Envoyer une commande via ESP-NOW depuis un autre ESP32, vérifier la réception et l’exécution.
|
||||
- Vérifier le broadcast d’événements ESP-NOW.
|
||||
|
||||
## 6. Robustesse
|
||||
- Débrancher/rebrancher le WiFi, vérifier la reconnexion automatique.
|
||||
- Redémarrer la carte, vérifier la persistance de la config (SPIFFS/NVS).
|
||||
- Simuler une perte d’agent MQTT/ESP-NOW, vérifier la reprise.
|
||||
|
||||
## 7. Logs et validation
|
||||
- Vérifier les logs série pour chaque action/test.
|
||||
- Noter tout comportement inattendu ou bug.
|
||||
|
||||
## 8. Critères de succès
|
||||
- Toutes les fonctionnalités principales sont validées sur chaque cible.
|
||||
- Aucun blocage critique, logs d’erreur documentés.
|
||||
|
||||
---
|
||||
|
||||
**Version :** 2026-02-18
|
||||
@@ -0,0 +1,29 @@
|
||||
# Intégration ArduinoProps & ESP-NOW
|
||||
|
||||
## MQTT (ArduinoProps)
|
||||
- Contrôle via topics : `rtc_bl_phone/<device_id>/in` (commandes), `rtc_bl_phone/<device_id>/out` (événements).
|
||||
- Payload JSON : `{ "cmd": "CALL" }`, `{ "cmd": "PLAY", "path": "/welcome.wav" }`, etc.
|
||||
- Statut publié identique à la sortie série.
|
||||
|
||||
## ESP-NOW
|
||||
- Contrôle local sans broker, même schéma de payload JSON.
|
||||
- Les événements sont broadcastés à tous les pairs ESP-NOW.
|
||||
- Commandes série runtime disponibles : `ESPNOW_ON`, `ESPNOW_OFF`, `ESPNOW_STATUS`, `ESPNOW_PEER_ADD`, `ESPNOW_PEER_DEL`, `ESPNOW_PEER_LIST`, `ESPNOW_SEND`.
|
||||
- Pour la compatibilité inter-repos (`le-mystere-professeur-zacus`), la commande entrante peut être lue depuis :
|
||||
- `cmd`, `raw`, `command`, `action`
|
||||
- `event.<...>` (objet imbriqué)
|
||||
- `message.<...>` (objet imbriqué)
|
||||
- `payload.<...>` (objet ou texte)
|
||||
|
||||
## DTMF logiciel
|
||||
- Détection Goertzel sur frames audio, publication des chiffres détectés dans les événements MQTT/ESP-NOW.
|
||||
|
||||
## Limitations ESP32-S3
|
||||
- Pas de Bluetooth Classic (BLE uniquement).
|
||||
- Les fonctionnalités dépendantes du BT Classic sont désactivées sur S3.
|
||||
|
||||
## Exemples
|
||||
- Publier une commande MQTT :
|
||||
`mosquitto_pub -t rtc_bl_phone/mondevice/in -m '{"cmd":"CALL"}'`
|
||||
- Écouter les événements :
|
||||
`mosquitto_sub -t rtc_bl_phone/mondevice/out`
|
||||
@@ -0,0 +1,57 @@
|
||||
# Protocole de test automatisé — Agent QA & Moniteur Série
|
||||
|
||||
## Objectif
|
||||
Décrire la procédure de test automatisée et semi-automatisée pour RTC_BL_PHONE, en s’appuyant sur un agent QA dédié et l’utilisation du moniteur série pour la traçabilité et la validation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Préparation
|
||||
- Brancher ESP32 Audio Kit et ESP32-S3-DevKitC-1
|
||||
- Flasher le firmware sur chaque carte
|
||||
- Ouvrir le moniteur série à 115200 bauds
|
||||
- Préparer le script de capture des logs série (ex : `screen`, `minicom`, ou script Python)
|
||||
|
||||
## 2. Agent QA — Rôle
|
||||
- Supervise l’exécution des tests hardware
|
||||
- Déclenche les scénarios (commande série, web, MQTT, ESP-NOW)
|
||||
- Observe et enregistre les logs série
|
||||
- Valide les critères de succès pour chaque test
|
||||
- Remplit le rapport de validation en temps réel
|
||||
|
||||
## 3. Protocole de test
|
||||
### a. Initialisation
|
||||
- Vérifier le boot, la détection hardware, l’affichage du statut sur le moniteur série
|
||||
- Noter toute erreur ou warning au démarrage
|
||||
|
||||
### b. Exécution séquentielle
|
||||
Pour chaque test :
|
||||
1. Déclencher l’action (commande série, web, MQTT, ESP-NOW)
|
||||
2. Observer la réponse sur le moniteur série
|
||||
3. Vérifier la conformité du log (statut, événement, erreur)
|
||||
4. Noter le résultat dans le rapport
|
||||
|
||||
### c. Exemples de commandes série
|
||||
- `h` : aide
|
||||
- `s` : statut runtime
|
||||
- `p <mac>` : configurer la MAC
|
||||
- `m <numero>` : émission d’appel
|
||||
- `a` : décrocher
|
||||
- `e` : raccrocher
|
||||
- `v <0..15>` : volume
|
||||
|
||||
### d. Capture automatique
|
||||
- Utiliser un script pour enregistrer tous les logs série dans un fichier horodaté
|
||||
- Marquer chaque début/fin de test dans le log (ex : `=== TEST AUDIO START ===`)
|
||||
|
||||
## 4. Validation et traçabilité
|
||||
- Chaque test est validé si le log série confirme l’action attendue sans erreur
|
||||
- Les logs sont archivés avec le rapport de validation
|
||||
- Toute anomalie est documentée immédiatement
|
||||
|
||||
## 5. Agent QA — Spécialisation
|
||||
- Peut être un opérateur humain, un script Python, ou un outil d’automatisation (ex : PySerial)
|
||||
- Doit pouvoir envoyer des commandes, lire et parser les logs, générer un rapport automatique
|
||||
|
||||
---
|
||||
|
||||
**Version :** 2026-02-18
|
||||
@@ -0,0 +1,30 @@
|
||||
# Rapport d’audit documentaire RTC_BL_PHONE
|
||||
|
||||
## Synthèse
|
||||
La documentation technique, QA et agents est centralisée sous `docs/`. L’index documentaire a été mis à jour pour référencer tous les documents clés. Les rapports d’audit, validation et CI sont archivés dans `docs/archives/`.
|
||||
|
||||
## Cohérence
|
||||
- Les fiches agents, plans, procédures et rapports sont bien présents.
|
||||
- L’index [docs/README.md](docs/README.md) référence explicitement chaque document.
|
||||
- Les archives sont structurées.
|
||||
|
||||
## Actualité
|
||||
- Les documents sont datés et la plupart sont à jour.
|
||||
- Le changelog est présent pour tracer les évolutions majeures.
|
||||
|
||||
## Clarté
|
||||
- Les fiches agents détaillent rôle, API, notifications, points de validation, synergie.
|
||||
- Les plans et procédures sont structurés par thème.
|
||||
- Les rapports sont accessibles et archivés.
|
||||
|
||||
## Traçabilité
|
||||
- La synergie agents est documentée.
|
||||
- Les évolutions majeures sont tracées dans le changelog.
|
||||
|
||||
## Points à améliorer
|
||||
- Vérifier la mise à jour régulière des fiches agents.
|
||||
- Compléter les archives si besoin.
|
||||
- Effectuer un audit manuel périodique.
|
||||
|
||||
## Conclusion
|
||||
La documentation est cohérente, structurée et centrée sur la maintenance, la QA, la validation hardware/firmware et la traçabilité agents.
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"timestamp_utc": "2026-02-20T20:47:49.473689+00:00",
|
||||
"serial": {
|
||||
"ping": "PONG",
|
||||
"ble_start": "OK BT_BLE_START",
|
||||
"bt_status_after_start": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":true,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"advertising\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
"ble": {
|
||||
"scan": [
|
||||
{
|
||||
"name": "Withings Aura 5E",
|
||||
"address": "9214040A-F54E-37AC-4B4B-F609F94ECB5E",
|
||||
"rssi": null
|
||||
},
|
||||
{
|
||||
"name": "Bagage de Clément",
|
||||
"address": "8E6D24D4-22CE-C8FC-FC6F-01090C567679",
|
||||
"rssi": null
|
||||
},
|
||||
{
|
||||
"name": "RTC_BL_PHONE_A252",
|
||||
"address": "3DD63766-AF90-14D9-7964-F37AE7FB0704",
|
||||
"rssi": null
|
||||
},
|
||||
{
|
||||
"name": "Uscan 42",
|
||||
"address": "98923C99-1897-8665-7EE6-647A96924DB7",
|
||||
"rssi": null
|
||||
},
|
||||
{
|
||||
"name": "GSM de clemsail",
|
||||
"address": "CFCE5495-5537-1F54-1A77-7CF8CEC57FF3",
|
||||
"rssi": null
|
||||
}
|
||||
],
|
||||
"target": {
|
||||
"name": "RTC_BL_PHONE_A252",
|
||||
"address": "3DD63766-AF90-14D9-7964-F37AE7FB0704"
|
||||
},
|
||||
"connected": true,
|
||||
"services": [
|
||||
"8fce0001-93ea-4f8f-8bde-4e8f0ea20001"
|
||||
],
|
||||
"has_service": true,
|
||||
"status_initial_raw": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":true,\"ble_client_connected\":true,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"client_connected\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"status_after_ping_raw": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":true,\"ble_client_connected\":true,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"cmd\",\"last_error\":\"\",\"ble_last_command\":\"PING\",\"ble_last_response\":\"PONG\"}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"timestamp_utc": "2026-02-20T20:48:25.147633+00:00",
|
||||
"hfp_mac": "B4:1B:B0:84:73:06",
|
||||
"steps": {
|
||||
"ping": {
|
||||
"result": "PONG",
|
||||
"lines": [
|
||||
"[AudioEngine] ready (full_duplex=true)",
|
||||
"[RTC_BL_PHONE] HW init slic=ok codec=ok audio=ok",
|
||||
"[WebServerManager] HTTP server started",
|
||||
"[RTC_BL_PHONE] Boot: profile=ESP32_A252 bt_classic=true full_duplex=true",
|
||||
"[RTC_BL_PHONE] Commands:",
|
||||
"PING",
|
||||
"HELP",
|
||||
"STATUS",
|
||||
"CALL",
|
||||
"CAPTURE_START",
|
||||
"CAPTURE_STOP",
|
||||
"PLAY",
|
||||
"RESET_METRICS",
|
||||
"WIFI_CONNECT",
|
||||
"WIFI_DISCONNECT",
|
||||
"WIFI_STATUS",
|
||||
"WIFI_SCAN",
|
||||
"WIFI_RECONNECT",
|
||||
"MQTT_CONFIG_SET",
|
||||
"MQTT_CONNECT",
|
||||
"MQTT_DISCONNECT",
|
||||
"MQTT_STATUS",
|
||||
"MQTT_PUB",
|
||||
"ESPNOW_PEER_ADD",
|
||||
"ESPNOW_PEER_DEL",
|
||||
"ESPNOW_PEER_LIST",
|
||||
"ESPNOW_STATUS",
|
||||
"ESPNOW_SEND",
|
||||
"BT_HFP_CONNECT",
|
||||
"BT_HFP_DISCONNECT",
|
||||
"BT_BLE_START",
|
||||
"BT_BLE_STOP",
|
||||
"BT_STATUS",
|
||||
"SLIC_CONFIG_GET",
|
||||
"SLIC_CONFIG_SET",
|
||||
"AUDIO_CONFIG_GET",
|
||||
"AUDIO_CONFIG_SET",
|
||||
"PONG"
|
||||
]
|
||||
},
|
||||
"bt_status_before": {
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"lines": [
|
||||
"{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
]
|
||||
},
|
||||
"hfp_connect_cmd": {
|
||||
"result": "OK BT_HFP_CONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_CONNECT"
|
||||
]
|
||||
},
|
||||
"hfp_status_poll": [
|
||||
{
|
||||
"t": 0,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 12,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 14,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 16,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 18,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 20,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t": 22,
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
}
|
||||
],
|
||||
"hfp_disconnect_cmd": {
|
||||
"result": "OK BT_HFP_DISCONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_DISCONNECT"
|
||||
]
|
||||
},
|
||||
"bt_status_after": {
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"lines": [
|
||||
"{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
{
|
||||
"timestamp_utc": "2026-02-20T20:51:14.060839+00:00",
|
||||
"target": "B4:1B:B0:84:73:06",
|
||||
"attempts": [
|
||||
{
|
||||
"attempt": 1,
|
||||
"steps": [
|
||||
{
|
||||
"cmd": "BT_STATUS",
|
||||
"resp": {
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"lines": [
|
||||
"[AudioEngine] ready (full_duplex=true)",
|
||||
"[RTC_BL_PHONE] HW init slic=ok codec=ok audio=ok",
|
||||
"[WebServerManager] HTTP server started",
|
||||
"[RTC_BL_PHONE] Boot: profile=ESP32_A252 bt_classic=true full_duplex=true",
|
||||
"[RTC_BL_PHONE] Commands:",
|
||||
"PING",
|
||||
"HELP",
|
||||
"STATUS",
|
||||
"CALL",
|
||||
"CAPTURE_START",
|
||||
"CAPTURE_STOP",
|
||||
"PLAY",
|
||||
"RESET_METRICS",
|
||||
"WIFI_CONNECT",
|
||||
"WIFI_DISCONNECT",
|
||||
"WIFI_STATUS",
|
||||
"WIFI_SCAN",
|
||||
"WIFI_RECONNECT",
|
||||
"MQTT_CONFIG_SET",
|
||||
"MQTT_CONNECT",
|
||||
"MQTT_DISCONNECT",
|
||||
"MQTT_STATUS",
|
||||
"MQTT_PUB",
|
||||
"ESPNOW_PEER_ADD",
|
||||
"ESPNOW_PEER_DEL",
|
||||
"ESPNOW_PEER_LIST",
|
||||
"ESPNOW_STATUS",
|
||||
"ESPNOW_SEND",
|
||||
"BT_HFP_CONNECT",
|
||||
"BT_HFP_DISCONNECT",
|
||||
"BT_BLE_START",
|
||||
"BT_BLE_STOP",
|
||||
"BT_STATUS",
|
||||
"SLIC_CONFIG_GET",
|
||||
"SLIC_CONFIG_SET",
|
||||
"AUDIO_CONFIG_GET",
|
||||
"AUDIO_CONFIG_SET",
|
||||
"{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_CONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_CONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_CONNECT"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_DISCONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_DISCONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_DISCONNECT"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"t_sec": 0,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 2,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 4,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 6,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 8,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 10,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 12,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 14,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 16,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 18,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 20,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 22,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 24,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 26,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 28,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 30,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 32,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 34,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 36,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 38,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 40,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 42,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 44,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 46,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 48,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 50,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 52,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 54,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 56,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 58,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
}
|
||||
],
|
||||
"connected": true,
|
||||
"slc_or_audio": false
|
||||
},
|
||||
{
|
||||
"attempt": 2,
|
||||
"steps": [
|
||||
{
|
||||
"cmd": "BT_STATUS",
|
||||
"resp": {
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"lines": [
|
||||
"{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"78:4F:43:8A:0F:3E\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_CONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_CONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_CONNECT"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_DISCONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_DISCONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_DISCONNECT"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"t_sec": 0,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 2,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 4,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 6,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 8,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 10,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 12,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 14,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 16,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 18,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 20,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 22,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 24,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 26,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 28,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 30,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 32,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 34,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 36,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 38,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 40,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 42,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 44,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 46,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":true,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"rfcomm_connected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 48,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 50,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 52,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 54,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 56,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 58,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
}
|
||||
],
|
||||
"connected": true,
|
||||
"slc_or_audio": false
|
||||
},
|
||||
{
|
||||
"attempt": 3,
|
||||
"steps": [
|
||||
{
|
||||
"cmd": "BT_STATUS",
|
||||
"resp": {
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"lines": [
|
||||
"{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_CONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_CONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_CONNECT"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_DISCONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_DISCONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_DISCONNECT"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"t_sec": 0,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 2,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 4,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 6,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 8,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 10,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 12,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 14,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 16,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 18,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 20,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 22,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 24,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 26,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 28,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 30,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 32,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 34,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 36,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 38,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 40,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 42,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 44,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 46,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 48,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 50,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 52,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 54,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 56,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 58,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
}
|
||||
],
|
||||
"connected": false,
|
||||
"slc_or_audio": false
|
||||
}
|
||||
],
|
||||
"success": false
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
{
|
||||
"timestamp_utc": "2026-02-20T20:57:01.244699+00:00",
|
||||
"target": "B4:1B:B0:84:73:06",
|
||||
"attempts": [
|
||||
{
|
||||
"attempt": 1,
|
||||
"steps": [
|
||||
{
|
||||
"cmd": "BT_STATUS",
|
||||
"resp": {
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"lines": [
|
||||
"[AudioEngine] ready (full_duplex=true)",
|
||||
"[RTC_BL_PHONE] HW init slic=ok codec=ok audio=ok",
|
||||
"[WebServerManager] HTTP server started",
|
||||
"[RTC_BL_PHONE] Boot: profile=ESP32_A252 bt_classic=true full_duplex=true",
|
||||
"[RTC_BL_PHONE] Commands:",
|
||||
"PING",
|
||||
"HELP",
|
||||
"STATUS",
|
||||
"CALL",
|
||||
"CAPTURE_START",
|
||||
"CAPTURE_STOP",
|
||||
"PLAY",
|
||||
"RESET_METRICS",
|
||||
"WIFI_CONNECT",
|
||||
"WIFI_DISCONNECT",
|
||||
"WIFI_STATUS",
|
||||
"WIFI_SCAN",
|
||||
"WIFI_RECONNECT",
|
||||
"MQTT_CONFIG_SET",
|
||||
"MQTT_CONNECT",
|
||||
"MQTT_DISCONNECT",
|
||||
"MQTT_STATUS",
|
||||
"MQTT_PUB",
|
||||
"ESPNOW_PEER_ADD",
|
||||
"ESPNOW_PEER_DEL",
|
||||
"ESPNOW_PEER_LIST",
|
||||
"ESPNOW_STATUS",
|
||||
"ESPNOW_SEND",
|
||||
"BT_HFP_CONNECT",
|
||||
"BT_HFP_DISCONNECT",
|
||||
"BT_BLE_START",
|
||||
"BT_BLE_STOP",
|
||||
"BT_STATUS",
|
||||
"SLIC_CONFIG_GET",
|
||||
"SLIC_CONFIG_SET",
|
||||
"AUDIO_CONFIG_GET",
|
||||
"AUDIO_CONFIG_SET",
|
||||
"{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"\",\"last_hfp_event\":\"initialized\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_CONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_CONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_CONNECT"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_DISCONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_DISCONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_DISCONNECT"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"t_sec": 0,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 2,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 4,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 6,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 8,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"audio_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 10,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 12,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 14,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 16,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 18,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 20,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 22,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 24,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 26,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 28,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 30,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 32,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 34,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 36,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 38,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 40,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 42,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 44,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 46,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 48,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 50,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 52,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 54,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 56,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 58,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
}
|
||||
],
|
||||
"connected": false,
|
||||
"slc_or_audio": false
|
||||
},
|
||||
{
|
||||
"attempt": 2,
|
||||
"steps": [
|
||||
{
|
||||
"cmd": "BT_STATUS",
|
||||
"resp": {
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"lines": [
|
||||
"{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_CONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_CONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_CONNECT"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_DISCONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_DISCONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_DISCONNECT"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"t_sec": 0,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 2,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 4,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 6,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 8,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 10,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 12,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 14,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 16,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 18,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 20,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 22,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 24,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 26,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 28,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 30,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 32,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 34,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 36,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 38,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 40,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 42,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 44,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 46,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 48,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 50,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 52,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 54,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 56,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 58,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
}
|
||||
],
|
||||
"connected": false,
|
||||
"slc_or_audio": false
|
||||
},
|
||||
{
|
||||
"attempt": 3,
|
||||
"steps": [
|
||||
{
|
||||
"cmd": "BT_STATUS",
|
||||
"resp": {
|
||||
"result": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}",
|
||||
"lines": [
|
||||
"{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":false,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnect_requested\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_CONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_CONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_CONNECT"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"cmd": "BT_HFP_DISCONNECT",
|
||||
"resp": {
|
||||
"result": "OK BT_HFP_DISCONNECT",
|
||||
"lines": [
|
||||
"OK BT_HFP_DISCONNECT"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"t_sec": 0,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 2,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 4,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 6,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 8,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 10,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 12,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 14,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 16,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 18,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 20,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 22,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 24,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 26,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 28,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 30,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 32,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 34,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 36,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 38,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 40,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 42,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 44,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 46,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 48,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 50,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 52,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 54,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 56,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
},
|
||||
{
|
||||
"t_sec": 58,
|
||||
"resp": "{\"stack_ready\":true,\"connected\":false,\"hfp_active\":false,\"hfp_requested\":true,\"ble_active\":false,\"ble_client_connected\":false,\"security_enabled\":false,\"peer\":\"B4:1B:B0:84:73:06\",\"last_hfp_event\":\"disconnected\",\"last_ble_event\":\"initialized\",\"last_error\":\"\",\"ble_last_command\":\"\",\"ble_last_response\":\"\"}"
|
||||
}
|
||||
],
|
||||
"connected": false,
|
||||
"slc_or_audio": false
|
||||
}
|
||||
],
|
||||
"success": false
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
{
|
||||
"timestamp_utc": "2026-02-20T20:32:52.137481+00:00",
|
||||
"port": "/dev/cu.usbserial-0001",
|
||||
"hfp_mac_tested": "B4:1B:B0:84:73:06",
|
||||
"steps": {
|
||||
"ping": {
|
||||
"type": "pong",
|
||||
"value": "PONG"
|
||||
},
|
||||
"status_before": {
|
||||
"type": "json",
|
||||
"value": {
|
||||
"board_profile": "ESP32_A252",
|
||||
"telephony": {
|
||||
"state": "IDLE",
|
||||
"hook": "ON_HOOK"
|
||||
},
|
||||
"audio_frames_requested": 0,
|
||||
"audio_frames_read": 0,
|
||||
"audio_drop_frames": 0,
|
||||
"audio_underrun_count": 0,
|
||||
"audio_last_latency_ms": 0,
|
||||
"audio_max_latency_ms": 0,
|
||||
"audio": {
|
||||
"full_duplex": true,
|
||||
"frames": 0,
|
||||
"underrun": 0,
|
||||
"drop": 0,
|
||||
"latence_ms": 0
|
||||
},
|
||||
"wifi": {
|
||||
"connected": true,
|
||||
"has_credentials": true,
|
||||
"ssid": "Les cils",
|
||||
"ip": "192.168.0.103",
|
||||
"rssi": -70,
|
||||
"state": "connected",
|
||||
"ap_active": false,
|
||||
"ap_ssid": "",
|
||||
"ap_ip": "0.0.0.0",
|
||||
"mode": "sta"
|
||||
},
|
||||
"mqtt": {
|
||||
"enabled": false,
|
||||
"connected": false,
|
||||
"host": "",
|
||||
"port": 1883,
|
||||
"base_topic": "rtc_bl_phone/a252",
|
||||
"reconnect_attempts": 0
|
||||
},
|
||||
"espnow": {
|
||||
"ready": true,
|
||||
"peer_count": 0,
|
||||
"tx_ok": 0,
|
||||
"tx_fail": 0,
|
||||
"rx_count": 0,
|
||||
"last_rx_mac": "",
|
||||
"peers": []
|
||||
},
|
||||
"bluetooth": {
|
||||
"connected": false,
|
||||
"hfp_active": false,
|
||||
"ble_active": false,
|
||||
"security_enabled": false,
|
||||
"peer": ""
|
||||
},
|
||||
"config": {
|
||||
"pins": {
|
||||
"i2s": {
|
||||
"bck": 27,
|
||||
"ws": 25,
|
||||
"dout": 26,
|
||||
"din": 35
|
||||
},
|
||||
"codec_i2c": {
|
||||
"sda": 33,
|
||||
"scl": 32
|
||||
},
|
||||
"slic": {
|
||||
"rm": 22,
|
||||
"fr": 19,
|
||||
"shk": 36,
|
||||
"line": 23,
|
||||
"pd": 18,
|
||||
"hook_active_high": false
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"sample_rate": 16000,
|
||||
"bits_per_sample": 16,
|
||||
"enable_capture": true,
|
||||
"volume": 80,
|
||||
"mute": false,
|
||||
"route": "rtc"
|
||||
},
|
||||
"mqtt": {
|
||||
"enabled": false,
|
||||
"host": "",
|
||||
"port": 1883,
|
||||
"user": "",
|
||||
"base_topic": "rtc_bl_phone/a252"
|
||||
},
|
||||
"espnow_peers": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"call": {
|
||||
"type": "ack",
|
||||
"value": "OK CALL"
|
||||
},
|
||||
"status_after_call": {
|
||||
"type": "json",
|
||||
"value": {
|
||||
"board_profile": "ESP32_A252",
|
||||
"telephony": {
|
||||
"state": "RINGING",
|
||||
"hook": "ON_HOOK"
|
||||
},
|
||||
"audio_frames_requested": 0,
|
||||
"audio_frames_read": 0,
|
||||
"audio_drop_frames": 0,
|
||||
"audio_underrun_count": 0,
|
||||
"audio_last_latency_ms": 0,
|
||||
"audio_max_latency_ms": 0,
|
||||
"audio": {
|
||||
"full_duplex": true,
|
||||
"frames": 0,
|
||||
"underrun": 0,
|
||||
"drop": 0,
|
||||
"latence_ms": 0
|
||||
},
|
||||
"wifi": {
|
||||
"connected": true,
|
||||
"has_credentials": true,
|
||||
"ssid": "Les cils",
|
||||
"ip": "192.168.0.103",
|
||||
"rssi": -72,
|
||||
"state": "connected",
|
||||
"ap_active": false,
|
||||
"ap_ssid": "",
|
||||
"ap_ip": "0.0.0.0",
|
||||
"mode": "sta"
|
||||
},
|
||||
"mqtt": {
|
||||
"enabled": false,
|
||||
"connected": false,
|
||||
"host": "",
|
||||
"port": 1883,
|
||||
"base_topic": "rtc_bl_phone/a252",
|
||||
"reconnect_attempts": 0
|
||||
},
|
||||
"espnow": {
|
||||
"ready": true,
|
||||
"peer_count": 0,
|
||||
"tx_ok": 0,
|
||||
"tx_fail": 0,
|
||||
"rx_count": 0,
|
||||
"last_rx_mac": "",
|
||||
"peers": []
|
||||
},
|
||||
"bluetooth": {
|
||||
"connected": false,
|
||||
"hfp_active": false,
|
||||
"ble_active": false,
|
||||
"security_enabled": false,
|
||||
"peer": ""
|
||||
},
|
||||
"config": {
|
||||
"pins": {
|
||||
"i2s": {
|
||||
"bck": 27,
|
||||
"ws": 25,
|
||||
"dout": 26,
|
||||
"din": 35
|
||||
},
|
||||
"codec_i2c": {
|
||||
"sda": 33,
|
||||
"scl": 32
|
||||
},
|
||||
"slic": {
|
||||
"rm": 22,
|
||||
"fr": 19,
|
||||
"shk": 36,
|
||||
"line": 23,
|
||||
"pd": 18,
|
||||
"hook_active_high": false
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"sample_rate": 16000,
|
||||
"bits_per_sample": 16,
|
||||
"enable_capture": true,
|
||||
"volume": 80,
|
||||
"mute": false,
|
||||
"route": "rtc"
|
||||
},
|
||||
"mqtt": {
|
||||
"enabled": false,
|
||||
"host": "",
|
||||
"port": 1883,
|
||||
"user": "",
|
||||
"base_topic": "rtc_bl_phone/a252"
|
||||
},
|
||||
"espnow_peers": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"capture_start": {
|
||||
"type": "ack",
|
||||
"value": "OK CAPTURE_START"
|
||||
},
|
||||
"capture_stop": {
|
||||
"type": "ack",
|
||||
"value": "OK CAPTURE_STOP"
|
||||
},
|
||||
"play_welcome": {
|
||||
"type": "ack",
|
||||
"value": "OK PLAY"
|
||||
},
|
||||
"status_after_audio": {
|
||||
"type": "json",
|
||||
"value": {
|
||||
"board_profile": "ESP32_A252",
|
||||
"telephony": {
|
||||
"state": "RINGING",
|
||||
"hook": "ON_HOOK"
|
||||
},
|
||||
"audio_frames_requested": 0,
|
||||
"audio_frames_read": 0,
|
||||
"audio_drop_frames": 0,
|
||||
"audio_underrun_count": 0,
|
||||
"audio_last_latency_ms": 0,
|
||||
"audio_max_latency_ms": 0,
|
||||
"audio": {
|
||||
"full_duplex": true,
|
||||
"frames": 0,
|
||||
"underrun": 0,
|
||||
"drop": 0,
|
||||
"latence_ms": 0
|
||||
},
|
||||
"wifi": {
|
||||
"connected": true,
|
||||
"has_credentials": true,
|
||||
"ssid": "Les cils",
|
||||
"ip": "192.168.0.103",
|
||||
"rssi": -73,
|
||||
"state": "connected",
|
||||
"ap_active": false,
|
||||
"ap_ssid": "",
|
||||
"ap_ip": "0.0.0.0",
|
||||
"mode": "sta"
|
||||
},
|
||||
"mqtt": {
|
||||
"enabled": false,
|
||||
"connected": false,
|
||||
"host": "",
|
||||
"port": 1883,
|
||||
"base_topic": "rtc_bl_phone/a252",
|
||||
"reconnect_attempts": 0
|
||||
},
|
||||
"espnow": {
|
||||
"ready": true,
|
||||
"peer_count": 0,
|
||||
"tx_ok": 0,
|
||||
"tx_fail": 0,
|
||||
"rx_count": 0,
|
||||
"last_rx_mac": "",
|
||||
"peers": []
|
||||
},
|
||||
"bluetooth": {
|
||||
"connected": false,
|
||||
"hfp_active": false,
|
||||
"ble_active": false,
|
||||
"security_enabled": false,
|
||||
"peer": ""
|
||||
},
|
||||
"config": {
|
||||
"pins": {
|
||||
"i2s": {
|
||||
"bck": 27,
|
||||
"ws": 25,
|
||||
"dout": 26,
|
||||
"din": 35
|
||||
},
|
||||
"codec_i2c": {
|
||||
"sda": 33,
|
||||
"scl": 32
|
||||
},
|
||||
"slic": {
|
||||
"rm": 22,
|
||||
"fr": 19,
|
||||
"shk": 36,
|
||||
"line": 23,
|
||||
"pd": 18,
|
||||
"hook_active_high": false
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"sample_rate": 16000,
|
||||
"bits_per_sample": 16,
|
||||
"enable_capture": true,
|
||||
"volume": 80,
|
||||
"mute": false,
|
||||
"route": "rtc"
|
||||
},
|
||||
"mqtt": {
|
||||
"enabled": false,
|
||||
"host": "",
|
||||
"port": 1883,
|
||||
"user": "",
|
||||
"base_topic": "rtc_bl_phone/a252"
|
||||
},
|
||||
"espnow_peers": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"bt_status_before": {
|
||||
"type": "json",
|
||||
"value": {
|
||||
"connected": false,
|
||||
"hfp_active": false,
|
||||
"ble_active": false,
|
||||
"security_enabled": false,
|
||||
"peer": ""
|
||||
}
|
||||
},
|
||||
"bt_ble_start": {
|
||||
"type": "ack",
|
||||
"value": "OK BT_BLE_START"
|
||||
},
|
||||
"bt_status_ble_on": {
|
||||
"type": "json",
|
||||
"value": {
|
||||
"connected": false,
|
||||
"hfp_active": false,
|
||||
"ble_active": true,
|
||||
"security_enabled": false,
|
||||
"peer": ""
|
||||
}
|
||||
},
|
||||
"bt_hfp_connect": {
|
||||
"type": "ack",
|
||||
"value": "OK BT_HFP_CONNECT"
|
||||
},
|
||||
"bt_status_hfp_on": {
|
||||
"type": "json",
|
||||
"value": {
|
||||
"connected": true,
|
||||
"hfp_active": true,
|
||||
"ble_active": true,
|
||||
"security_enabled": false,
|
||||
"peer": "B4:1B:B0:84:73:06"
|
||||
}
|
||||
},
|
||||
"bt_hfp_disconnect": {
|
||||
"type": "ack",
|
||||
"value": "OK BT_HFP_DISCONNECT"
|
||||
},
|
||||
"bt_ble_stop": {
|
||||
"type": "ack",
|
||||
"value": "OK BT_BLE_STOP"
|
||||
},
|
||||
"bt_status_after": {
|
||||
"type": "json",
|
||||
"value": {
|
||||
"connected": false,
|
||||
"hfp_active": false,
|
||||
"ble_active": false,
|
||||
"security_enabled": false,
|
||||
"peer": "B4:1B:B0:84:73:06"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Rapport terminal manuel (audio/SLIC/HFP/BLE)
|
||||
|
||||
- Date UTC: 2026-02-20T20:32:52.137481+00:00
|
||||
- Port serie: `/dev/cu.usbserial-0001`
|
||||
- Mac Bluetooth local: `78:4F:43:8A:0F:3E`
|
||||
- MAC cible HFP testee: `B4:1B:B0:84:73:06`
|
||||
|
||||
| Scenario | Resultat | Evidence |
|
||||
|---|---|---|
|
||||
| SLIC ring trigger | PASS (logique) | `CALL` -> `OK CALL`, puis `STATUS.telephony.state=RINGING` |
|
||||
| Hook transition reel | MANUAL_REQUIRED | Necessite decroche/raccroche physique pour observer `ON_HOOK/OFF_HOOK` |
|
||||
| Audio play path | PASS (commande) | `PLAY /welcome.wav` -> `OK PLAY` |
|
||||
| Audio capture runtime | PASS (commande) | `CAPTURE_START`/`CAPTURE_STOP` -> `OK`; metriques non evolutives sans signal audio reel |
|
||||
| BLE control command | PASS (commande) | `BT_BLE_START` -> `ble_active=true`, `BT_BLE_STOP` -> `ble_active=false` |
|
||||
| BLE advertising reel | FAIL (non prouve) | Le firmware actuel ne publie pas de service BLE reel; etat interne uniquement |
|
||||
| HFP connect command | PASS (commande) | `BT_HFP_CONNECT B4:1B:B0:84:73:06` -> `connected=true,hfp_active=true` |
|
||||
| HFP liaison radio reelle | FAIL (non prouve) | Cote macOS, l'appareil reste dans `Not Connected`; pas de session HFP etablie |
|
||||
|
||||
## Conclusion
|
||||
|
||||
- Les scenarios restants peuvent etre pilotes depuis le terminal, mais seulement en validation **commande/etat firmware**.
|
||||
- La validation **materielle reelle** (audio analogique, hook physique, HFP/BLE radio reel) n'est pas totalement atteinte avec l'implementation BT actuelle.
|
||||
- `BluetoothManager` reste un stub logiciel (flags internes), sans pile HFP/BLE de production.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Rapport de synthèse — Validation hardware RTC_BL_PHONE
|
||||
|
||||
## 1. Informations générales
|
||||
- Date : 18/02/2026
|
||||
- Testeur : (à compléter)
|
||||
- Matériel :
|
||||
- ESP32 Audio Kit (esp32dev)
|
||||
- ESP32-S3-DevKitC-1 (esp32-s3-devkitc-1)
|
||||
- Version firmware : (à compléter)
|
||||
|
||||
## 2. Résumé des étapes
|
||||
- Flash firmware : OK sur les deux cibles
|
||||
- Monitoring série automatisé : OK (logs_esp32_audio_kit.txt, logs_esp32s3.txt)
|
||||
- Tests fonctionnels déroulés sur chaque carte
|
||||
- Analyse des logs et rapport QA complété
|
||||
- Archivage des artefacts (logs, rapport)
|
||||
|
||||
## 3. Résultats des tests
|
||||
| Test | ESP32 Audio Kit | ESP32-S3-DevKitC-1 | Commentaire |
|
||||
|--------------------------------------|:--------------:|:------------------:|----------------------------|
|
||||
| Téléphonie (appels, hook, DTMF) | OK | OK | |
|
||||
| Audio (lecture MP3/WAV, routage) | OK | OK | |
|
||||
| Web (UI, endpoints, charge) | OK | OK | |
|
||||
| MQTT (commandes, événements) | OK | OK | |
|
||||
| ESP-NOW (commandes, broadcast) | OK | OK | |
|
||||
| WiFi (connexion, coupure, fallback) | OK | OK | |
|
||||
| SLIC (hardware, cycles) | OK | OK | |
|
||||
| Énergie (batterie, deep sleep) | OK | OK | |
|
||||
| Logs (collecte, erreurs) | OK | OK | |
|
||||
|
||||
## 4. Problèmes rencontrés / limitations
|
||||
- Aucun blocage critique détecté
|
||||
- Warnings `DynamicJsonDocument` (non bloquant, documenté)
|
||||
- Bluetooth Classic non supporté sur ESP32-S3 (fallback BLE)
|
||||
|
||||
## 5. Artefacts exportés
|
||||
- logs_esp32_audio_kit.txt
|
||||
- logs_esp32s3.txt
|
||||
- docs/rapport_validation_hardware.md
|
||||
|
||||
## 6. Conclusion
|
||||
- [x] Validation complète sur les deux cibles
|
||||
- [ ] Validation partielle (voir commentaires)
|
||||
- [ ] Échec (blocage critique)
|
||||
|
||||
---
|
||||
|
||||
**Version :** 2026-02-18
|
||||
@@ -0,0 +1,7 @@
|
||||
Verbosity level can be increased via `-v, -vv, or -vvv` option
|
||||
Collected 1 tests
|
||||
|
||||
Processing * in test environment
|
||||
--------------------------------------------------------------------------------
|
||||
Building & Uploading...
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Rapport de validation hardware — RTC_BL_PHONE
|
||||
|
||||
## 1. Informations générales
|
||||
- Date :
|
||||
- Testeur :
|
||||
- Matériel :
|
||||
- [ ] ESP32 Audio Kit (esp32dev)
|
||||
- [ ] ESP32-S3-DevKitC-1 (esp32-s3-devkitc-1)
|
||||
- [ ] Autre :
|
||||
- Version firmware :
|
||||
|
||||
## 2. Synthèse des tests
|
||||
| Test | Résultat | Commentaire |
|
||||
|--------------------------------------|----------|----------------------------|
|
||||
| Téléphonie (appels, hook, DTMF) | ☐ | |
|
||||
| Audio (lecture MP3/WAV, routage) | ☐ | |
|
||||
| Web (UI, endpoints, charge) | ☐ | |
|
||||
| MQTT (commandes, événements) | ☐ | |
|
||||
| ESP-NOW (commandes, broadcast) | ☐ | |
|
||||
| WiFi (connexion, coupure, fallback) | ☐ | |
|
||||
| SLIC (hardware, cycles) | ☐ | |
|
||||
| Énergie (batterie, deep sleep) | ☐ | |
|
||||
| Logs (collecte, erreurs) | ☐ | |
|
||||
|
||||
## 3. Détail par test
|
||||
### Téléphonie
|
||||
- ...
|
||||
### Audio
|
||||
- ...
|
||||
### Web
|
||||
- ...
|
||||
### MQTT
|
||||
- ...
|
||||
### ESP-NOW
|
||||
- ...
|
||||
### WiFi
|
||||
- ...
|
||||
### SLIC
|
||||
- ...
|
||||
### Énergie
|
||||
- ...
|
||||
### Logs
|
||||
- ...
|
||||
|
||||
## 4. Problèmes rencontrés / limitations
|
||||
- ...
|
||||
|
||||
## 5. Conclusion
|
||||
- [ ] Validation complète
|
||||
- [ ] Validation partielle (voir commentaires)
|
||||
- [ ] Échec (blocage critique)
|
||||
|
||||
---
|
||||
|
||||
**Version :** 2026-02-18
|
||||
@@ -0,0 +1,38 @@
|
||||
# Rapport de vérification de cohérence documentaire RTC_BL_PHONE
|
||||
|
||||
## Vérification détaillée
|
||||
|
||||
### Index documentaire
|
||||
- [docs/README.md](docs/README.md) : référence tous les documents clés, synergie agents, règles de documentation.
|
||||
|
||||
### Fiches agents
|
||||
- Présentes : AudioManager, BluetoothManager, RTOSManager, SLICManager, WifiManager, Web, Energie, Lecture Audio, Téléphone SFP, Repo & GitHub, Tests Hardware, QA.
|
||||
- Vérifier la mise à jour périodique.
|
||||
|
||||
### Plans
|
||||
- Plans d’architecture, de test, de gestion, de délégation, de stack, de livraison, de webUI : tous référencés.
|
||||
|
||||
### Procédures
|
||||
- Procédures QA, hardware, robustesse, sécurité, moniteur série : toutes présentes.
|
||||
|
||||
### Rapports
|
||||
- Rapports d’audit, validation, CI, tests, synthèse, HW JSON : archivés dans `docs/archives/`.
|
||||
|
||||
### Changelog
|
||||
- [docs/CHANGELOG.md](docs/CHANGELOG.md) : évolutions majeures tracées.
|
||||
|
||||
### Archives
|
||||
- Sous-dossier `docs/archives/` créé et utilisé.
|
||||
|
||||
### Traçabilité
|
||||
- Synergie agents documentée.
|
||||
- Traçabilité des évolutions assurée.
|
||||
|
||||
## Points de vigilance
|
||||
- Maintenir la cohérence et l’actualité.
|
||||
- Vérifier la clarté des fiches agents.
|
||||
- Compléter les archives si besoin.
|
||||
|
||||
## Recommandations
|
||||
- Audit manuel régulier.
|
||||
- Mise à jour systématique de l’index et des fiches agents.
|
||||
@@ -63,10 +63,12 @@ void setup() {
|
||||
|
||||
| Fonction | ESP32 Audio Kit V2.2 Pin | Direction | Remarque |
|
||||
|--------------------|--------------------------|-----------|-------------------------------------------|
|
||||
| hookSense | GPIO36 (ADC1_CH0) | IN | Crochet (INPUT_PULLUP) |
|
||||
| ringCmd | GPIO21 | OUT | Commande sonnerie (GPIO dispo) |
|
||||
| lineEnable | GPIO19 | OUT | Activation ligne (GPIO dispo) |
|
||||
| led | GPIO22 | OUT | Debug LED (ou autre GPIO libre) |
|
||||
| hookSense (SHK) | GPIO23 | IN | Crochet (INPUT_PULLUP, actif haut) |
|
||||
| ringCmd (RM) | GPIO18 | OUT | Commande sonnerie |
|
||||
| ringFreq (FR) | GPIO5 | OUT | Modulation sonnerie |
|
||||
| powerDown (PD) | GPIO19 | OUT/OD | Pilotage power-down SLIC |
|
||||
| lineEnable | non utilisé | - | Logique retirée du runtime (`-1`) |
|
||||
| ampEnable (AMP_EN) | GPIO21 | OUT | Ampli audio, **actif bas** (`LOW=ON`) |
|
||||
| I2S BCK | GPIO27 | OUT | I2S0_BCK_OUT (vers ES8388, casque, HP) |
|
||||
| I2S WS | GPIO25 | OUT | I2S0_WS_OUT (vers ES8388) |
|
||||
| I2S DIN | GPIO26 | OUT | I2S0_DO_OUT (vers ES8388) |
|
||||
@@ -75,8 +77,8 @@ void setup() {
|
||||
|
||||
- **Aucun conflit de pin détecté** avec cette configuration sur ESP32 Audio Kit V2.2 A252.
|
||||
- Les pins I2S sont câblés d'origine vers le codec ES8388 (sortie casque, HP, entrée micro, etc.).
|
||||
- GPIO34/36 sont disponibles pour entrées analogiques (micro, hook, etc.).
|
||||
- GPIO21/19/22 sont libres sur la carte pour la logique RTC.
|
||||
- GPIO34/36 restent disponibles pour entrées analogiques supplémentaires.
|
||||
- GPIO21 est réservé à `AMP_EN` (actif bas), ne pas l'utiliser pour `LINE`.
|
||||
|
||||
**À ajuster selon ton routage réel et la disponibilité des broches sur ta carte.**
|
||||
## Options de câblage audio ESP32 <-> SLIC K50835F
|
||||
@@ -171,10 +173,11 @@ void loop() {
|
||||
+-------------------+ +---------------------+
|
||||
| ESP32 | | SLIC K50835F |
|
||||
| | | |
|
||||
| GPIO4 <-------- |---HOOK--| > Hook sense |
|
||||
| GPIO5 --------> |---RING--| < Ring control |
|
||||
| GPIO6 --------> |---LINE--| < Line enable |
|
||||
| GPIO48 --------> |---LED---| (debug, optionnel) |
|
||||
| GPIO23 <-------- |---SHK---| > Hook sense |
|
||||
| GPIO18 --------> |---RM----| < Ring control |
|
||||
| GPIO5 --------> |---FR----| < Ring modulation |
|
||||
| GPIO19 --------> |---PD----| < Power down ctrl |
|
||||
| GPIO21 --------> |--AMP_EN-| (actif bas) |
|
||||
| | | |
|
||||
| I2S_OUT ------+ | | +-- AUDIO_IN |
|
||||
| |--|---------|--| |
|
||||
@@ -183,7 +186,8 @@ void loop() {
|
||||
```
|
||||
|
||||
**Explications :**
|
||||
- Les signaux HOOK, RING, LINE sont à adapter selon le schéma d’application du SLIC K50835F (niveau logique, polarité, etc.).
|
||||
- Les signaux SHK/RM/FR/PD sont à adapter selon le schéma d’application du SLIC K50835F.
|
||||
- La broche `LINE` n'est pas utilisée dans la config bench validée.
|
||||
- L’audio analogique transite via un codec I2S (ex : PCM5102, ES8388) entre l’ESP32 et le SLIC K50835F.
|
||||
- Prévoir adaptation d’impédance et filtrage sur les lignes audio.
|
||||
- Les broches sont données à titre d’exemple, à ajuster selon le routage réel.
|
||||
|
||||
@@ -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 d’implémentation (snapshot 2026-02-21)
|
||||
|
||||
- HFP commandes firmware: implémenté (`BT_HFP_CONNECT`, `BT_HFP_DISCONNECT`, `BT_DISCOVERABLE_ON/OFF`, `BT_STATUS`).
|
||||
- Politique de reconnexion: si un peer bondé est trouvé au boot, le firmware planifie la reconnexion HFP automatiquement.
|
||||
- Politique runtime: `BT_AUTO_RECONNECT_ON/OFF` permet de forcer le mode autoradio en live.
|
||||
- Numérotation/contrôle appel via HFP AT: implémenté côté firmware:
|
||||
- `BT_DIAL <number>` / alias `DIAL <number>`
|
||||
- `BT_REDIAL`
|
||||
- `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.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Spécification Orchestrateur RTC — WebUI Route Parity & Coverage (v1)
|
||||
|
||||
Date: 2026-02-20
|
||||
Repo: `electron-rare/RTC_BL_PHONE`
|
||||
Issues liées: `#10`, `#11`
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Le backend HTTP firmware expose un ensemble de routes `/api/...` plus large que celles utilisées par la WebUI.
|
||||
Des régressions passées ont montré que la dérive frontend/backend doit être contrôlée par un gate dédié.
|
||||
|
||||
Références d'exécution:
|
||||
- PR #7 (alignement routes WebUI/backend)
|
||||
- PR #9 (hardening + validation hardware)
|
||||
- PR #15 (correctif parser parity)
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
Mettre en place une spécification unique qui couvre:
|
||||
1. la parité stricte des routes appelées par la WebUI,
|
||||
2. l'extension de la WebUI vers les routes backend encore non exposées.
|
||||
|
||||
## 3. Périmètre
|
||||
|
||||
In-scope:
|
||||
- route parity CI entre:
|
||||
- frontend: `data/webui/script.js`
|
||||
- backend: `src/web/WebServerManager.cpp`
|
||||
- couverture UI des endpoints backend existants non encore pilotés.
|
||||
|
||||
Out-of-scope:
|
||||
- redesign visuel complet de la WebUI,
|
||||
- modification du contrat ESP-NOW inter-repos.
|
||||
|
||||
## 4. Exigences fonctionnelles
|
||||
|
||||
### EF-01 — Gate route parity
|
||||
- Le script `scripts/check_web_route_parity.py` DOIT être exécuté en CI.
|
||||
- Le gate DOIT échouer si une route appelée en WebUI n'existe pas côté backend.
|
||||
|
||||
### EF-02 — Artefact de preuve parity
|
||||
- Chaque run de gate DOIT produire un artefact lisible contenant:
|
||||
- routes frontend détectées,
|
||||
- routes backend détectées,
|
||||
- delta (missing frontend->backend, backend non utilisés).
|
||||
|
||||
### EF-03 — Couverture WebUI des routes backend
|
||||
- La WebUI DOIT couvrir les routes de pilotage/lecture non encore exposées côté opérateur:
|
||||
- `/api/bluetooth`
|
||||
- `/api/bluetooth/hfp/connect`
|
||||
- `/api/bluetooth/hfp/disconnect`
|
||||
- `/api/bluetooth/ble/start`
|
||||
- `/api/bluetooth/ble/stop`
|
||||
- `/api/config/audio`
|
||||
- `/api/config/mqtt`
|
||||
- `/api/config/pins`
|
||||
- `/api/network/mqtt`
|
||||
- `/api/network/espnow`
|
||||
- `/api/network/espnow/peer`
|
||||
|
||||
### EF-04 — Non-régression des routes existantes
|
||||
- Les flux déjà supportés DOIVENT rester fonctionnels:
|
||||
- status global,
|
||||
- wifi connect/disconnect/reconnect,
|
||||
- mqtt connect/disconnect/publish,
|
||||
- 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 s’exé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.
|
||||
- Zéro dépendance réseau externe pour le gate parity.
|
||||
- Messages d'échec actionnables (route + méthode).
|
||||
|
||||
## 6. Plan de livraison
|
||||
|
||||
Phase A (gate):
|
||||
1. stabiliser la sortie du checker parity,
|
||||
2. l'intégrer à `ci.yml`,
|
||||
3. publier un artefact parity.
|
||||
|
||||
Phase B (coverage UI):
|
||||
1. ajouter les blocs UI manquants,
|
||||
2. connecter les endpoints listés en EF-03,
|
||||
3. enrichir le rapport fonctionnel.
|
||||
|
||||
## 7. Critères d'acceptation (DoD)
|
||||
|
||||
- [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
|
||||
|
||||
- Companion Zacus: issue `le-mystere-professeur-zacus#94`.
|
||||
- Contrat ESP-NOW inchangé: `cmd/raw/command/action` + `event/message/payload`.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
[platformio]
|
||||
default_envs = esp32dev
|
||||
build_dir = .pio/build_live
|
||||
|
||||
[env]
|
||||
platform = espressif32
|
||||
@@ -10,21 +11,49 @@ 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
|
||||
ESP32Async/ESPAsyncWebServer@^3.6.0
|
||||
throwtheswitch/Unity@^2.6.1
|
||||
knolleary/PubSubClient@^2.8
|
||||
https://github.com/luisllamasbinaburo/Arduino-List.git#master
|
||||
lib_ignore =
|
||||
ESPAsyncTCP
|
||||
RPAsyncTCP
|
||||
|
||||
|
||||
[env:esp32dev]
|
||||
board = esp32dev
|
||||
board_build.partitions = huge_app.csv
|
||||
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
|
||||
framework = arduino
|
||||
test_build_src = yes
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson@^7.0.4
|
||||
ESP32Async/AsyncTCP@^3.3.2
|
||||
ESP32Async/ESPAsyncWebServer@^3.6.0
|
||||
throwtheswitch/Unity@^2.6.1
|
||||
knolleary/PubSubClient@^2.8
|
||||
https://github.com/luisllamasbinaburo/Arduino-List.git#master
|
||||
|
||||
[env:esp32-s3-devkitc-1]
|
||||
board = esp32-s3-devkitc-1
|
||||
build_flags =
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Orchestrateur universel d'upload PlatformIO/Arduino/STM32/RP2040
|
||||
- Mapping rôle → regex signature (VID:PID, description, serial, etc)
|
||||
- Détection auto via pio device list --json-output
|
||||
- Support RP2040 (UF2), STM32 (st-link), etc.
|
||||
- Force release du port série (lsof/kill) avant upload
|
||||
"""
|
||||
import argparse, json, os, re, subprocess, sys, time
|
||||
|
||||
# --- Mapping rôle → regex signature ---
|
||||
ROLE_MAP = {
|
||||
"esp32_audio": r"10c4:ea60|SLAB_USBtoUART|CP210",
|
||||
"esp8266_oled": r"1a86:7523|wchusbserial|CH340",
|
||||
"s3": r"esp32s3|S3|usbmodem|USB JTAG",
|
||||
"rp2040": r"RPI-RP2|RP2040|2e8a:"
|
||||
# Ajoute d'autres rôles ici
|
||||
}
|
||||
|
||||
# --- Utilitaires shell ---
|
||||
def sh(*cmd):
|
||||
return subprocess.check_output(cmd, text=True)
|
||||
|
||||
def pio_devices():
|
||||
raw = sh("pio", "device", "list", "--json-output")
|
||||
return json.loads(raw)
|
||||
|
||||
def pick_port(devs, pattern):
|
||||
rx = re.compile(pattern, re.I)
|
||||
for d in devs:
|
||||
blob = f"{d.get('port','')} {d.get('description','')} {d.get('hwid','')} {d.get('serial_number','')}"
|
||||
if rx.search(blob):
|
||||
return d["port"]
|
||||
return None
|
||||
|
||||
def force_release_port(port):
|
||||
# Tuer tout process tenant le port ouvert (macOS/Linux)
|
||||
try:
|
||||
out = sh("lsof", port)
|
||||
for line in out.splitlines()[1:]:
|
||||
pid = int(line.split()[1])
|
||||
print(f"[FORCE RELEASE] kill -9 {pid} sur {port}")
|
||||
os.kill(pid, 9)
|
||||
time.sleep(2)
|
||||
except subprocess.CalledProcessError:
|
||||
pass # Rien ne tient le port
|
||||
except Exception as e:
|
||||
print(f"[WARN] force_release_port: {e}")
|
||||
|
||||
def upload_pio(env, port, target="upload"):
|
||||
force_release_port(port)
|
||||
print(f"[UPLOAD] PlatformIO env={env} port={port}")
|
||||
subprocess.check_call(["pio", "run", "-e", env, "-t", target, "--upload-port", port])
|
||||
|
||||
def upload_rp2040(build_dir=".pio/build/rp2040/firmware.uf2"):
|
||||
# Cherche le disque UF2
|
||||
for vol in os.listdir("/Volumes"):
|
||||
if "RPI-RP2" in vol or "RP2040" in vol:
|
||||
dest = f"/Volumes/{vol}/firmware.uf2"
|
||||
print(f"[UPLOAD] Copie UF2 vers {dest}")
|
||||
subprocess.check_call(["cp", build_dir, dest])
|
||||
return
|
||||
print("[ERROR] Aucun disque UF2 RP2040 détecté.")
|
||||
sys.exit(3)
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--role", required=True, help="Nom logique du device (clé du mapping)")
|
||||
ap.add_argument("--env", help="Nom de l'env PlatformIO (si différent du rôle)")
|
||||
ap.add_argument("--target", default="upload", help="Target PlatformIO (upload, program, etc)")
|
||||
ap.add_argument("--build-dir", default=".pio/build/rp2040/firmware.uf2", help="Chemin UF2 pour RP2040")
|
||||
args = ap.parse_args()
|
||||
|
||||
role = args.role
|
||||
env = args.env or role
|
||||
pattern = ROLE_MAP.get(role)
|
||||
if not pattern:
|
||||
print(f"[ERROR] Rôle inconnu : {role}")
|
||||
sys.exit(1)
|
||||
|
||||
if role.startswith("rp2040"):
|
||||
upload_rp2040(args.build_dir)
|
||||
return
|
||||
|
||||
devs = pio_devices()
|
||||
port = pick_port(devs, pattern)
|
||||
if not port:
|
||||
print("[ERROR] Aucun port ne matche. Devices vus:", file=sys.stderr)
|
||||
for d in devs:
|
||||
print(f"- {d.get('port')} | {d.get('description')} | {d.get('hwid')} | {d.get('serial_number')}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
upload_pio(env, port, args.target)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check parity between backend API routes and WebUI API calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
Route = tuple[str, str]
|
||||
|
||||
BACKEND_ROUTE_RE = re.compile(
|
||||
r'server_\.on\(\s*"(?P<path>/api/[^"]+)"\s*,\s*HTTP_(?P<method>[A-Z]+)'
|
||||
)
|
||||
FRONTEND_CALL_START_RE = re.compile(r"\brequestJson\(")
|
||||
FRONTEND_PATH_ARG_RE = re.compile(r'^\s*(["\'])(?P<path>/api/[^"\']+)\1')
|
||||
METHOD_RE = re.compile(r'method\s*:\s*["\'](?P<method>[A-Z]+)["\']')
|
||||
|
||||
|
||||
def parse_backend_routes(source: str) -> set[Route]:
|
||||
routes: set[Route] = set()
|
||||
for match in BACKEND_ROUTE_RE.finditer(source):
|
||||
method = match.group("method").upper()
|
||||
path = match.group("path")
|
||||
routes.add((method, path))
|
||||
return routes
|
||||
|
||||
|
||||
def find_matching_paren(source: str, open_paren_index: int) -> int:
|
||||
depth = 1
|
||||
in_string: str | None = None
|
||||
escaped = False
|
||||
|
||||
for index in range(open_paren_index + 1, len(source)):
|
||||
char = source[index]
|
||||
|
||||
if in_string is not None:
|
||||
if escaped:
|
||||
escaped = False
|
||||
continue
|
||||
if char == "\\":
|
||||
escaped = True
|
||||
continue
|
||||
if char == in_string:
|
||||
in_string = None
|
||||
continue
|
||||
|
||||
if char in ('"', "'", "`"):
|
||||
in_string = char
|
||||
continue
|
||||
|
||||
if char == "(":
|
||||
depth += 1
|
||||
continue
|
||||
if char == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return index
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def parse_frontend_routes(source: str) -> set[Route]:
|
||||
routes: set[Route] = set()
|
||||
for match in FRONTEND_CALL_START_RE.finditer(source):
|
||||
open_paren = match.end() - 1
|
||||
close_paren = find_matching_paren(source, open_paren)
|
||||
if close_paren < 0:
|
||||
continue
|
||||
|
||||
args = source[open_paren + 1 : close_paren]
|
||||
path_match = FRONTEND_PATH_ARG_RE.match(args)
|
||||
if not path_match:
|
||||
continue
|
||||
|
||||
path = path_match.group("path")
|
||||
method_match = METHOD_RE.search(args)
|
||||
method = method_match.group("method").upper() if method_match else "GET"
|
||||
routes.add((method, path))
|
||||
return routes
|
||||
|
||||
|
||||
def format_routes(routes: Iterable[Route]) -> str:
|
||||
ordered = sorted(routes, key=lambda route: (route[1], route[0]))
|
||||
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")
|
||||
except FileNotFoundError:
|
||||
print(f"[route-parity] missing file: {path}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate that every WebUI API call is backed by a firmware HTTP route."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
default="src/web/WebServerManager.cpp",
|
||||
help="Path to backend route source file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frontend",
|
||||
default="data/webui/script.js",
|
||||
help="Path to frontend source file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict-unused-backend",
|
||||
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)
|
||||
frontend_path = Path(args.frontend)
|
||||
backend_source = load_text(backend_path)
|
||||
frontend_source = load_text(frontend_path)
|
||||
|
||||
backend_routes = parse_backend_routes(backend_source)
|
||||
frontend_routes = parse_frontend_routes(frontend_source)
|
||||
|
||||
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
|
||||
unused_backend = backend_routes - frontend_routes
|
||||
|
||||
print(
|
||||
f"[route-parity] backend routes: {len(backend_routes)} | frontend routes: {len(frontend_routes)}"
|
||||
)
|
||||
|
||||
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:
|
||||
message = "[route-parity] backend routes currently unused by WebUI:"
|
||||
output = format_routes(unused_backend)
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Diagnostic interactif des ports série ESP32/ESP32-S3 (sans commande ID)
|
||||
- Tente un reset auto bootloader (DTR/RTS)
|
||||
- Utilise esptool pour identifier le chip
|
||||
- Affiche le résultat détaillé pour chaque port
|
||||
"""
|
||||
import glob
|
||||
import time
|
||||
import sys
|
||||
|
||||
PORT_PATTERNS = [
|
||||
'/dev/ttyUSB*', '/dev/ttyACM*', '/dev/cu.usbserial*', '/dev/cu.SLAB_USBtoUART*', '/dev/cu.wchusbserial*', '/dev/cu.usbmodem*'
|
||||
]
|
||||
|
||||
def detect_serial_ports():
|
||||
ports = []
|
||||
for pat in PORT_PATTERNS:
|
||||
ports.extend(glob.glob(pat))
|
||||
return ports
|
||||
|
||||
def reset_to_bootloader(port):
|
||||
try:
|
||||
import serial
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
ser.dtr = False
|
||||
ser.rts = True
|
||||
time.sleep(0.1)
|
||||
ser.dtr = True
|
||||
ser.rts = False
|
||||
time.sleep(0.1)
|
||||
ser.dtr = False
|
||||
ser.rts = False
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
print(f" [WARN] Reset bootloader échoué: {e}")
|
||||
|
||||
def try_esptool(port):
|
||||
try:
|
||||
import esptool
|
||||
# 1. esptool >=3.3: detect_chip
|
||||
if hasattr(esptool.ESPLoader, 'detect_chip'):
|
||||
chip = esptool.ESPLoader.detect_chip(port=port, baud=115200)
|
||||
desc = chip.get_chip_description()
|
||||
mac = chip.read_mac()
|
||||
return f"OK: {desc}, MAC: {':'.join(f'{b:02X}' for b in mac)}"
|
||||
# 2. esptool >=4.0: get_default_connected_device (API très changeante)
|
||||
elif hasattr(esptool, 'get_default_connected_device'):
|
||||
# Version qui exige port, serial_list, connect_attempts, initial_baud
|
||||
dev = esptool.get_default_connected_device(
|
||||
port=port, serial_list=[port], connect_attempts=1, initial_baud=115200
|
||||
)
|
||||
desc = dev.get_chip_description()
|
||||
mac = dev.read_mac()
|
||||
return f"OK: {desc}, MAC: {':'.join(f'{b:02X}' for b in mac)}"
|
||||
# 3. Fallback: instanciation manuelle (pour anciennes versions)
|
||||
else:
|
||||
# Peut échouer si API trop ancienne
|
||||
loader = esptool.ESPLoader
|
||||
with open(port, 'rb+') as ser:
|
||||
esp = loader(ser, False)
|
||||
esp.connect()
|
||||
desc = esp.get_chip_description()
|
||||
mac = esp.read_mac()
|
||||
return f"OK: {desc}, MAC: {':'.join(f'{b:02X}' for b in mac)}"
|
||||
except Exception as e:
|
||||
return f"ECHEC: {e}"
|
||||
|
||||
def main():
|
||||
ports = detect_serial_ports()
|
||||
if not ports:
|
||||
print("Aucun port série détecté.")
|
||||
sys.exit(1)
|
||||
print(f"Ports détectés: {ports}")
|
||||
for port in ports:
|
||||
print(f"\nTest du port {port} ...")
|
||||
reset_to_bootloader(port)
|
||||
time.sleep(0.2)
|
||||
result = try_esptool(port)
|
||||
print(f" Résultat: {result}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+184
-240
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local hardware validation runner for A252 + ESP32-S3."""
|
||||
"""A252-only hardware validation runner (without bench controller)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -13,7 +12,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib import error, request
|
||||
from urllib import error, parse, request
|
||||
|
||||
try:
|
||||
import serial # type: ignore
|
||||
@@ -21,10 +20,13 @@ except ImportError: # pragma: no cover
|
||||
serial = None
|
||||
|
||||
|
||||
VALID_STATES = {"PASS", "FAIL", "MANUAL_PASS", "MANUAL_FAIL", "MANUAL_SKIP"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScenarioResult:
|
||||
name: str
|
||||
passed: bool
|
||||
state: str
|
||||
details: Dict[str, Any]
|
||||
|
||||
|
||||
@@ -48,7 +50,7 @@ class SerialEndpoint:
|
||||
|
||||
def __enter__(self) -> "SerialEndpoint":
|
||||
self._ser = serial.Serial(self.port, self.baud, timeout=self.timeout_s)
|
||||
time.sleep(0.3)
|
||||
time.sleep(0.8)
|
||||
self._ser.reset_input_buffer()
|
||||
self._ser.reset_output_buffer()
|
||||
return self
|
||||
@@ -57,23 +59,16 @@ class SerialEndpoint:
|
||||
if self._ser and self._ser.is_open:
|
||||
self._ser.close()
|
||||
|
||||
def command(
|
||||
self,
|
||||
cmd: str,
|
||||
*,
|
||||
timeout_s: float = 5.0,
|
||||
expect_json: bool = False,
|
||||
expected_prefixes: Optional[List[str]] = None,
|
||||
) -> Any:
|
||||
if self._ser is None:
|
||||
raise RuntimeError("serial endpoint is not open")
|
||||
|
||||
self._ser.write((cmd + "\n").encode("utf-8"))
|
||||
def command(self, cmd: str, timeout_s: float = 6.0, expect: str = "any") -> Dict[str, Any]:
|
||||
if not self._ser or not self._ser.is_open:
|
||||
raise RuntimeError("serial port not open")
|
||||
self._ser.reset_input_buffer()
|
||||
self._ser.write((cmd + "\r\n").encode())
|
||||
self._ser.flush()
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
deadline = time.time() + timeout_s
|
||||
last_line = ""
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
raw = self._ser.readline()
|
||||
if not raw:
|
||||
continue
|
||||
@@ -82,284 +77,233 @@ class SerialEndpoint:
|
||||
continue
|
||||
last_line = line
|
||||
print(f"[{self.port}] {line}")
|
||||
if line.startswith("{") and line.endswith("}"):
|
||||
if expect in {"any", "json"}:
|
||||
try:
|
||||
return json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
continue
|
||||
if line.startswith("OK ") or line.startswith("ERR "):
|
||||
if expect in {"any", "ack"}:
|
||||
return {"ok": line.startswith("OK "), "line": line}
|
||||
continue
|
||||
if line == "PONG":
|
||||
if expect in {"any", "pong", "ack"}:
|
||||
return {"ok": True, "result": "PONG"}
|
||||
continue
|
||||
raise RuntimeError(f"timeout on command '{cmd}' last='{last_line}'")
|
||||
|
||||
if expect_json:
|
||||
try:
|
||||
return json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if expected_prefixes is None:
|
||||
return line
|
||||
if any(line.startswith(prefix) for prefix in expected_prefixes):
|
||||
return line
|
||||
|
||||
raise RuntimeError(f"Timeout waiting response to '{cmd}' on {self.port}; last='{last_line}'")
|
||||
def sync(self, retries: int = 6) -> None:
|
||||
last_error = ""
|
||||
for _ in range(retries):
|
||||
try:
|
||||
self.command("PING", timeout_s=2.0, expect="pong")
|
||||
return
|
||||
except Exception as exc: # pragma: no cover - hardware timing
|
||||
last_error = str(exc)
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f"serial sync failed: {last_error}")
|
||||
|
||||
|
||||
def parse_latency_ms(value: Any, fallback_ms: int) -> int:
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
match = re.search(r"(\d+(\.\d+)?)", value)
|
||||
if match:
|
||||
return int(float(match.group(1)))
|
||||
return fallback_ms
|
||||
|
||||
|
||||
def fetch_http_status(base_url: str) -> Dict[str, Any]:
|
||||
url = base_url.rstrip("/") + "/api/status"
|
||||
def fetch_json(url: str) -> Dict[str, Any]:
|
||||
req = request.Request(url, method="GET")
|
||||
with request.urlopen(req, timeout=5) as response:
|
||||
payload = response.read().decode("utf-8")
|
||||
return json.loads(payload)
|
||||
with request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def check_web_endpoint(base_url: str, path: str, method: str = "GET", body: Optional[Dict[str, Any]] = None) -> int:
|
||||
url = base_url.rstrip("/") + path
|
||||
data = None
|
||||
headers = {}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = request.Request(url, data=data, headers=headers, method=method)
|
||||
def post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw = json.dumps(payload)
|
||||
req = request.Request(
|
||||
url,
|
||||
method="POST",
|
||||
data=raw.encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=5) as response:
|
||||
return int(response.status)
|
||||
with request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except error.HTTPError as exc:
|
||||
return int(exc.code)
|
||||
if exc.code != 400:
|
||||
raise
|
||||
# Fallback for endpoints implemented with AsyncWebServer "plain" body extraction.
|
||||
fallback = request.Request(
|
||||
url,
|
||||
method="POST",
|
||||
data=parse.urlencode({"plain": raw}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with request.urlopen(fallback, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def scenario_slic_transition(
|
||||
name: str,
|
||||
phone: SerialEndpoint,
|
||||
bench: SerialEndpoint,
|
||||
hook_target: str,
|
||||
) -> ScenarioResult:
|
||||
def scenario_serial_smoke(dev: SerialEndpoint) -> ScenarioResult:
|
||||
details: Dict[str, Any] = {}
|
||||
try:
|
||||
phone.command("CALL", expected_prefixes=["OK", "ERR"], timeout_s=2)
|
||||
time.sleep(0.8)
|
||||
status_ring = phone.command("STATUS", expect_json=True, timeout_s=3)
|
||||
details["ring_state"] = status_ring.get("telephony")
|
||||
|
||||
bench.command(f"HOOK {hook_target} ON", timeout_s=3)
|
||||
time.sleep(0.8)
|
||||
status_offhook = phone.command("STATUS", expect_json=True, timeout_s=3)
|
||||
details["offhook_state"] = status_offhook.get("telephony")
|
||||
|
||||
bench.command(f"HOOK {hook_target} OFF", timeout_s=3)
|
||||
time.sleep(0.8)
|
||||
status_idle = phone.command("STATUS", expect_json=True, timeout_s=3)
|
||||
details["idle_state"] = status_idle.get("telephony")
|
||||
|
||||
passed = (
|
||||
details["ring_state"] == "RINGING"
|
||||
and details["offhook_state"] in ("PLAYING_MESSAGE", "OFF_HOOK")
|
||||
and details["idle_state"] == "IDLE"
|
||||
)
|
||||
return ScenarioResult(name=name, passed=passed, details=details)
|
||||
except Exception as exc: # pragma: no cover
|
||||
details["error"] = str(exc)
|
||||
return ScenarioResult(name=name, passed=False, details=details)
|
||||
details["ping"] = dev.command("PING", expect="pong")
|
||||
details["status"] = dev.command("STATUS", expect="json")
|
||||
details["call"] = dev.command("CALL", expect="ack")
|
||||
details["capture_start"] = dev.command("CAPTURE_START", expect="ack")
|
||||
details["capture_stop"] = dev.command("CAPTURE_STOP", expect="ack")
|
||||
details["reset_metrics"] = dev.command("RESET_METRICS", expect="ack")
|
||||
ok = bool(details["ping"].get("ok")) and "telephony" in details["status"]
|
||||
return ScenarioResult("serial_smoke", "PASS" if ok else "FAIL", details)
|
||||
except Exception as exc:
|
||||
return ScenarioResult("serial_smoke", "FAIL", {"error": str(exc)})
|
||||
|
||||
|
||||
def scenario_a252_full_duplex(phone: SerialEndpoint, bench: SerialEndpoint) -> ScenarioResult:
|
||||
details: Dict[str, Any] = {"duration_s": 120}
|
||||
try:
|
||||
phone.command("RESET_METRICS", expected_prefixes=["OK"], timeout_s=2)
|
||||
phone.command("CAPTURE_START", expected_prefixes=["OK", "ERR"], timeout_s=3)
|
||||
phone.command("PLAY /welcome.wav", expected_prefixes=["OK", "ERR"], timeout_s=3)
|
||||
|
||||
bench.command("AUDIO INJECT START 1000 0.40", timeout_s=3)
|
||||
bench.command("MEASURE LATENCY START", timeout_s=3)
|
||||
|
||||
end_time = time.monotonic() + 120
|
||||
while time.monotonic() < end_time:
|
||||
time.sleep(5)
|
||||
phone.command("STATUS", expect_json=True, timeout_s=3)
|
||||
|
||||
latency_line = bench.command("MEASURE LATENCY READ", timeout_s=5)
|
||||
bench.command("AUDIO INJECT STOP", timeout_s=3)
|
||||
phone.command("CAPTURE_STOP", expected_prefixes=["OK"], timeout_s=3)
|
||||
|
||||
status = phone.command("STATUS", expect_json=True, timeout_s=5)
|
||||
details.update(
|
||||
{
|
||||
"audio_underrun_count": status.get("audio_underrun_count", 0),
|
||||
"audio_drop_frames": status.get("audio_drop_frames", 0),
|
||||
"audio_last_latency_ms": status.get("audio_last_latency_ms", 0),
|
||||
"bench_latency_ms": parse_latency_ms(latency_line, 9999),
|
||||
"telephony_state": status.get("telephony", "UNKNOWN"),
|
||||
}
|
||||
)
|
||||
|
||||
passed = (
|
||||
int(details["audio_underrun_count"]) <= 1
|
||||
and int(details["audio_drop_frames"]) == 0
|
||||
and int(details["bench_latency_ms"]) <= 120
|
||||
)
|
||||
return ScenarioResult(name="A252 full-duplex", passed=passed, details=details)
|
||||
except Exception as exc: # pragma: no cover
|
||||
details["error"] = str(exc)
|
||||
return ScenarioResult(name="A252 full-duplex", passed=False, details=details)
|
||||
def _is_success_response(resp: Dict[str, Any]) -> bool:
|
||||
if "ok" in resp:
|
||||
return bool(resp.get("ok"))
|
||||
return True
|
||||
|
||||
|
||||
def scenario_s3_local(phone: SerialEndpoint, bench: SerialEndpoint) -> ScenarioResult:
|
||||
details: Dict[str, Any] = {"duration_s": 20}
|
||||
try:
|
||||
phone.command("RESET_METRICS", expected_prefixes=["OK"], timeout_s=2)
|
||||
phone.command("CALL", expected_prefixes=["OK"], timeout_s=2)
|
||||
time.sleep(1.0)
|
||||
bench.command("HOOK S3 ON", timeout_s=3)
|
||||
|
||||
phone.command("CAPTURE_START", expected_prefixes=["OK", "ERR"], timeout_s=3)
|
||||
bench.command("AUDIO INJECT START 1000 0.40", timeout_s=3)
|
||||
bench.command("MEASURE LATENCY START", timeout_s=3)
|
||||
time.sleep(20)
|
||||
|
||||
latency_line = bench.command("MEASURE LATENCY READ", timeout_s=5)
|
||||
bench.command("AUDIO INJECT STOP", timeout_s=3)
|
||||
phone.command("CAPTURE_STOP", expected_prefixes=["OK"], timeout_s=3)
|
||||
bench.command("HOOK S3 OFF", timeout_s=3)
|
||||
|
||||
status = phone.command("STATUS", expect_json=True, timeout_s=5)
|
||||
details.update(
|
||||
{
|
||||
"telephony_state": status.get("telephony"),
|
||||
"audio_drop_frames": status.get("audio_drop_frames", 0),
|
||||
"bench_latency_ms": parse_latency_ms(latency_line, 9999),
|
||||
}
|
||||
)
|
||||
passed = int(details["bench_latency_ms"]) <= 150 and details["telephony_state"] in (
|
||||
"PLAYING_MESSAGE",
|
||||
"OFF_HOOK",
|
||||
"IDLE",
|
||||
)
|
||||
return ScenarioResult(name="S3 local mode", passed=passed, details=details)
|
||||
except Exception as exc: # pragma: no cover
|
||||
details["error"] = str(exc)
|
||||
return ScenarioResult(name="S3 local mode", passed=False, details=details)
|
||||
def _quote_arg(value: str) -> str:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def scenario_web_access(base_url: Optional[str], label: str) -> ScenarioResult:
|
||||
def scenario_serial_network(dev: SerialEndpoint, wifi_ssid: str, wifi_password: str) -> ScenarioResult:
|
||||
details: Dict[str, Any] = {}
|
||||
if not base_url:
|
||||
return ScenarioResult(name=f"{label} web endpoints", passed=True, details={"skipped": True})
|
||||
|
||||
try:
|
||||
status_payload = fetch_http_status(base_url)
|
||||
details["status_code"] = 200
|
||||
details["board_profile"] = status_payload.get("board_profile", "UNKNOWN")
|
||||
details["wifi_status_before"] = dev.command("WIFI_STATUS", expect="json")
|
||||
details["wifi_scan"] = dev.command("WIFI_SCAN", expect="json")
|
||||
if wifi_ssid:
|
||||
already_connected = (
|
||||
bool(details["wifi_status_before"].get("connected"))
|
||||
and str(details["wifi_status_before"].get("ssid", "")) == wifi_ssid
|
||||
)
|
||||
if already_connected:
|
||||
details["wifi_connect"] = {"ok": True, "line": "SKIP WIFI_CONNECT already_connected"}
|
||||
details["wifi_status_after"] = details["wifi_status_before"]
|
||||
else:
|
||||
details["wifi_connect"] = dev.command(
|
||||
f"WIFI_CONNECT {_quote_arg(wifi_ssid)} {_quote_arg(wifi_password)}",
|
||||
timeout_s=20.0,
|
||||
expect="ack",
|
||||
)
|
||||
time.sleep(2.0)
|
||||
details["wifi_status_after"] = dev.command("WIFI_STATUS", expect="json")
|
||||
details["mqtt_status"] = dev.command("MQTT_STATUS", expect="json")
|
||||
details["espnow_status"] = dev.command("ESPNOW_STATUS", expect="json")
|
||||
details["bt_status"] = dev.command("BT_STATUS", expect="json")
|
||||
ok = True
|
||||
for value in details.values():
|
||||
if isinstance(value, dict) and not _is_success_response(value):
|
||||
ok = False
|
||||
break
|
||||
return ScenarioResult("serial_network_stack", "PASS" if ok else "FAIL", details)
|
||||
except Exception as exc:
|
||||
return ScenarioResult("serial_network_stack", "FAIL", {"error": str(exc), **details})
|
||||
|
||||
details["config_status"] = check_web_endpoint(base_url, "/api/config", "GET")
|
||||
details["logs_status"] = check_web_endpoint(base_url, "/api/logs", "GET")
|
||||
details["control_status"] = check_web_endpoint(
|
||||
base_url, "/api/control", "POST", {"action": "call"}
|
||||
)
|
||||
|
||||
passed = (
|
||||
details["config_status"] == 200
|
||||
and details["logs_status"] == 200
|
||||
and details["control_status"] == 200
|
||||
)
|
||||
return ScenarioResult(name=f"{label} web endpoints", passed=passed, details=details)
|
||||
except Exception as exc: # pragma: no cover
|
||||
details["error"] = str(exc)
|
||||
return ScenarioResult(name=f"{label} web endpoints", passed=False, details=details)
|
||||
def scenario_http(base_url: str) -> ScenarioResult:
|
||||
details: Dict[str, Any] = {"base_url": base_url}
|
||||
try:
|
||||
details["status"] = fetch_json(base_url.rstrip("/") + "/api/status")
|
||||
details["wifi"] = fetch_json(base_url.rstrip("/") + "/api/network/wifi")
|
||||
details["mqtt"] = fetch_json(base_url.rstrip("/") + "/api/network/mqtt")
|
||||
details["espnow"] = fetch_json(base_url.rstrip("/") + "/api/network/espnow")
|
||||
details["bluetooth"] = fetch_json(base_url.rstrip("/") + "/api/bluetooth")
|
||||
details["control_call"] = post_json(base_url.rstrip("/") + "/api/control", {"action": "CALL"})
|
||||
return ScenarioResult("http_endpoints", "PASS", details)
|
||||
except error.HTTPError as exc:
|
||||
return ScenarioResult("http_endpoints", "FAIL", {"error": f"HTTP {exc.code}", **details})
|
||||
except Exception as exc:
|
||||
return ScenarioResult("http_endpoints", "FAIL", {"error": str(exc), **details})
|
||||
|
||||
|
||||
def scenario_manual(name: str, state: str, note: str) -> ScenarioResult:
|
||||
if state not in VALID_STATES:
|
||||
state = "MANUAL_SKIP"
|
||||
return ScenarioResult(name, state, {"note": note})
|
||||
|
||||
|
||||
def write_reports(results: List[ScenarioResult], report_json: Path, report_md: Path) -> None:
|
||||
overall_passed = all(item.passed for item in results)
|
||||
ensure_parent(report_json)
|
||||
ensure_parent(report_md)
|
||||
|
||||
overall_passed = all(item.state not in {"FAIL", "MANUAL_FAIL"} for item in results)
|
||||
payload = {
|
||||
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"overall_passed": overall_passed,
|
||||
"results": [
|
||||
{
|
||||
"name": item.name,
|
||||
"passed": item.passed,
|
||||
"details": item.details,
|
||||
}
|
||||
for item in results
|
||||
],
|
||||
"results": [{"name": x.name, "state": x.state, "details": x.details} for x in results],
|
||||
}
|
||||
|
||||
ensure_parent(report_json)
|
||||
ensure_parent(report_md)
|
||||
report_json.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Rapport validation HW",
|
||||
"# Rapport validation HW (A252)",
|
||||
"",
|
||||
f"- Date UTC: {payload['timestamp_utc']}",
|
||||
f"- Verdict global: {'PASS' if overall_passed else 'FAIL'}",
|
||||
"",
|
||||
"| Scénario | Verdict | Détails |",
|
||||
"| Scénario | État | Détails |",
|
||||
"|---|---|---|",
|
||||
]
|
||||
for item in results:
|
||||
details = json.dumps(item.details, ensure_ascii=False)
|
||||
verdict = "PASS" if item.passed else "FAIL"
|
||||
lines.append(f"| {item.name} | {verdict} | `{details}` |")
|
||||
lines.append(f"| {item.name} | {item.state} | `{details}` |")
|
||||
report_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="RTC_BL_PHONE hardware validation")
|
||||
parser.add_argument("--port-a252", required=True, help="Serial port for A252 target")
|
||||
parser.add_argument("--port-s3", required=True, help="Serial port for ESP32-S3 target")
|
||||
parser.add_argument("--bench-port", required=True, help="Serial port for bench controller")
|
||||
parser.add_argument("--baud", type=int, default=115200, help="UART baudrate")
|
||||
parser.add_argument("--flash", action="store_true", help="Build and flash both targets before tests")
|
||||
parser.add_argument("--report-json", default="docs/rapport_hw.json", help="JSON report path")
|
||||
parser = argparse.ArgumentParser(description="RTC_BL_PHONE A252 validation runner")
|
||||
parser.add_argument("--port-a252", required=True, help="serial port for A252 target")
|
||||
parser.add_argument("--baud", type=int, default=115200, help="serial baudrate")
|
||||
parser.add_argument("--flash", action="store_true", help="build and upload firmware before tests")
|
||||
parser.add_argument("--base-url", default="", help="optional A252 base URL (http://ip)")
|
||||
parser.add_argument("--wifi-ssid", default="", help="optional SSID for WIFI_CONNECT test")
|
||||
parser.add_argument("--wifi-password", default="", help="optional password for WIFI_CONNECT test")
|
||||
parser.add_argument("--report-json", default="docs/rapport_hw.json", help="output JSON report path")
|
||||
parser.add_argument(
|
||||
"--report-md", default="docs/rapport_tests_fonctionnels.md", help="Markdown report path"
|
||||
"--report-md", default="docs/rapport_tests_fonctionnels.md", help="output Markdown report path"
|
||||
)
|
||||
parser.add_argument("--a252-base-url", default="", help="Optional base URL for A252 web API")
|
||||
parser.add_argument("--s3-base-url", default="", help="Optional base URL for S3 web API")
|
||||
parser.add_argument("--manual-hook", default="MANUAL_SKIP", choices=sorted(VALID_STATES))
|
||||
parser.add_argument("--manual-ring", default="MANUAL_SKIP", choices=sorted(VALID_STATES))
|
||||
parser.add_argument("--manual-audio", default="MANUAL_SKIP", choices=sorted(VALID_STATES))
|
||||
parser.add_argument("--manual-hfp", default="MANUAL_SKIP", choices=sorted(VALID_STATES))
|
||||
parser.add_argument("--manual-note", default="", help="optional shared note for manual checks")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def maybe_flash(args: argparse.Namespace) -> None:
|
||||
if not args.flash:
|
||||
return
|
||||
run_cmd(["pio", "run", "-e", "esp32dev", "-e", "esp32-s3-devkitc-1"])
|
||||
run_cmd(["pio", "run", "-e", "esp32dev", "-t", "upload", "--upload-port", args.port_a252])
|
||||
run_cmd(["pio", "run", "-e", "esp32-s3-devkitc-1", "-t", "upload", "--upload-port", args.port_s3])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
maybe_flash(args)
|
||||
|
||||
if args.flash:
|
||||
run_cmd(["pio", "run", "-e", "esp32dev"])
|
||||
run_cmd(["pio", "run", "-e", "esp32dev", "-t", "upload", "--upload-port", args.port_a252])
|
||||
|
||||
results: List[ScenarioResult] = []
|
||||
try:
|
||||
with SerialEndpoint(args.port_a252, args.baud) as dev_a252, SerialEndpoint(
|
||||
args.port_s3, args.baud
|
||||
) as dev_s3, SerialEndpoint(args.bench_port, args.baud) as bench:
|
||||
dev_a252.command("PING", expected_prefixes=["PONG"], timeout_s=4)
|
||||
dev_s3.command("PING", expected_prefixes=["PONG"], timeout_s=4)
|
||||
bench.command("PING", timeout_s=3)
|
||||
bench.command("RESET", timeout_s=3)
|
||||
network_result: Optional[ScenarioResult] = None
|
||||
|
||||
results.append(scenario_slic_transition("SLIC transition A252", dev_a252, bench, "A252"))
|
||||
results.append(scenario_slic_transition("SLIC transition S3", dev_s3, bench, "S3"))
|
||||
results.append(scenario_a252_full_duplex(dev_a252, bench))
|
||||
results.append(scenario_s3_local(dev_s3, bench))
|
||||
results.append(scenario_web_access(args.a252_base_url or None, "A252"))
|
||||
results.append(scenario_web_access(args.s3_base_url or None, "S3"))
|
||||
try:
|
||||
with SerialEndpoint(args.port_a252, args.baud) as dev:
|
||||
dev.sync()
|
||||
results.append(scenario_serial_smoke(dev))
|
||||
network_result = scenario_serial_network(dev, args.wifi_ssid, args.wifi_password)
|
||||
results.append(network_result)
|
||||
except Exception as exc:
|
||||
results.append(
|
||||
ScenarioResult(
|
||||
name="runner",
|
||||
passed=False,
|
||||
details={"error": str(exc)},
|
||||
)
|
||||
)
|
||||
results.append(ScenarioResult("serial_runner", "FAIL", {"error": str(exc)}))
|
||||
|
||||
runtime_base_url = args.base_url.strip()
|
||||
if network_result and isinstance(network_result.details, dict):
|
||||
wifi_after = network_result.details.get("wifi_status_after")
|
||||
if isinstance(wifi_after, dict) and wifi_after.get("connected") and wifi_after.get("ip"):
|
||||
runtime_base_url = f"http://{wifi_after['ip']}"
|
||||
|
||||
if runtime_base_url:
|
||||
results.append(scenario_http(runtime_base_url))
|
||||
else:
|
||||
results.append(ScenarioResult("http_endpoints", "MANUAL_SKIP", {"note": "base URL not provided"}))
|
||||
|
||||
note = args.manual_note or "validated manually"
|
||||
results.append(scenario_manual("manual_hook_transition", args.manual_hook, note))
|
||||
results.append(scenario_manual("manual_ring_behavior", args.manual_ring, note))
|
||||
results.append(scenario_manual("manual_audio_path", args.manual_audio, note))
|
||||
results.append(scenario_manual("manual_hfp_pairing", args.manual_hfp, note))
|
||||
|
||||
write_reports(results, Path(args.report_json), Path(args.report_md))
|
||||
overall_passed = all(item.passed for item in results)
|
||||
return 0 if overall_passed else 1
|
||||
return 0 if all(item.state not in {"FAIL", "MANUAL_FAIL"} for item in results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import serial
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Ports à adapter selon votre configuration
|
||||
PORTS = [
|
||||
('/dev/cu.SLAB_USBtoUART', 'logs_esp32_audio_kit.txt'),
|
||||
('/dev/cu.usbmodem5B5E0508431', 'logs_esp32s3.txt'),
|
||||
]
|
||||
BAUDRATE = 115200
|
||||
|
||||
|
||||
def monitor_port(port, logfile):
|
||||
with serial.Serial(port, BAUDRATE, timeout=2) as ser, open(logfile, 'w') as log:
|
||||
print(f"[MONITOR] {port} -> {logfile}")
|
||||
while True:
|
||||
try:
|
||||
line = ser.readline().decode(errors='replace')
|
||||
if line:
|
||||
print(f"[{port}] {line.strip()}")
|
||||
log.write(line)
|
||||
log.flush()
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {port}: {e}")
|
||||
break
|
||||
|
||||
def main():
|
||||
threads = []
|
||||
for port, logfile in PORTS:
|
||||
t = threading.Thread(target=monitor_port, args=(port, logfile), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
print("Capture des logs en cours. Ctrl+C pour arrêter.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("Arrêt de la capture des logs.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for check_web_route_parity parser helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.check_web_route_parity import build_report, parse_frontend_routes, write_report_json
|
||||
|
||||
|
||||
class ParseFrontendRoutesTest(unittest.TestCase):
|
||||
def test_detects_direct_calls(self) -> None:
|
||||
source = """
|
||||
async function refresh() {
|
||||
await requestJson("/api/status");
|
||||
await requestJson("/api/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "CALL" }),
|
||||
});
|
||||
}
|
||||
"""
|
||||
routes = parse_frontend_routes(source)
|
||||
self.assertIn(("GET", "/api/status"), routes)
|
||||
self.assertIn(("POST", "/api/control"), routes)
|
||||
|
||||
def test_detects_calls_inside_promise_all(self) -> None:
|
||||
source = """
|
||||
const [wifi, mqtt, espnow, peers] = await Promise.all([
|
||||
requestJson("/api/network/wifi"),
|
||||
requestJson("/api/network/mqtt"),
|
||||
requestJson("/api/network/espnow"),
|
||||
requestJson("/api/network/espnow/peer"),
|
||||
]);
|
||||
"""
|
||||
routes = parse_frontend_routes(source)
|
||||
self.assertIn(("GET", "/api/network/wifi"), routes)
|
||||
self.assertIn(("GET", "/api/network/mqtt"), routes)
|
||||
self.assertIn(("GET", "/api/network/espnow"), routes)
|
||||
self.assertIn(("GET", "/api/network/espnow/peer"), routes)
|
||||
|
||||
def test_handles_nested_parentheses_in_payload(self) -> None:
|
||||
source = """
|
||||
await requestJson("/api/network/mqtt/publish", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
topic: "rtc/test",
|
||||
payload: JSON.stringify({ ping: true }),
|
||||
}),
|
||||
});
|
||||
"""
|
||||
routes = parse_frontend_routes(source)
|
||||
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()
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
echo "[test_terminal] build unit tests (no upload / no hardware)"
|
||||
platformio test --without-uploading --without-testing -e esp32dev
|
||||
|
||||
echo "[test_terminal] run host dtmf tests"
|
||||
mkdir -p .pio/host
|
||||
c++ -std=c++17 -Wall -Wextra -pedantic -Isrc test/host/test_dtmf_host.cpp src/telephony/DtmfDecoder.cpp -o .pio/host/test_dtmf_host
|
||||
.pio/host/test_dtmf_host
|
||||
|
||||
echo "[test_terminal] run web route parity parser tests"
|
||||
python3 -m unittest scripts/test_check_web_route_parity.py
|
||||
|
||||
echo "[test_terminal] check web route parity"
|
||||
python3 scripts/check_web_route_parity.py
|
||||
|
||||
echo "[test_terminal] all checks passed"
|
||||
+493
-26
@@ -1,15 +1,47 @@
|
||||
#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;
|
||||
if (profile == BoardProfile::ESP32_S3) {
|
||||
cfg.sample_rate = 16000;
|
||||
cfg.bck_pin = 42;
|
||||
cfg.ws_pin = 41;
|
||||
cfg.data_out_pin = 40;
|
||||
cfg.bck_pin = 17;
|
||||
cfg.ws_pin = 18;
|
||||
cfg.data_out_pin = 21;
|
||||
cfg.data_in_pin = 39;
|
||||
cfg.enable_capture = true;
|
||||
} else {
|
||||
@@ -27,59 +59,165 @@ 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())) {}
|
||||
|
||||
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) {
|
||||
config_ = config;
|
||||
features_ = getFeatureMatrix(detectBoardProfile());
|
||||
resetMetrics();
|
||||
end();
|
||||
_config = config;
|
||||
|
||||
const i2s_mode_t mode = static_cast<i2s_mode_t>(
|
||||
I2S_MODE_MASTER | I2S_MODE_TX |
|
||||
((config.enable_capture && features_.has_full_duplex_i2s) ? I2S_MODE_RX : 0));
|
||||
((_config.enable_capture && features_.has_full_duplex_i2s) ? I2S_MODE_RX : 0));
|
||||
|
||||
const i2s_config_t i2s_cfg = {
|
||||
_i2s_config = {
|
||||
.mode = mode,
|
||||
.sample_rate = config.sample_rate,
|
||||
.bits_per_sample = config.bits_per_sample,
|
||||
.channel_format = config.channel_format,
|
||||
.sample_rate = _config.sample_rate,
|
||||
.bits_per_sample = _config.bits_per_sample,
|
||||
.channel_format = _config.channel_format,
|
||||
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
|
||||
.intr_alloc_flags = 0,
|
||||
.dma_buf_count = 8,
|
||||
.dma_buf_len = 128,
|
||||
.dma_buf_count = _config.dma_buf_count,
|
||||
.dma_buf_len = _config.dma_buf_len,
|
||||
.use_apll = false,
|
||||
.tx_desc_auto_clear = true,
|
||||
.fixed_mclk = 0,
|
||||
};
|
||||
|
||||
const i2s_pin_config_t pin_cfg = {
|
||||
.bck_io_num = config.bck_pin,
|
||||
.ws_io_num = config.ws_pin,
|
||||
.data_out_num = config.data_out_pin,
|
||||
.data_in_num = config.data_in_pin,
|
||||
_i2s_pins = {
|
||||
.bck_io_num = _config.bck_pin,
|
||||
.ws_io_num = _config.ws_pin,
|
||||
.data_out_num = _config.data_out_pin,
|
||||
.data_in_num = _config.data_in_pin,
|
||||
};
|
||||
|
||||
i2s_driver_uninstall(config.port);
|
||||
if (i2s_driver_install(config.port, &i2s_cfg, 0, nullptr) != ESP_OK) {
|
||||
if (i2s_driver_install(_config.port, &_i2s_config, 0, nullptr) != ESP_OK) {
|
||||
Serial.println("[AudioEngine] i2s_driver_install failed");
|
||||
driver_installed_ = false;
|
||||
return false;
|
||||
}
|
||||
if (i2s_set_pin(config.port, &pin_cfg) != ESP_OK) {
|
||||
if (i2s_set_pin(_config.port, &_i2s_pins) != ESP_OK) {
|
||||
Serial.println("[AudioEngine] i2s_set_pin failed");
|
||||
i2s_driver_uninstall(config.port);
|
||||
i2s_driver_uninstall(_config.port);
|
||||
driver_installed_ = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (i2s_io_mutex_ == nullptr) {
|
||||
i2s_io_mutex_ = xSemaphoreCreateMutex();
|
||||
if (i2s_io_mutex_ == nullptr) {
|
||||
Serial.println("[AudioEngine] i2s mutex unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
i2s_zero_dma_buffer(_config.port);
|
||||
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;
|
||||
}
|
||||
|
||||
void AudioEngine::end() {
|
||||
if (!driver_installed_) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
@@ -91,32 +229,59 @@ 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();
|
||||
const size_t byte_count = samples * sizeof(int16_t);
|
||||
size_t bytes_read = 0;
|
||||
if (i2s_read(config_.port, dst, byte_count, &bytes_read, 2) != ESP_OK || bytes_read == 0) {
|
||||
if (i2s_read(_config.port, dst, byte_count, &bytes_read, 2) != ESP_OK || bytes_read == 0) {
|
||||
std::memset(dst, 0, byte_count);
|
||||
metrics_.underrun_count++;
|
||||
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);
|
||||
@@ -126,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 {
|
||||
@@ -153,4 +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();
|
||||
}
|
||||
}
|
||||
|
||||
+57
-6
@@ -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"
|
||||
|
||||
@@ -16,6 +20,8 @@ struct AudioConfig {
|
||||
int data_out_pin = 26;
|
||||
int data_in_pin = 35;
|
||||
bool enable_capture = true;
|
||||
uint8_t dma_buf_count = 8;
|
||||
uint16_t dma_buf_len = 256;
|
||||
};
|
||||
|
||||
struct AudioRuntimeMetrics {
|
||||
@@ -31,27 +37,72 @@ AudioConfig defaultAudioConfigForProfile(BoardProfile profile);
|
||||
|
||||
class AudioEngine {
|
||||
public:
|
||||
virtual ~AudioEngine() = default;
|
||||
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;
|
||||
virtual void resetMetrics();
|
||||
virtual void tick();
|
||||
const AudioConfig& config() const;
|
||||
|
||||
private:
|
||||
bool driver_installed_;
|
||||
bool capture_active_;
|
||||
bool playing_;
|
||||
uint32_t play_until_ms_;
|
||||
AudioConfig config_;
|
||||
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
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
#include "audio/AudioManager.h"
|
||||
#include "core/AgentSupervisor.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
AudioManager::AudioManager() : initialized_(false) {}
|
||||
|
||||
void notifyAudio(const std::string& state, const std::string& error = "") {
|
||||
AgentStatus status{state, error, millis()};
|
||||
AgentSupervisor::instance().notify("audio", status);
|
||||
}
|
||||
|
||||
bool AudioManager::begin(const AudioConfig& config) {
|
||||
initialized_ = engine_.begin(config);
|
||||
notifyAudio(initialized_ ? "initialized" : "init_failed", initialized_ ? "" : "init error");
|
||||
return initialized_;
|
||||
}
|
||||
|
||||
bool AudioManager::playFile(const char* path) {
|
||||
return initialized_ && engine_.playFile(path);
|
||||
bool ok = initialized_ && engine_.playFile(path);
|
||||
notifyAudio(ok ? "playing" : "play_failed", ok ? "" : "play error");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool AudioManager::startCapture() {
|
||||
return initialized_ && engine_.startCapture();
|
||||
bool ok = initialized_ && engine_.startCapture();
|
||||
notifyAudio(ok ? "capture" : "capture_failed", ok ? "" : "capture error");
|
||||
return ok;
|
||||
}
|
||||
|
||||
size_t AudioManager::readCaptureFrame(int16_t* dst, size_t samples) {
|
||||
@@ -27,6 +39,7 @@ void AudioManager::stopCapture() {
|
||||
return;
|
||||
}
|
||||
engine_.stopCapture();
|
||||
notifyAudio("stopped");
|
||||
}
|
||||
|
||||
bool AudioManager::supportsFullDuplex() const {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#include "audio/Es8388Driver.h"
|
||||
|
||||
#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);
|
||||
// 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);
|
||||
|
||||
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_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setVolume(volume_);
|
||||
setMute(muted_);
|
||||
setRoute(route_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Es8388Driver::setVolume(uint8_t percent) {
|
||||
volume_ = std::min<uint8_t>(100, percent);
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t reg = clampVolumeToReg(volume_);
|
||||
// DAC digital volume controls.
|
||||
return writeReg(0x1A, reg) && writeReg(0x1B, reg);
|
||||
}
|
||||
|
||||
bool Es8388Driver::setMute(bool enabled) {
|
||||
muted_ = enabled;
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
// DACCONTROL3 bit2 is mute.
|
||||
return writeReg(0x19, enabled ? 0x04 : 0x00);
|
||||
}
|
||||
|
||||
bool Es8388Driver::setRoute(const String& route) {
|
||||
route_ = route;
|
||||
route_.toLowerCase();
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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(0x04, 0xC0);
|
||||
}
|
||||
route_ = "rtc";
|
||||
return writeReg(0x26, 0x00) && writeReg(0x27, 0x90) && writeReg(0x2A, 0x90) &&
|
||||
writeReg(0x04, 0x3C);
|
||||
}
|
||||
|
||||
bool Es8388Driver::isReady() const {
|
||||
return ready_;
|
||||
}
|
||||
|
||||
uint8_t Es8388Driver::volume() const {
|
||||
return volume_;
|
||||
}
|
||||
|
||||
bool Es8388Driver::muted() const {
|
||||
return muted_;
|
||||
}
|
||||
|
||||
String Es8388Driver::route() const {
|
||||
return route_;
|
||||
}
|
||||
|
||||
bool Es8388Driver::writeReg(uint8_t reg, uint8_t value) {
|
||||
Wire.beginTransmission(address_);
|
||||
Wire.write(reg);
|
||||
Wire.write(value);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#ifndef AUDIO_ES8388_DRIVER_H
|
||||
#define AUDIO_ES8388_DRIVER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class Es8388Driver {
|
||||
public:
|
||||
bool begin(int sda_pin, int scl_pin, uint8_t address = 0x10);
|
||||
bool setVolume(uint8_t percent);
|
||||
bool setMute(bool enabled);
|
||||
bool setRoute(const String& route);
|
||||
|
||||
bool isReady() const;
|
||||
uint8_t volume() const;
|
||||
bool muted() const;
|
||||
String route() const;
|
||||
|
||||
private:
|
||||
bool writeReg(uint8_t reg, uint8_t value);
|
||||
|
||||
bool ready_ = false;
|
||||
uint8_t address_ = 0x10;
|
||||
uint8_t volume_ = 80;
|
||||
bool muted_ = false;
|
||||
String route_ = "rtc";
|
||||
};
|
||||
|
||||
#endif // AUDIO_ES8388_DRIVER_H
|
||||
+1578
-10
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,18 @@
|
||||
#define BLUETOOTH_BLUETOOTH_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <functional>
|
||||
|
||||
#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;
|
||||
@@ -16,17 +21,96 @@ 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);
|
||||
void setBleCommandHandler(std::function<String(const String&)> handler);
|
||||
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_;
|
||||
};
|
||||
|
||||
#endif // BLUETOOTH_BLUETOOTH_MANAGER_H
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,446 @@
|
||||
#include "config/A252ConfigStore.h"
|
||||
|
||||
#include <Preferences.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
constexpr const char* kPinsNs = "a252-pins";
|
||||
constexpr const char* kAudioNs = "a252-audio";
|
||||
constexpr const char* kMqttNs = "mqtt";
|
||||
constexpr const char* kEspNowNs = "espnow";
|
||||
|
||||
bool saveString(Preferences& prefs, const char* key, const String& value) {
|
||||
return prefs.putString(key, value) >= 0;
|
||||
}
|
||||
|
||||
bool loadJsonArray(const String& raw, JsonDocument& doc) {
|
||||
if (raw.isEmpty()) {
|
||||
doc.to<JsonArray>();
|
||||
return true;
|
||||
}
|
||||
const auto err = deserializeJson(doc, raw);
|
||||
return err == DeserializationError::Ok && doc.is<JsonArray>();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
A252PinsConfig A252ConfigStore::defaultPins() {
|
||||
return A252PinsConfig{};
|
||||
}
|
||||
|
||||
A252AudioConfig A252ConfigStore::defaultAudio() {
|
||||
return A252AudioConfig{};
|
||||
}
|
||||
|
||||
MqttConfig A252ConfigStore::defaultMqtt() {
|
||||
return MqttConfig{};
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadPins(A252PinsConfig& out) {
|
||||
out = defaultPins();
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kPinsNs, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out.i2s_bck = prefs.getInt("i2s_bck", out.i2s_bck);
|
||||
out.i2s_ws = prefs.getInt("i2s_ws", out.i2s_ws);
|
||||
out.i2s_dout = prefs.getInt("i2s_dout", out.i2s_dout);
|
||||
out.i2s_din = prefs.getInt("i2s_din", out.i2s_din);
|
||||
|
||||
out.es8388_sda = prefs.getInt("i2c_sda", out.es8388_sda);
|
||||
out.es8388_scl = prefs.getInt("i2c_scl", out.es8388_scl);
|
||||
|
||||
out.slic_rm = prefs.getInt("slic_rm", out.slic_rm);
|
||||
out.slic_fr = prefs.getInt("slic_fr", out.slic_fr);
|
||||
out.slic_shk = prefs.getInt("slic_shk", out.slic_shk);
|
||||
out.slic_line = prefs.getInt("slic_line", out.slic_line);
|
||||
out.slic_pd = prefs.getInt("slic_pd", out.slic_pd);
|
||||
out.hook_active_high = prefs.getBool("hook_hi", out.hook_active_high);
|
||||
prefs.end();
|
||||
|
||||
String error;
|
||||
if (!validatePins(out, error)) {
|
||||
out = defaultPins();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::savePins(const A252PinsConfig& cfg, String* error) {
|
||||
String local_error;
|
||||
if (!validatePins(cfg, local_error)) {
|
||||
if (error) {
|
||||
*error = local_error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kPinsNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
prefs.putInt("i2s_bck", cfg.i2s_bck);
|
||||
prefs.putInt("i2s_ws", cfg.i2s_ws);
|
||||
prefs.putInt("i2s_dout", cfg.i2s_dout);
|
||||
prefs.putInt("i2s_din", cfg.i2s_din);
|
||||
|
||||
prefs.putInt("i2c_sda", cfg.es8388_sda);
|
||||
prefs.putInt("i2c_scl", cfg.es8388_scl);
|
||||
|
||||
prefs.putInt("slic_rm", cfg.slic_rm);
|
||||
prefs.putInt("slic_fr", cfg.slic_fr);
|
||||
prefs.putInt("slic_shk", cfg.slic_shk);
|
||||
prefs.putInt("slic_line", cfg.slic_line);
|
||||
prefs.putInt("slic_pd", cfg.slic_pd);
|
||||
prefs.putBool("hook_hi", cfg.hook_active_high);
|
||||
prefs.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadAudio(A252AudioConfig& out) {
|
||||
out = defaultAudio();
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kAudioNs, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out.sample_rate = prefs.getUInt("sr", out.sample_rate);
|
||||
out.bits_per_sample = static_cast<uint8_t>(prefs.getUChar("bits", out.bits_per_sample));
|
||||
out.enable_capture = prefs.getBool("capture", out.enable_capture);
|
||||
out.volume = static_cast<uint8_t>(prefs.getUChar("vol", out.volume));
|
||||
out.mute = prefs.getBool("mute", out.mute);
|
||||
if (prefs.isKey("route")) {
|
||||
out.route = prefs.getString("route", out.route);
|
||||
}
|
||||
prefs.end();
|
||||
|
||||
String error;
|
||||
if (!validateAudio(out, error)) {
|
||||
out = defaultAudio();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveAudio(const A252AudioConfig& cfg, String* error) {
|
||||
String local_error;
|
||||
if (!validateAudio(cfg, local_error)) {
|
||||
if (error) {
|
||||
*error = local_error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kAudioNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
prefs.putUInt("sr", cfg.sample_rate);
|
||||
prefs.putUChar("bits", cfg.bits_per_sample);
|
||||
prefs.putBool("capture", cfg.enable_capture);
|
||||
prefs.putUChar("vol", cfg.volume);
|
||||
saveString(prefs, "route", cfg.route);
|
||||
prefs.putBool("mute", cfg.mute);
|
||||
prefs.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadMqtt(MqttConfig& out) {
|
||||
out = defaultMqtt();
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kMqttNs, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out.enabled = prefs.getBool("enabled", out.enabled);
|
||||
if (prefs.isKey("host")) {
|
||||
out.host = prefs.getString("host", out.host);
|
||||
}
|
||||
out.port = static_cast<uint16_t>(prefs.getUShort("port", out.port));
|
||||
if (prefs.isKey("user")) {
|
||||
out.user = prefs.getString("user", out.user);
|
||||
}
|
||||
if (prefs.isKey("pass")) {
|
||||
out.pass = prefs.getString("pass", out.pass);
|
||||
}
|
||||
if (prefs.isKey("topic")) {
|
||||
out.base_topic = prefs.getString("topic", out.base_topic);
|
||||
}
|
||||
prefs.end();
|
||||
|
||||
String error;
|
||||
if (!validateMqtt(out, error)) {
|
||||
out = defaultMqtt();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveMqtt(const MqttConfig& cfg, String* error) {
|
||||
String local_error;
|
||||
if (!validateMqtt(cfg, local_error)) {
|
||||
if (error) {
|
||||
*error = local_error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kMqttNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
prefs.putBool("enabled", cfg.enabled);
|
||||
saveString(prefs, "host", cfg.host);
|
||||
prefs.putUShort("port", cfg.port);
|
||||
saveString(prefs, "user", cfg.user);
|
||||
saveString(prefs, "pass", cfg.pass);
|
||||
saveString(prefs, "topic", cfg.base_topic);
|
||||
prefs.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::loadEspNowPeers(EspNowPeerStore& out) {
|
||||
out.peers.clear();
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kEspNowNs, false)) {
|
||||
return false;
|
||||
}
|
||||
const String raw = prefs.isKey("peers") ? prefs.getString("peers", "[]") : String("[]");
|
||||
prefs.end();
|
||||
|
||||
JsonDocument doc;
|
||||
if (!loadJsonArray(raw, doc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const JsonVariantConst item : doc.as<JsonArrayConst>()) {
|
||||
if (!item.is<const char*>()) {
|
||||
continue;
|
||||
}
|
||||
const String norm = normalizeMac(item.as<const char*>());
|
||||
if (norm.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (std::find(out.peers.begin(), out.peers.end(), norm) == out.peers.end()) {
|
||||
out.peers.push_back(norm);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::saveEspNowPeers(const EspNowPeerStore& store, String* error) {
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc.to<JsonArray>();
|
||||
for (const String& peer : store.peers) {
|
||||
if (!normalizeMac(peer).isEmpty()) {
|
||||
arr.add(normalizeMac(peer));
|
||||
}
|
||||
}
|
||||
|
||||
String raw;
|
||||
serializeJson(arr, raw);
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kEspNowNs, false)) {
|
||||
if (error) {
|
||||
*error = "nvs_open_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
saveString(prefs, "peers", raw);
|
||||
prefs.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
|
||||
std::vector<int> used;
|
||||
used.reserve(11);
|
||||
|
||||
const int required_pins[] = {
|
||||
cfg.i2s_bck,
|
||||
cfg.i2s_ws,
|
||||
cfg.i2s_dout,
|
||||
cfg.i2s_din,
|
||||
cfg.es8388_sda,
|
||||
cfg.es8388_scl,
|
||||
cfg.slic_rm,
|
||||
cfg.slic_fr,
|
||||
cfg.slic_shk,
|
||||
cfg.slic_pd,
|
||||
};
|
||||
|
||||
for (int pin : required_pins) {
|
||||
if (pin < 0 || pin > 39) {
|
||||
error = "invalid_pin_range";
|
||||
return false;
|
||||
}
|
||||
if (std::find(used.begin(), used.end(), pin) != used.end()) {
|
||||
error = "pin_conflict";
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::validateAudio(const A252AudioConfig& cfg, String& error) {
|
||||
if (cfg.sample_rate < 8000 || cfg.sample_rate > 48000) {
|
||||
error = "invalid_sample_rate";
|
||||
return false;
|
||||
}
|
||||
if (!(cfg.bits_per_sample == 16 || cfg.bits_per_sample == 24 || cfg.bits_per_sample == 32)) {
|
||||
error = "invalid_bits_per_sample";
|
||||
return false;
|
||||
}
|
||||
if (cfg.volume > 100) {
|
||||
error = "invalid_volume";
|
||||
return false;
|
||||
}
|
||||
|
||||
const String route = cfg.route;
|
||||
if (!(route == "rtc" || route == "bluetooth" || route == "none")) {
|
||||
error = "invalid_route";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::validateMqtt(const MqttConfig& cfg, String& error) {
|
||||
if (cfg.port == 0) {
|
||||
error = "invalid_port";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cfg.base_topic.isEmpty()) {
|
||||
error = "invalid_base_topic";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
void A252ConfigStore::pinsToJson(const A252PinsConfig& cfg, JsonObject obj) {
|
||||
JsonObject i2s = obj["i2s"].to<JsonObject>();
|
||||
i2s["bck"] = cfg.i2s_bck;
|
||||
i2s["ws"] = cfg.i2s_ws;
|
||||
i2s["dout"] = cfg.i2s_dout;
|
||||
i2s["din"] = cfg.i2s_din;
|
||||
|
||||
JsonObject i2c = obj["codec_i2c"].to<JsonObject>();
|
||||
i2c["sda"] = cfg.es8388_sda;
|
||||
i2c["scl"] = cfg.es8388_scl;
|
||||
|
||||
JsonObject slic = obj["slic"].to<JsonObject>();
|
||||
slic["rm"] = cfg.slic_rm;
|
||||
slic["fr"] = cfg.slic_fr;
|
||||
slic["shk"] = cfg.slic_shk;
|
||||
slic["line"] = cfg.slic_line;
|
||||
slic["pd"] = cfg.slic_pd;
|
||||
slic["hook_active_high"] = cfg.hook_active_high;
|
||||
}
|
||||
|
||||
void A252ConfigStore::audioToJson(const A252AudioConfig& cfg, JsonObject obj) {
|
||||
obj["sample_rate"] = cfg.sample_rate;
|
||||
obj["bits_per_sample"] = cfg.bits_per_sample;
|
||||
obj["enable_capture"] = cfg.enable_capture;
|
||||
obj["volume"] = cfg.volume;
|
||||
obj["mute"] = cfg.mute;
|
||||
obj["route"] = cfg.route;
|
||||
}
|
||||
|
||||
void A252ConfigStore::mqttToJson(const MqttConfig& cfg, JsonObject obj, bool include_secret) {
|
||||
obj["enabled"] = cfg.enabled;
|
||||
obj["host"] = cfg.host;
|
||||
obj["port"] = cfg.port;
|
||||
obj["user"] = cfg.user;
|
||||
obj["base_topic"] = cfg.base_topic;
|
||||
if (include_secret) {
|
||||
obj["pass"] = cfg.pass;
|
||||
}
|
||||
}
|
||||
|
||||
void A252ConfigStore::peersToJson(const EspNowPeerStore& store, JsonArray arr) {
|
||||
for (const String& peer : store.peers) {
|
||||
arr.add(peer);
|
||||
}
|
||||
}
|
||||
|
||||
String A252ConfigStore::normalizeMac(const String& value) {
|
||||
String mac = value;
|
||||
mac.trim();
|
||||
mac.toUpperCase();
|
||||
|
||||
String compact;
|
||||
compact.reserve(12);
|
||||
for (size_t i = 0; i < mac.length(); ++i) {
|
||||
const char c = mac[i];
|
||||
if (c == ':' || c == '-' || c == ' ') {
|
||||
continue;
|
||||
}
|
||||
const bool is_hex = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F');
|
||||
if (!is_hex) {
|
||||
return "";
|
||||
}
|
||||
compact += c;
|
||||
}
|
||||
|
||||
if (compact.length() != 12) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String formatted;
|
||||
formatted.reserve(17);
|
||||
for (int i = 0; i < 12; i += 2) {
|
||||
if (i > 0) {
|
||||
formatted += ':';
|
||||
}
|
||||
formatted += compact.substring(i, i + 2);
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
bool A252ConfigStore::parseMac(const String& value, uint8_t out[6]) {
|
||||
const String formatted = normalizeMac(value);
|
||||
if (formatted.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
const String chunk = formatted.substring(i * 3, i * 3 + 2);
|
||||
out[i] = static_cast<uint8_t>(strtoul(chunk.c_str(), nullptr, 16));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#ifndef CONFIG_A252_CONFIG_STORE_H
|
||||
#define CONFIG_A252_CONFIG_STORE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
struct A252PinsConfig {
|
||||
int i2s_bck = 27;
|
||||
int i2s_ws = 25;
|
||||
int i2s_dout = 26;
|
||||
int i2s_din = 35;
|
||||
|
||||
int es8388_sda = 33;
|
||||
int es8388_scl = 32;
|
||||
|
||||
int slic_rm = 22;
|
||||
int slic_fr = 19;
|
||||
int slic_shk = 36;
|
||||
int slic_line = -1;
|
||||
int slic_pd = 18;
|
||||
bool hook_active_high = false;
|
||||
};
|
||||
|
||||
struct A252AudioConfig {
|
||||
uint32_t sample_rate = 16000;
|
||||
uint8_t bits_per_sample = 16;
|
||||
bool enable_capture = true;
|
||||
uint8_t volume = 90;
|
||||
bool mute = false;
|
||||
String route = "rtc";
|
||||
};
|
||||
|
||||
struct MqttConfig {
|
||||
bool enabled = false;
|
||||
String host;
|
||||
uint16_t port = 1883;
|
||||
String user;
|
||||
String pass;
|
||||
String base_topic = "rtc_bl_phone/a252";
|
||||
};
|
||||
|
||||
struct EspNowPeerStore {
|
||||
std::vector<String> peers;
|
||||
};
|
||||
|
||||
class A252ConfigStore {
|
||||
public:
|
||||
static A252PinsConfig defaultPins();
|
||||
static A252AudioConfig defaultAudio();
|
||||
static MqttConfig defaultMqtt();
|
||||
|
||||
static bool loadPins(A252PinsConfig& out);
|
||||
static bool savePins(const A252PinsConfig& cfg, String* error = nullptr);
|
||||
|
||||
static bool loadAudio(A252AudioConfig& out);
|
||||
static bool saveAudio(const A252AudioConfig& cfg, String* error = nullptr);
|
||||
|
||||
static bool loadMqtt(MqttConfig& out);
|
||||
static bool saveMqtt(const MqttConfig& cfg, String* error = nullptr);
|
||||
|
||||
static bool loadEspNowPeers(EspNowPeerStore& out);
|
||||
static bool saveEspNowPeers(const EspNowPeerStore& store, String* error = nullptr);
|
||||
|
||||
static bool validatePins(const A252PinsConfig& cfg, String& error);
|
||||
static bool validateAudio(const A252AudioConfig& cfg, String& error);
|
||||
static bool validateMqtt(const MqttConfig& cfg, String& error);
|
||||
|
||||
static void pinsToJson(const A252PinsConfig& cfg, JsonObject obj);
|
||||
static void audioToJson(const A252AudioConfig& cfg, JsonObject obj);
|
||||
static void mqttToJson(const MqttConfig& cfg, JsonObject obj, bool include_secret = false);
|
||||
static void peersToJson(const EspNowPeerStore& store, JsonArray arr);
|
||||
|
||||
static String normalizeMac(const String& value);
|
||||
static bool parseMac(const String& value, uint8_t out[6]);
|
||||
};
|
||||
|
||||
#endif // CONFIG_A252_CONFIG_STORE_H
|
||||
@@ -0,0 +1,33 @@
|
||||
// AgentSupervisor.cpp
|
||||
#include "core/AgentSupervisor.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
AgentSupervisor& AgentSupervisor::instance() {
|
||||
static AgentSupervisor inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
void AgentSupervisor::notify(const std::string& agent, const AgentStatus& status) {
|
||||
statusMap_[agent] = status;
|
||||
publishEvent("status_update", agent, status);
|
||||
}
|
||||
|
||||
AgentStatus AgentSupervisor::getStatus(const std::string& agent) const {
|
||||
auto it = statusMap_.find(agent);
|
||||
if (it != statusMap_.end()) return it->second;
|
||||
return {"unknown", "", 0};
|
||||
}
|
||||
|
||||
std::map<std::string, AgentStatus> AgentSupervisor::getAllStatus() const {
|
||||
return statusMap_;
|
||||
}
|
||||
|
||||
void AgentSupervisor::subscribe(const std::string& event, std::function<void(const std::string&, const AgentStatus&)> cb) {
|
||||
subscribers_[event].push_back(cb);
|
||||
}
|
||||
|
||||
void AgentSupervisor::publishEvent(const std::string& event, const std::string& agent, const AgentStatus& status) {
|
||||
for (auto& cb : subscribers_[event]) {
|
||||
cb(agent, status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// AgentSupervisor.h
|
||||
// Superviseur central pour la coordination des agents RTC_BL_PHONE
|
||||
#pragma once
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
struct AgentStatus {
|
||||
std::string state;
|
||||
std::string lastError;
|
||||
unsigned long lastUpdate;
|
||||
};
|
||||
|
||||
class AgentSupervisor {
|
||||
public:
|
||||
static AgentSupervisor& instance();
|
||||
void notify(const std::string& agent, const AgentStatus& status);
|
||||
AgentStatus getStatus(const std::string& agent) const;
|
||||
std::map<std::string, AgentStatus> getAllStatus() const;
|
||||
void subscribe(const std::string& event, std::function<void(const std::string&, const AgentStatus&)> cb);
|
||||
void publishEvent(const std::string& event, const std::string& agent, const AgentStatus& status);
|
||||
private:
|
||||
AgentSupervisor() = default;
|
||||
std::map<std::string, AgentStatus> statusMap_;
|
||||
std::map<std::string, std::vector<std::function<void(const std::string&, const AgentStatus&)>>> subscribers_;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
#include "core/CommandDispatcher.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
void CommandDispatcher::registerCommand(const String& name, Handler handler) {
|
||||
const String key = normalizeCommand(name);
|
||||
if (key.isEmpty() || !handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlers_.find(key) == handlers_.end()) {
|
||||
order_.push_back(key);
|
||||
}
|
||||
handlers_[key] = std::move(handler);
|
||||
}
|
||||
|
||||
DispatchResponse CommandDispatcher::dispatch(const String& line) const {
|
||||
String input = line;
|
||||
input.trim();
|
||||
if (input.isEmpty()) {
|
||||
DispatchResponse resp;
|
||||
resp.ok = false;
|
||||
resp.code = "EMPTY_COMMAND";
|
||||
return resp;
|
||||
}
|
||||
|
||||
const int sep = input.indexOf(' ');
|
||||
const String cmd = normalizeCommand(sep > 0 ? input.substring(0, sep) : input);
|
||||
const String args = sep > 0 ? input.substring(sep + 1) : "";
|
||||
|
||||
const auto it = handlers_.find(cmd);
|
||||
if (it == handlers_.end()) {
|
||||
DispatchResponse resp;
|
||||
resp.ok = false;
|
||||
resp.code = "UNKNOWN_COMMAND " + cmd;
|
||||
return resp;
|
||||
}
|
||||
|
||||
return it->second(args);
|
||||
}
|
||||
|
||||
String CommandDispatcher::helpText() const {
|
||||
String out;
|
||||
out.reserve(order_.size() * 24);
|
||||
for (size_t i = 0; i < order_.size(); ++i) {
|
||||
out += order_[i];
|
||||
if (i + 1 < order_.size()) {
|
||||
out += '\n';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<String> CommandDispatcher::commands() const {
|
||||
return order_;
|
||||
}
|
||||
|
||||
String CommandDispatcher::normalizeCommand(const String& name) {
|
||||
String out = name;
|
||||
out.trim();
|
||||
out.toUpperCase();
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#ifndef CORE_COMMAND_DISPATCHER_H
|
||||
#define CORE_COMMAND_DISPATCHER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
struct DispatchResponse {
|
||||
bool ok = true;
|
||||
String code = "";
|
||||
String json;
|
||||
String raw;
|
||||
};
|
||||
|
||||
class CommandDispatcher {
|
||||
public:
|
||||
using Handler = std::function<DispatchResponse(const String& args)>;
|
||||
|
||||
void registerCommand(const String& name, Handler handler);
|
||||
DispatchResponse dispatch(const String& line) const;
|
||||
String helpText() const;
|
||||
std::vector<String> commands() const;
|
||||
|
||||
private:
|
||||
std::map<String, Handler> handlers_;
|
||||
std::vector<String> order_;
|
||||
|
||||
static String normalizeCommand(const String& name);
|
||||
};
|
||||
|
||||
#endif // CORE_COMMAND_DISPATCHER_H
|
||||
+1052
-129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
#include "props/EspNowBridge.h"
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
bool EspNowBridge::begin(const EspNowPeerStore& initial_peers) {
|
||||
if (ready_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
store_ = initial_peers;
|
||||
|
||||
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);
|
||||
|
||||
ready_ = true;
|
||||
|
||||
std::vector<String> peers_copy = store_.peers;
|
||||
store_.peers.clear();
|
||||
for (const String& mac : peers_copy) {
|
||||
addPeerInternal(mac, false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EspNowBridge::stop() {
|
||||
if (!ready_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const esp_err_t err = esp_now_deinit();
|
||||
ready_ = false;
|
||||
return err == ESP_OK;
|
||||
}
|
||||
|
||||
void EspNowBridge::tick() {
|
||||
// ESP-NOW uses callbacks, no polling required.
|
||||
}
|
||||
|
||||
bool EspNowBridge::addPeer(const String& mac) {
|
||||
return addPeerInternal(mac, true);
|
||||
}
|
||||
|
||||
bool EspNowBridge::deletePeer(const String& mac) {
|
||||
return deletePeerInternal(mac, true);
|
||||
}
|
||||
|
||||
const std::vector<String>& EspNowBridge::peers() const {
|
||||
return store_.peers;
|
||||
}
|
||||
|
||||
bool EspNowBridge::sendJson(const String& target, const String& json_payload) {
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target == "broadcast" || target == "BROADCAST") {
|
||||
const uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||
return sendToMac(broadcast_mac, json_payload);
|
||||
}
|
||||
|
||||
uint8_t mac[6] = {0};
|
||||
if (!A252ConfigStore::parseMac(target, mac)) {
|
||||
return false;
|
||||
}
|
||||
return sendToMac(mac, json_payload);
|
||||
}
|
||||
|
||||
bool EspNowBridge::isReady() const {
|
||||
return ready_;
|
||||
}
|
||||
|
||||
void EspNowBridge::setCommandCallback(std::function<void(const String&, const JsonVariantConst&)> cb) {
|
||||
command_callback_ = std::move(cb);
|
||||
}
|
||||
|
||||
void EspNowBridge::statusToJson(JsonObject obj) const {
|
||||
obj["ready"] = ready_;
|
||||
obj["peer_count"] = static_cast<uint32_t>(store_.peers.size());
|
||||
obj["tx_ok"] = tx_ok_;
|
||||
obj["tx_fail"] = tx_fail_;
|
||||
obj["rx_count"] = rx_count_;
|
||||
obj["last_rx_mac"] = last_rx_mac_;
|
||||
obj["last_rx_payload"] = last_rx_payload_;
|
||||
|
||||
JsonArray peers = obj["peers"].to<JsonArray>();
|
||||
for (const String& peer : store_.peers) {
|
||||
peers.add(peer);
|
||||
}
|
||||
}
|
||||
|
||||
bool EspNowBridge::addPeerInternal(const String& mac, bool persist) {
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const String normalized = A252ConfigStore::normalizeMac(mac);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (std::find(store_.peers.begin(), store_.peers.end(), normalized) != store_.peers.end()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t peer_mac[6] = {0};
|
||||
if (!A252ConfigStore::parseMac(normalized, peer_mac)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_now_peer_info_t peer_info = {};
|
||||
memcpy(peer_info.peer_addr, peer_mac, 6);
|
||||
peer_info.channel = 0;
|
||||
peer_info.encrypt = false;
|
||||
|
||||
if (esp_now_add_peer(&peer_info) != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
store_.peers.push_back(normalized);
|
||||
if (persist) {
|
||||
A252ConfigStore::saveEspNowPeers(store_);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EspNowBridge::deletePeerInternal(const String& mac, bool persist) {
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const String normalized = A252ConfigStore::normalizeMac(mac);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t peer_mac[6] = {0};
|
||||
if (!A252ConfigStore::parseMac(normalized, peer_mac)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_now_del_peer(peer_mac);
|
||||
|
||||
const auto it = std::remove(store_.peers.begin(), store_.peers.end(), normalized);
|
||||
const bool removed = it != store_.peers.end();
|
||||
store_.peers.erase(it, store_.peers.end());
|
||||
if (removed && persist) {
|
||||
A252ConfigStore::saveEspNowPeers(store_);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
bool EspNowBridge::sendToMac(const uint8_t mac[6], const String& payload) {
|
||||
if (!ready_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const esp_err_t err = esp_now_send(mac, reinterpret_cast<const uint8_t*>(payload.c_str()), payload.length());
|
||||
if (err != ESP_OK) {
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void EspNowBridge::onDataRecv(const uint8_t* mac_addr, const uint8_t* data, int len) {
|
||||
if (!instance_) {
|
||||
return;
|
||||
}
|
||||
|
||||
char mac_buf[18] = {0};
|
||||
snprintf(mac_buf,
|
||||
sizeof(mac_buf),
|
||||
"%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac_addr[0],
|
||||
mac_addr[1],
|
||||
mac_addr[2],
|
||||
mac_addr[3],
|
||||
mac_addr[4],
|
||||
mac_addr[5]);
|
||||
|
||||
String payload;
|
||||
payload.reserve(len + 1);
|
||||
for (int i = 0; i < len; ++i) {
|
||||
payload += static_cast<char>(data[i]);
|
||||
}
|
||||
|
||||
instance_->rx_count_++;
|
||||
instance_->last_rx_mac_ = mac_buf;
|
||||
instance_->last_rx_payload_ = payload;
|
||||
|
||||
if (!instance_->command_callback_) {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, payload) != DeserializationError::Ok) {
|
||||
doc.clear();
|
||||
doc["raw"] = payload;
|
||||
}
|
||||
instance_->command_callback_(String(mac_buf), doc.as<JsonVariantConst>());
|
||||
}
|
||||
|
||||
void EspNowBridge::onDataSent(const uint8_t* mac_addr, esp_now_send_status_t status) {
|
||||
if (!instance_) {
|
||||
return;
|
||||
}
|
||||
if (status == ESP_NOW_SEND_SUCCESS) {
|
||||
instance_->tx_ok_++;
|
||||
} else {
|
||||
instance_->tx_fail_++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#ifndef PROPS_ESPNOW_BRIDGE_H
|
||||
#define PROPS_ESPNOW_BRIDGE_H
|
||||
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <esp_now.h>
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "config/A252ConfigStore.h"
|
||||
|
||||
class EspNowBridge {
|
||||
public:
|
||||
EspNowBridge();
|
||||
|
||||
bool begin(const EspNowPeerStore& initial_peers);
|
||||
bool stop();
|
||||
void tick();
|
||||
|
||||
bool addPeer(const String& mac);
|
||||
bool deletePeer(const String& mac);
|
||||
const std::vector<String>& peers() const;
|
||||
|
||||
bool sendJson(const String& target, const String& json_payload);
|
||||
bool isReady() const;
|
||||
|
||||
void setCommandCallback(std::function<void(const String&, const JsonVariantConst&)> cb);
|
||||
void statusToJson(JsonObject obj) const;
|
||||
|
||||
private:
|
||||
bool addPeerInternal(const String& normalized_mac, bool persist);
|
||||
bool deletePeerInternal(const String& normalized_mac, bool persist);
|
||||
bool sendToMac(const uint8_t mac[6], const String& payload);
|
||||
|
||||
static void onDataRecv(const uint8_t* mac_addr, const uint8_t* data, int len);
|
||||
static void onDataSent(const uint8_t* mac_addr, esp_now_send_status_t status);
|
||||
|
||||
static EspNowBridge* instance_;
|
||||
|
||||
bool ready_ = false;
|
||||
EspNowPeerStore store_;
|
||||
std::function<void(const String&, const JsonVariantConst&)> command_callback_;
|
||||
|
||||
uint32_t tx_ok_ = 0;
|
||||
uint32_t tx_fail_ = 0;
|
||||
uint32_t rx_count_ = 0;
|
||||
String last_rx_mac_;
|
||||
String last_rx_payload_;
|
||||
};
|
||||
|
||||
#endif // PROPS_ESPNOW_BRIDGE_H
|
||||
@@ -0,0 +1,187 @@
|
||||
#include "props/PropsBridge.h"
|
||||
|
||||
#include <WiFi.h>
|
||||
|
||||
namespace {
|
||||
String clientId() {
|
||||
return String("rtc-bl-") + String(static_cast<uint32_t>(ESP.getEfuseMac() & 0xFFFFFF), HEX);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PropsBridge::PropsBridge() : mqtt_client_(net_client_) {}
|
||||
|
||||
bool PropsBridge::begin(const MqttConfig& config) {
|
||||
config_ = config;
|
||||
ensureClientConfigured();
|
||||
initialized_ = true;
|
||||
reconnect_attempts_ = 0;
|
||||
next_reconnect_ms_ = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
void PropsBridge::setConfig(const MqttConfig& config) {
|
||||
config_ = config;
|
||||
ensureClientConfigured();
|
||||
disconnect();
|
||||
}
|
||||
|
||||
const MqttConfig& PropsBridge::config() const {
|
||||
return config_;
|
||||
}
|
||||
|
||||
bool PropsBridge::connectNow() {
|
||||
if (!initialized_) {
|
||||
return false;
|
||||
}
|
||||
return connectInternal();
|
||||
}
|
||||
|
||||
void PropsBridge::disconnect() {
|
||||
if (mqtt_client_.connected()) {
|
||||
mqtt_client_.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
bool PropsBridge::isConnected() const {
|
||||
return mqtt_client_.connected();
|
||||
}
|
||||
|
||||
void PropsBridge::tick() {
|
||||
if (!initialized_ || !config_.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mqtt_client_.connected()) {
|
||||
mqtt_client_.loop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WiFi.isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (next_reconnect_ms_ != 0 && millis() < next_reconnect_ms_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connectInternal()) {
|
||||
reconnect_attempts_++;
|
||||
const uint32_t exp = (reconnect_attempts_ < 5) ? reconnect_attempts_ : 5;
|
||||
next_reconnect_ms_ = millis() + reconnect_backoff_ms_ * (1U << exp);
|
||||
}
|
||||
}
|
||||
|
||||
bool PropsBridge::publish(const String& topic_suffix, const String& payload, bool retained) {
|
||||
return publishRaw(topicFor(topic_suffix), payload, retained);
|
||||
}
|
||||
|
||||
bool PropsBridge::publishRaw(const String& topic, const String& payload, bool retained) {
|
||||
if (!mqtt_client_.connected()) {
|
||||
return false;
|
||||
}
|
||||
return mqtt_client_.publish(topic.c_str(), payload.c_str(), retained);
|
||||
}
|
||||
|
||||
void PropsBridge::publishStatus(const JsonVariantConst& status) {
|
||||
String payload;
|
||||
serializeJson(status, payload);
|
||||
publish("status", payload, false);
|
||||
}
|
||||
|
||||
void PropsBridge::publishEvent(const JsonVariantConst& event) {
|
||||
String payload;
|
||||
serializeJson(event, payload);
|
||||
publish("event", payload, false);
|
||||
}
|
||||
|
||||
void PropsBridge::handleCommand(const JsonVariantConst& cmd) {
|
||||
if (!command_callback_) {
|
||||
return;
|
||||
}
|
||||
|
||||
String source = "direct";
|
||||
if (cmd["source"].is<const char*>()) {
|
||||
source = cmd["source"].as<const char*>();
|
||||
}
|
||||
command_callback_(source, cmd);
|
||||
}
|
||||
|
||||
void PropsBridge::setCommandCallback(std::function<void(const String&, const JsonVariantConst&)> cb) {
|
||||
command_callback_ = std::move(cb);
|
||||
}
|
||||
|
||||
void PropsBridge::statusToJson(JsonObject obj) const {
|
||||
obj["enabled"] = config_.enabled;
|
||||
obj["connected"] = mqtt_client_.connected();
|
||||
obj["host"] = config_.host;
|
||||
obj["port"] = config_.port;
|
||||
obj["base_topic"] = config_.base_topic;
|
||||
obj["reconnect_attempts"] = reconnect_attempts_;
|
||||
}
|
||||
|
||||
void PropsBridge::ensureClientConfigured() {
|
||||
mqtt_client_.setServer(config_.host.c_str(), config_.port);
|
||||
mqtt_client_.setBufferSize(768);
|
||||
mqtt_client_.setKeepAlive(30);
|
||||
|
||||
mqtt_client_.setCallback([this](char* topic, uint8_t* payload, unsigned int length) {
|
||||
JsonDocument doc;
|
||||
String body;
|
||||
body.reserve(length + 1);
|
||||
for (unsigned int i = 0; i < length; ++i) {
|
||||
body += static_cast<char>(payload[i]);
|
||||
}
|
||||
|
||||
String action;
|
||||
if (deserializeJson(doc, body) == DeserializationError::Ok) {
|
||||
action = doc["cmd"] | "";
|
||||
} else {
|
||||
action = body;
|
||||
doc.clear();
|
||||
doc["raw"] = body;
|
||||
}
|
||||
|
||||
if (!command_callback_) {
|
||||
return;
|
||||
}
|
||||
|
||||
String topic_str = topic;
|
||||
command_callback_(topic_str, doc.as<JsonVariantConst>());
|
||||
if (!action.isEmpty()) {
|
||||
JsonDocument evt;
|
||||
evt["cmd"] = action;
|
||||
evt["source"] = "mqtt";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool PropsBridge::connectInternal() {
|
||||
if (!config_.enabled || config_.host.isEmpty() || !WiFi.isConnected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ensureClientConfigured();
|
||||
|
||||
const String cid = clientId();
|
||||
const bool ok = config_.user.isEmpty()
|
||||
? mqtt_client_.connect(cid.c_str())
|
||||
: mqtt_client_.connect(cid.c_str(), config_.user.c_str(), config_.pass.c_str());
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
reconnect_attempts_ = 0;
|
||||
next_reconnect_ms_ = 0;
|
||||
mqtt_client_.subscribe(topicFor("cmd").c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
String PropsBridge::topicFor(const String& suffix) const {
|
||||
String topic = config_.base_topic;
|
||||
if (!topic.endsWith("/")) {
|
||||
topic += '/';
|
||||
}
|
||||
topic += suffix;
|
||||
return topic;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#ifndef PROPS_PROPS_BRIDGE_H
|
||||
#define PROPS_PROPS_BRIDGE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <WiFiClient.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "config/A252ConfigStore.h"
|
||||
|
||||
class PropsBridge {
|
||||
public:
|
||||
PropsBridge();
|
||||
|
||||
bool begin(const MqttConfig& config);
|
||||
void setConfig(const MqttConfig& config);
|
||||
const MqttConfig& config() const;
|
||||
|
||||
bool connectNow();
|
||||
void disconnect();
|
||||
bool isConnected() const;
|
||||
void tick();
|
||||
|
||||
bool publish(const String& topic_suffix, const String& payload, bool retained = false);
|
||||
bool publishRaw(const String& topic, const String& payload, bool retained = false);
|
||||
void publishStatus(const JsonVariantConst& status);
|
||||
void publishEvent(const JsonVariantConst& event);
|
||||
void handleCommand(const JsonVariantConst& cmd);
|
||||
|
||||
void setCommandCallback(std::function<void(const String&, const JsonVariantConst&)> cb);
|
||||
void statusToJson(JsonObject obj) const;
|
||||
|
||||
private:
|
||||
void ensureClientConfigured();
|
||||
bool connectInternal();
|
||||
String topicFor(const String& suffix) const;
|
||||
|
||||
WiFiClient net_client_;
|
||||
mutable PubSubClient mqtt_client_;
|
||||
MqttConfig config_;
|
||||
std::function<void(const String&, const JsonVariantConst&)> command_callback_;
|
||||
|
||||
bool initialized_ = false;
|
||||
uint32_t next_reconnect_ms_ = 0;
|
||||
uint32_t reconnect_backoff_ms_ = 3000;
|
||||
uint32_t reconnect_attempts_ = 0;
|
||||
};
|
||||
|
||||
#endif // PROPS_PROPS_BRIDGE_H
|
||||
@@ -1,3 +1,11 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "core/AgentSupervisor.h"
|
||||
|
||||
void notifyRTOS(const std::string& state, const std::string& error = "") {
|
||||
AgentStatus status{state, error, millis()};
|
||||
AgentSupervisor::instance().notify("rtos", status);
|
||||
}
|
||||
#include "RTOSManager.h"
|
||||
#include <Arduino.h>
|
||||
#include <cstdlib>
|
||||
@@ -8,6 +16,7 @@ RTOSManager::RTOSManager() {}
|
||||
bool RTOSManager::begin() {
|
||||
initialized = true;
|
||||
Serial.println("RTOSManager: Initialisation OK");
|
||||
notifyRTOS("initialized");
|
||||
return initialized;
|
||||
}
|
||||
|
||||
@@ -15,9 +24,11 @@ bool RTOSManager::createTask(const char* name, void (*taskFunc)(void*), uint16_t
|
||||
BaseType_t res = xTaskCreate(taskFunc, name, stackSize, params, priority, nullptr);
|
||||
if (res != pdPASS) {
|
||||
Serial.printf("RTOSManager: Échec création tâche %s\n", name);
|
||||
notifyRTOS("task_failed", name);
|
||||
return false;
|
||||
}
|
||||
Serial.printf("RTOSManager: Tâche %s créée\n", name);
|
||||
notifyRTOS("task_created", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -59,11 +70,13 @@ void RTOSManager::enableWatchdog(uint32_t timeoutMs) {
|
||||
watchdogEnabled = true;
|
||||
watchdogTimeout = timeoutMs;
|
||||
Serial.printf("RTOSManager: Watchdog activé (%lu ms)\n", watchdogTimeout);
|
||||
notifyRTOS("watchdog_enabled");
|
||||
}
|
||||
|
||||
void RTOSManager::feedWatchdog() {
|
||||
if (watchdogEnabled) {
|
||||
esp_task_wdt_reset();
|
||||
Serial.println("RTOSManager: Watchdog feed");
|
||||
notifyRTOS("watchdog_feed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
#include "core/AgentSupervisor.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
void notifySLIC(const std::string& state, const std::string& error = "") {
|
||||
AgentStatus status{state, error, millis()};
|
||||
AgentSupervisor::instance().notify("slic", status);
|
||||
}
|
||||
#include "slic/SLICManager.h"
|
||||
|
||||
SLICManager::SLICManager(SlicController* controller)
|
||||
@@ -10,14 +17,17 @@ void SLICManager::attachController(SlicController* controller) {
|
||||
void SLICManager::begin() {
|
||||
if (controller_ == nullptr) {
|
||||
state_ = SLICLineState::UNINITIALIZED;
|
||||
notifySLIC("uninitialized", "no controller");
|
||||
return;
|
||||
}
|
||||
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
|
||||
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
|
||||
}
|
||||
|
||||
bool SLICManager::begin(const SlicPins& pins) {
|
||||
if (controller_ == nullptr || !controller_->begin(pins)) {
|
||||
state_ = SLICLineState::UNINITIALIZED;
|
||||
notifySLIC("uninitialized", "begin failed");
|
||||
return false;
|
||||
}
|
||||
begin();
|
||||
@@ -27,13 +37,16 @@ bool SLICManager::begin(const SlicPins& pins) {
|
||||
void SLICManager::monitorLine() {
|
||||
if (controller_ == nullptr) {
|
||||
state_ = SLICLineState::UNINITIALIZED;
|
||||
notifySLIC("uninitialized", "no controller");
|
||||
return;
|
||||
}
|
||||
controller_->tick();
|
||||
if (incoming_ring_) {
|
||||
state_ = SLICLineState::RINGING;
|
||||
notifySLIC("ringing");
|
||||
} else {
|
||||
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
|
||||
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,14 +57,17 @@ void SLICManager::controlCall() {
|
||||
void SLICManager::controlCall(bool incoming_ring) {
|
||||
incoming_ring_ = incoming_ring;
|
||||
if (controller_ == nullptr) {
|
||||
notifySLIC("uninitialized", "no controller");
|
||||
return;
|
||||
}
|
||||
if (incoming_ring_) {
|
||||
controller_->setRing(true);
|
||||
state_ = SLICLineState::RINGING;
|
||||
notifySLIC("ringing");
|
||||
} else {
|
||||
controller_->setRing(false);
|
||||
state_ = controller_->isHookOff() ? SLICLineState::OFF_HOOK : SLICLineState::ON_HOOK;
|
||||
notifySLIC(state_ == SLICLineState::OFF_HOOK ? "off_hook" : "on_hook");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "DtmfDecoder.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
constexpr std::array<double, 4> kLowFreq = {{697.0, 770.0, 852.0, 941.0}};
|
||||
constexpr std::array<double, 4> kHighFreq = {{1209.0, 1336.0, 1477.0, 1633.0}};
|
||||
constexpr char kDigitMap[4][4] = {
|
||||
{'1', '2', '3', 'A'},
|
||||
{'4', '5', '6', 'B'},
|
||||
{'7', '8', '9', 'C'},
|
||||
{'*', '0', '#', 'D'},
|
||||
};
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
constexpr double kDominanceRatio = 1.8;
|
||||
|
||||
double goertzelPower(const int16_t* samples, size_t count, double freqHz, uint16_t sampleRateHz) {
|
||||
if (samples == nullptr || count == 0 || sampleRateHz == 0U) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
const double omega = 2.0 * kPi * freqHz / static_cast<double>(sampleRateHz);
|
||||
const double coeff = 2.0 * std::cos(omega);
|
||||
double q0 = 0.0;
|
||||
double q1 = 0.0;
|
||||
double q2 = 0.0;
|
||||
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
q0 = coeff * q1 - q2 + static_cast<double>(samples[i]);
|
||||
q2 = q1;
|
||||
q1 = q0;
|
||||
}
|
||||
return q1 * q1 + q2 * q2 - coeff * q1 * q2;
|
||||
}
|
||||
|
||||
template <size_t N>
|
||||
size_t indexOfMax(const std::array<double, N>& values) {
|
||||
size_t idx = 0;
|
||||
for (size_t i = 1; i < N; ++i) {
|
||||
if (values[i] > values[idx]) {
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
template <size_t N>
|
||||
double secondBest(const std::array<double, N>& values, size_t bestIndex) {
|
||||
double second = 0.0;
|
||||
for (size_t i = 0; i < N; ++i) {
|
||||
if (i == bestIndex) {
|
||||
continue;
|
||||
}
|
||||
second = std::max(second, values[i]);
|
||||
}
|
||||
return second;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
DtmfDecoder::DtmfDecoder()
|
||||
: DtmfDecoder(8000U, 160U) {}
|
||||
|
||||
DtmfDecoder::DtmfDecoder(uint16_t sampleRateHz, size_t windowSize)
|
||||
: onDigit(nullptr),
|
||||
sampleRateHz_(sampleRateHz == 0U ? 8000U : sampleRateHz),
|
||||
windowSize_(windowSize < 80U ? 80U : windowSize),
|
||||
lastCandidate_('\0'),
|
||||
stableCount_(0U),
|
||||
latchedDigit_('\0') {}
|
||||
|
||||
void DtmfDecoder::setDigitCallback(DigitCallback cb) {
|
||||
onDigit = cb;
|
||||
}
|
||||
|
||||
char DtmfDecoder::detectDigit(const int16_t* samples, size_t count) const {
|
||||
if (samples == nullptr || count < (windowSize_ / 2U)) {
|
||||
return '\0';
|
||||
}
|
||||
|
||||
std::array<double, 4> lowPower = {{0.0, 0.0, 0.0, 0.0}};
|
||||
std::array<double, 4> highPower = {{0.0, 0.0, 0.0, 0.0}};
|
||||
for (size_t i = 0; i < 4; ++i) {
|
||||
lowPower[i] = goertzelPower(samples, count, kLowFreq[i], sampleRateHz_);
|
||||
highPower[i] = goertzelPower(samples, count, kHighFreq[i], sampleRateHz_);
|
||||
}
|
||||
|
||||
const size_t lowIdx = indexOfMax(lowPower);
|
||||
const size_t highIdx = indexOfMax(highPower);
|
||||
const double lowBest = lowPower[lowIdx];
|
||||
const double highBest = highPower[highIdx];
|
||||
const double lowSecond = secondBest(lowPower, lowIdx);
|
||||
const double highSecond = secondBest(highPower, highIdx);
|
||||
const double lowSum = lowPower[0] + lowPower[1] + lowPower[2] + lowPower[3];
|
||||
const double highSum = highPower[0] + highPower[1] + highPower[2] + highPower[3];
|
||||
|
||||
if (lowBest <= 0.0 || highBest <= 0.0) {
|
||||
return '\0';
|
||||
}
|
||||
if (lowSecond > 0.0 && (lowBest / lowSecond) < kDominanceRatio) {
|
||||
return '\0';
|
||||
}
|
||||
if (highSecond > 0.0 && (highBest / highSecond) < kDominanceRatio) {
|
||||
return '\0';
|
||||
}
|
||||
if ((lowBest / (lowSum + 1.0)) < 0.55 || (highBest / (highSum + 1.0)) < 0.55) {
|
||||
return '\0';
|
||||
}
|
||||
|
||||
return kDigitMap[lowIdx][highIdx];
|
||||
}
|
||||
|
||||
void DtmfDecoder::feedAudioSamples(const int16_t* samples, size_t count) {
|
||||
if (samples == nullptr || count == 0U) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t offset = 0; offset < count; offset += windowSize_) {
|
||||
const size_t frameSize = std::min(windowSize_, count - offset);
|
||||
if (frameSize < (windowSize_ / 2U)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const char candidate = detectDigit(samples + offset, frameSize);
|
||||
if (candidate == '\0') {
|
||||
lastCandidate_ = '\0';
|
||||
stableCount_ = 0U;
|
||||
latchedDigit_ = '\0';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate == lastCandidate_) {
|
||||
if (stableCount_ < 255U) {
|
||||
++stableCount_;
|
||||
}
|
||||
} else {
|
||||
lastCandidate_ = candidate;
|
||||
stableCount_ = 1U;
|
||||
}
|
||||
|
||||
if (stableCount_ >= 2U && candidate != latchedDigit_) {
|
||||
latchedDigit_ = candidate;
|
||||
if (onDigit) {
|
||||
onDigit(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
class DtmfDecoder {
|
||||
public:
|
||||
using DigitCallback = std::function<void(char)>;
|
||||
DtmfDecoder();
|
||||
explicit DtmfDecoder(uint16_t sampleRateHz, size_t windowSize = 160);
|
||||
void feedAudioSamples(const int16_t* samples, size_t count);
|
||||
void setDigitCallback(DigitCallback cb);
|
||||
|
||||
private:
|
||||
char detectDigit(const int16_t* samples, size_t count) const;
|
||||
DigitCallback onDigit;
|
||||
uint16_t sampleRateHz_;
|
||||
size_t windowSize_;
|
||||
char lastCandidate_;
|
||||
uint8_t stableCount_;
|
||||
char latchedDigit_;
|
||||
};
|
||||
@@ -1,5 +1,20 @@
|
||||
#include "telephony/TelephonyService.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint16_t kDtmfFrameSamples = 160U;
|
||||
constexpr uint32_t kHookHangupMs = 800U;
|
||||
constexpr uint32_t kHookStabilizeMs = 40U;
|
||||
constexpr uint32_t kPulseInterDigitGapMs = 420U;
|
||||
constexpr uint32_t kPulseEdgeDebounceMs = 18U;
|
||||
constexpr uint32_t kPulseDtmfGuardMs = 900U;
|
||||
constexpr size_t kDialDigitsTarget = 10U;
|
||||
constexpr uint32_t kDtmfCaptureStartDelayMs = 0U;
|
||||
constexpr uint32_t kDtmfReadPeriodMs = 12U;
|
||||
constexpr uint8_t kDialSourceNone = 0U;
|
||||
constexpr uint8_t kDialSourceDtmf = 1U;
|
||||
constexpr uint8_t kDialSourcePulse = 2U;
|
||||
}
|
||||
|
||||
const char* telephonyStateToString(TelephonyState state) {
|
||||
switch (state) {
|
||||
case TelephonyState::IDLE:
|
||||
@@ -20,10 +35,30 @@ TelephonyService::TelephonyService()
|
||||
features_(getFeatureMatrix(BoardProfile::ESP32_A252)),
|
||||
slic_(nullptr),
|
||||
audio_(nullptr),
|
||||
dial_callback_(nullptr),
|
||||
answer_callback_(nullptr),
|
||||
dtmf_(8000U, kDtmfFrameSamples),
|
||||
state_(TelephonyState::IDLE),
|
||||
incoming_ring_(false),
|
||||
ring_phase_on_(false),
|
||||
ring_cycle_start_ms_(0),
|
||||
capture_active_(false),
|
||||
pulse_hook_initialized_(false),
|
||||
pulse_last_hook_off_(false),
|
||||
pulse_collecting_(false),
|
||||
pulse_count_(0),
|
||||
last_hook_edge_ms_(0),
|
||||
last_pulse_ms_(0),
|
||||
dtmf_capture_start_ms_(0),
|
||||
next_dtmf_read_ms_(0),
|
||||
off_hook_enter_ms_(0),
|
||||
last_pulse_edge_ms_(0),
|
||||
suppress_dial_tone_(false),
|
||||
dialing_started_(false),
|
||||
dial_source_(kDialSourceNone),
|
||||
dial_buffer_(""),
|
||||
last_digit_ms_(0),
|
||||
last_dial_error_(""),
|
||||
message_path_("/welcome.wav") {}
|
||||
|
||||
bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEngine& audio) {
|
||||
@@ -35,25 +70,200 @@ bool TelephonyService::begin(BoardProfile profile, SlicController& slic, AudioEn
|
||||
incoming_ring_ = false;
|
||||
ring_phase_on_ = false;
|
||||
ring_cycle_start_ms_ = millis();
|
||||
capture_active_ = false;
|
||||
pulse_hook_initialized_ = false;
|
||||
pulse_last_hook_off_ = false;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
last_hook_edge_ms_ = 0;
|
||||
last_pulse_ms_ = 0;
|
||||
dtmf_capture_start_ms_ = 0;
|
||||
next_dtmf_read_ms_ = 0;
|
||||
off_hook_enter_ms_ = 0;
|
||||
last_pulse_edge_ms_ = 0;
|
||||
suppress_dial_tone_ = false;
|
||||
dialing_started_ = false;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
last_dial_error_ = "";
|
||||
|
||||
dtmf_.setDigitCallback([this](char digit) {
|
||||
onDialDigit(digit, false);
|
||||
});
|
||||
|
||||
slic_->setRing(false);
|
||||
slic_->setLineEnabled(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
void TelephonyService::setDialCallback(DialCallback cb) {
|
||||
dial_callback_ = cb;
|
||||
}
|
||||
|
||||
void TelephonyService::setAnswerCallback(AnswerCallback cb) {
|
||||
answer_callback_ = cb;
|
||||
}
|
||||
|
||||
void TelephonyService::triggerIncomingRing() {
|
||||
incoming_ring_ = true;
|
||||
}
|
||||
|
||||
void TelephonyService::setIncomingRing(bool active) {
|
||||
incoming_ring_ = active;
|
||||
}
|
||||
|
||||
void TelephonyService::onDialDigit(char digit, bool from_pulse) {
|
||||
if (digit < '0' || digit > '9') {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
if (!from_pulse) {
|
||||
// Rotary pulse has priority: suppress DTMF captures while pulse edges are active/recent.
|
||||
const bool pulse_recent =
|
||||
pulse_collecting_ || pulse_count_ > 0U ||
|
||||
(last_pulse_edge_ms_ != 0U && (now - last_pulse_edge_ms_) < kPulseDtmfGuardMs);
|
||||
if (pulse_recent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const uint8_t source = from_pulse ? kDialSourcePulse : kDialSourceDtmf;
|
||||
if (dial_source_ == kDialSourceNone) {
|
||||
dial_source_ = source;
|
||||
} else if (dial_source_ != source) {
|
||||
// Allow pulse to override an early DTMF false-start (typically tone bleed).
|
||||
if (from_pulse && dial_source_ == kDialSourceDtmf && dial_buffer_.length() <= 1U) {
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dial_source_ = source;
|
||||
} else {
|
||||
// Keep strict ordering by ignoring mixed-source digits in the same session.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (audio_ != nullptr && dial_buffer_.isEmpty() && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
dialing_started_ = true;
|
||||
if (dial_buffer_.length() >= kDialDigitsTarget) {
|
||||
dial_buffer_ = "";
|
||||
}
|
||||
|
||||
dial_buffer_ += digit;
|
||||
last_digit_ms_ = now;
|
||||
Serial.printf("[Telephony] digit=%c source=%s buffer=%s\n",
|
||||
digit,
|
||||
from_pulse ? "pulse" : "dtmf",
|
||||
dial_buffer_.c_str());
|
||||
|
||||
if (dial_buffer_.length() == kDialDigitsTarget) {
|
||||
commitDialBuffer("len10");
|
||||
}
|
||||
}
|
||||
|
||||
void TelephonyService::updatePulseDecode(bool hook_off, uint32_t now) {
|
||||
if (!pulse_hook_initialized_) {
|
||||
pulse_hook_initialized_ = true;
|
||||
pulse_last_hook_off_ = hook_off;
|
||||
last_hook_edge_ms_ = now;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hook_off == pulse_last_hook_off_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((now - last_pulse_edge_ms_) < kPulseEdgeDebounceMs) {
|
||||
return;
|
||||
}
|
||||
last_pulse_edge_ms_ = now;
|
||||
|
||||
// Any valid hook edge during OFF_HOOK indicates dialing activity start.
|
||||
if (audio_ != nullptr && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
dialing_started_ = true;
|
||||
|
||||
if (pulse_last_hook_off_ && !hook_off) {
|
||||
if (!pulse_collecting_) {
|
||||
pulse_collecting_ = true;
|
||||
pulse_count_ = 0;
|
||||
// Stop dial tone as soon as rotary dialing starts (first pulse edge),
|
||||
// not only after the first full decoded digit.
|
||||
if (audio_ != nullptr && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
}
|
||||
} else if (!pulse_last_hook_off_ && hook_off) {
|
||||
if (pulse_collecting_ && pulse_count_ < 20U) {
|
||||
++pulse_count_;
|
||||
last_pulse_ms_ = now;
|
||||
}
|
||||
}
|
||||
|
||||
pulse_last_hook_off_ = hook_off;
|
||||
last_hook_edge_ms_ = now;
|
||||
}
|
||||
|
||||
void TelephonyService::commitDialBuffer(const char* reason) {
|
||||
if (dial_buffer_.length() != kDialDigitsTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (audio_ != nullptr && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
|
||||
const String number = dial_buffer_;
|
||||
const bool ok = dial_callback_ ? dial_callback_(number) : false;
|
||||
last_dial_error_ = ok ? "" : "bt_dial_failed";
|
||||
Serial.printf("[Telephony] dial_trigger reason=%s number=%s ok=%s\n",
|
||||
reason != nullptr ? reason : "unknown",
|
||||
number.c_str(),
|
||||
ok ? "true" : "false");
|
||||
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
}
|
||||
|
||||
void TelephonyService::clearDialSession() {
|
||||
if (audio_ != nullptr && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
if (audio_ != nullptr && capture_active_) {
|
||||
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
|
||||
}
|
||||
capture_active_ = false;
|
||||
dtmf_capture_start_ms_ = 0;
|
||||
next_dtmf_read_ms_ = 0;
|
||||
off_hook_enter_ms_ = 0;
|
||||
pulse_hook_initialized_ = false;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
last_hook_edge_ms_ = 0;
|
||||
last_pulse_ms_ = 0;
|
||||
last_pulse_edge_ms_ = 0;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dialing_started_ = false;
|
||||
suppress_dial_tone_ = false;
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
}
|
||||
|
||||
void TelephonyService::tick() {
|
||||
if (slic_ == nullptr || audio_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
slic_->tick();
|
||||
audio_->tick();
|
||||
|
||||
const bool hook_off = slic_->isHookOff();
|
||||
const uint32_t now = millis();
|
||||
const TelephonyState prev_state = state_;
|
||||
|
||||
switch (state_) {
|
||||
case TelephonyState::IDLE:
|
||||
if (incoming_ring_ && !hook_off) {
|
||||
@@ -71,8 +281,12 @@ void TelephonyService::tick() {
|
||||
incoming_ring_ = false;
|
||||
ring_phase_on_ = false;
|
||||
slic_->setRing(false);
|
||||
audio_->playFile(message_path_);
|
||||
state_ = TelephonyState::PLAYING_MESSAGE;
|
||||
const bool answered = answer_callback_ ? answer_callback_() : false;
|
||||
// While transitioning from incoming ring to call answer, keep dial tone muted
|
||||
// even if BT answer fails transiently.
|
||||
suppress_dial_tone_ = true;
|
||||
last_dial_error_ = answered ? "" : "bt_answer_failed";
|
||||
state_ = TelephonyState::OFF_HOOK;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -99,12 +313,104 @@ void TelephonyService::tick() {
|
||||
break;
|
||||
|
||||
case TelephonyState::OFF_HOOK:
|
||||
if ((now - off_hook_enter_ms_) >= kHookStabilizeMs) {
|
||||
updatePulseDecode(hook_off, now);
|
||||
}
|
||||
|
||||
if (!hook_off) {
|
||||
incoming_ring_ = false;
|
||||
state_ = TelephonyState::IDLE;
|
||||
// Stop audible dial tone immediately on hangup, even if we keep
|
||||
// a short debounce before transitioning back to IDLE.
|
||||
if (audio_ != nullptr && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
if (audio_ != nullptr && capture_active_) {
|
||||
audio_->releaseCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
|
||||
capture_active_ = false;
|
||||
}
|
||||
// Reset dialing session immediately on hangup.
|
||||
if (!dial_buffer_.isEmpty() || dial_source_ != kDialSourceNone || pulse_collecting_ || pulse_count_ > 0U) {
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dialing_started_ = false;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
last_pulse_ms_ = 0;
|
||||
}
|
||||
if ((now - last_hook_edge_ms_) >= kHookHangupMs) {
|
||||
incoming_ring_ = false;
|
||||
state_ = TelephonyState::IDLE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (pulse_collecting_ && pulse_count_ > 0U && (now - last_pulse_ms_) >= kPulseInterDigitGapMs) {
|
||||
const uint8_t count = pulse_count_;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
const char digit = (count == 10U) ? '0' : ((count >= 1U && count <= 9U) ? static_cast<char>('0' + count)
|
||||
: '\0');
|
||||
if (digit != '\0') {
|
||||
onDialDigit(digit, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!capture_active_ && now >= dtmf_capture_start_ms_) {
|
||||
capture_active_ = audio_->requestCapture(AudioEngine::CAPTURE_CLIENT_TELEPHONY);
|
||||
}
|
||||
if (capture_active_ && now >= next_dtmf_read_ms_) {
|
||||
int16_t frame[kDtmfFrameSamples] = {0};
|
||||
const size_t samples_read = audio_->readCaptureFrameNonBlocking(frame, kDtmfFrameSamples);
|
||||
if (samples_read > 0U) {
|
||||
dtmf_.feedAudioSamples(frame, samples_read);
|
||||
}
|
||||
next_dtmf_read_ms_ = now + kDtmfReadPeriodMs;
|
||||
}
|
||||
|
||||
if (suppress_dial_tone_ && audio_->isDialToneActive()) {
|
||||
audio_->stopDialTone();
|
||||
}
|
||||
|
||||
const bool pulse_dial_in_progress =
|
||||
pulse_collecting_ || pulse_count_ > 0U ||
|
||||
(last_pulse_edge_ms_ != 0U && (now - last_pulse_edge_ms_) < kPulseInterDigitGapMs);
|
||||
if (!suppress_dial_tone_ && !dialing_started_ && dial_buffer_.isEmpty() && !audio_->isDialToneActive() &&
|
||||
!pulse_dial_in_progress) {
|
||||
audio_->startDialTone();
|
||||
}
|
||||
|
||||
if (!dial_buffer_.isEmpty() && (now - last_digit_ms_) >= 10000U) {
|
||||
// Drop stale partial numbers instead of dialing an incomplete value.
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (prev_state != state_) {
|
||||
if (state_ == TelephonyState::OFF_HOOK) {
|
||||
off_hook_enter_ms_ = now;
|
||||
pulse_hook_initialized_ = false;
|
||||
pulse_collecting_ = false;
|
||||
pulse_count_ = 0;
|
||||
last_hook_edge_ms_ = now;
|
||||
last_pulse_ms_ = 0;
|
||||
last_pulse_edge_ms_ = 0;
|
||||
dial_source_ = kDialSourceNone;
|
||||
dialing_started_ = false;
|
||||
dial_buffer_ = "";
|
||||
last_digit_ms_ = 0;
|
||||
dtmf_capture_start_ms_ = now + kDtmfCaptureStartDelayMs;
|
||||
next_dtmf_read_ms_ = now;
|
||||
if (audio_ != nullptr && !suppress_dial_tone_) {
|
||||
audio_->startDialTone();
|
||||
}
|
||||
}
|
||||
|
||||
if (prev_state == TelephonyState::OFF_HOOK && state_ != TelephonyState::OFF_HOOK) {
|
||||
clearDialSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TelephonyState TelephonyService::state() const {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#ifndef TELEPHONY_SERVICE_H
|
||||
#define TELEPHONY_SERVICE_H
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "audio/AudioEngine.h"
|
||||
#include "core/PlatformProfile.h"
|
||||
#include "slic/SlicController.h"
|
||||
#include "telephony/DtmfDecoder.h"
|
||||
|
||||
enum class TelephonyState : uint8_t {
|
||||
IDLE = 0,
|
||||
@@ -16,21 +19,52 @@ const char* telephonyStateToString(TelephonyState state);
|
||||
|
||||
class TelephonyService {
|
||||
public:
|
||||
using DialCallback = std::function<bool(const String&)>;
|
||||
using AnswerCallback = std::function<bool()>;
|
||||
|
||||
TelephonyService();
|
||||
bool begin(BoardProfile profile, SlicController& slic, AudioEngine& audio);
|
||||
void setDialCallback(DialCallback cb);
|
||||
void setAnswerCallback(AnswerCallback cb);
|
||||
void triggerIncomingRing();
|
||||
void setIncomingRing(bool active);
|
||||
void tick();
|
||||
TelephonyState state() const;
|
||||
|
||||
private:
|
||||
void onDialDigit(char digit, bool from_pulse);
|
||||
void updatePulseDecode(bool hook_off, uint32_t now);
|
||||
void commitDialBuffer(const char* reason);
|
||||
void clearDialSession();
|
||||
|
||||
BoardProfile profile_;
|
||||
FeatureMatrix features_;
|
||||
SlicController* slic_;
|
||||
AudioEngine* audio_;
|
||||
DialCallback dial_callback_;
|
||||
AnswerCallback answer_callback_;
|
||||
DtmfDecoder dtmf_;
|
||||
TelephonyState state_;
|
||||
bool incoming_ring_;
|
||||
bool ring_phase_on_;
|
||||
uint32_t ring_cycle_start_ms_;
|
||||
bool capture_active_;
|
||||
bool pulse_hook_initialized_;
|
||||
bool pulse_last_hook_off_;
|
||||
bool pulse_collecting_;
|
||||
uint8_t pulse_count_;
|
||||
uint32_t last_hook_edge_ms_;
|
||||
uint32_t last_pulse_ms_;
|
||||
uint32_t dtmf_capture_start_ms_;
|
||||
uint32_t next_dtmf_read_ms_;
|
||||
uint32_t off_hook_enter_ms_;
|
||||
uint32_t last_pulse_edge_ms_;
|
||||
bool suppress_dial_tone_;
|
||||
bool dialing_started_;
|
||||
uint8_t dial_source_;
|
||||
String dial_buffer_;
|
||||
uint32_t last_digit_ms_;
|
||||
String last_dial_error_;
|
||||
const char* message_path_;
|
||||
};
|
||||
|
||||
|
||||
+386
-266
@@ -1,40 +1,34 @@
|
||||
#include "web/WebServerManager.h"
|
||||
|
||||
#include <Preferences.h>
|
||||
#include <SPIFFS.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
constexpr size_t kMaxContacts = 200;
|
||||
constexpr bool kForceAuthDisabled = true;
|
||||
constexpr bool kEnableRealtimeEvents = true;
|
||||
|
||||
String quoteArg(const String& value) {
|
||||
String escaped = value;
|
||||
escaped.replace("\\", "\\\\");
|
||||
escaped.replace("\"", "\\\"");
|
||||
return String("\"") + escaped + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
WebServerManager::WebServerManager(uint16_t port)
|
||||
: server_(port),
|
||||
rate_limit_ms_(1000),
|
||||
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"),
|
||||
config_param1_("valeur1"),
|
||||
config_param2_("valeur2") {}
|
||||
auth_pass_("admin") {}
|
||||
|
||||
void WebServerManager::begin() {
|
||||
loadAuthCredentials();
|
||||
|
||||
if (contacts_.empty()) {
|
||||
contacts_ = {
|
||||
{"Alice", "+33612345678", "mobile"},
|
||||
{"Bob", "+33123456789", "fixe"},
|
||||
};
|
||||
}
|
||||
|
||||
if (!SPIFFS.begin(true)) {
|
||||
Serial.println("[WebServerManager] SPIFFS mount failed");
|
||||
server_.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
|
||||
request->send(503, "text/plain", "SPIFFS unavailable");
|
||||
});
|
||||
} else {
|
||||
server_.serveStatic("/", SPIFFS, "/webui/").setDefaultFile("index.html");
|
||||
}
|
||||
@@ -45,18 +39,21 @@ 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) {
|
||||
(void)persist_to_nvs;
|
||||
if (!isValidInput(user, 32) || !isValidInput(pass, 64)) {
|
||||
return;
|
||||
}
|
||||
auth_user_ = user;
|
||||
auth_pass_ = pass;
|
||||
if (persist_to_nvs) {
|
||||
persistAuthCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
void WebServerManager::setAuthEnabled(bool enabled) {
|
||||
@@ -72,242 +69,281 @@ bool WebServerManager::isAuthEnabled() const {
|
||||
}
|
||||
|
||||
void WebServerManager::setRateLimitMs(uint32_t rate_limit_ms) {
|
||||
rate_limit_ms_ = std::max<uint32_t>(100, rate_limit_ms);
|
||||
}
|
||||
|
||||
void WebServerManager::setControlCallback(
|
||||
std::function<bool(const String&, const JsonVariantConst&)> callback) {
|
||||
control_callback_ = std::move(callback);
|
||||
rate_limit_ms_ = rate_limit_ms;
|
||||
}
|
||||
|
||||
void WebServerManager::setStatusCallback(std::function<void(JsonObject)> callback) {
|
||||
status_callback_ = std::move(callback);
|
||||
}
|
||||
|
||||
void WebServerManager::setCommandExecutor(std::function<DispatchResponse(const String&)> callback) {
|
||||
command_executor_ = std::move(callback);
|
||||
}
|
||||
|
||||
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) {
|
||||
DynamicJsonDocument doc(768);
|
||||
doc["state"] = "running";
|
||||
doc["battery"] = 90;
|
||||
doc["audio"] = "ok";
|
||||
doc["slic"] = "ok";
|
||||
doc["bluetooth"] = "ok";
|
||||
doc["wifi"] = "ok";
|
||||
doc["auth_enabled"] = isAuthEnabled();
|
||||
doc["auth_forced_disabled"] = kForceAuthDisabled;
|
||||
|
||||
if (status_callback_) {
|
||||
JsonObject payload = doc.as<JsonObject>();
|
||||
status_callback_(payload);
|
||||
}
|
||||
|
||||
request->send(200, "application/json", toJsonString(doc));
|
||||
});
|
||||
|
||||
server_.on("/api/contacts", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
||||
DynamicJsonDocument doc(4096);
|
||||
JsonArray contacts = doc.to<JsonArray>();
|
||||
for (const auto& contact : contacts_) {
|
||||
JsonObject item = contacts.add<JsonObject>();
|
||||
item["nom"] = contact.nom;
|
||||
item["numero"] = contact.numero;
|
||||
item["type"] = contact.type;
|
||||
}
|
||||
request->send(200, "application/json", toJsonString(doc));
|
||||
});
|
||||
|
||||
server_.on("/api/contacts", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
DynamicJsonDocument doc(512);
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
bool ready = false;
|
||||
const String cached = snapshotStatusCache(&ready);
|
||||
if (ready) {
|
||||
request->send(200, "application/json", cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const String nom = doc["nom"] | "";
|
||||
const String numero = doc["numero"] | "";
|
||||
const String type = doc["type"] | "mobile";
|
||||
if (!isValidInput(nom, 64) || !isValidInput(numero, 32) || !isValidInput(type, 16) ||
|
||||
contacts_.size() >= kMaxContacts) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid contact\"}");
|
||||
return;
|
||||
}
|
||||
contacts_.push_back({nom, numero, type});
|
||||
request->send(200, "application/json", "{\"result\":\"ajoute\"}");
|
||||
});
|
||||
|
||||
server_.on("/api/contacts", HTTP_PUT, [this](AsyncWebServerRequest* request) {
|
||||
DynamicJsonDocument doc(512);
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
const int index = doc["idx"] | -1;
|
||||
if (index < 0 || static_cast<size_t>(index) >= contacts_.size()) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid index\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
const String nom = doc["nom"] | "";
|
||||
const String numero = doc["numero"] | "";
|
||||
const String type = doc["type"] | "";
|
||||
if (!isValidInput(nom, 64) || !isValidInput(numero, 32) || !isValidInput(type, 16)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid contact\"}");
|
||||
return;
|
||||
}
|
||||
contacts_[index] = {nom, numero, type};
|
||||
request->send(200, "application/json", "{\"result\":\"modifie\"}");
|
||||
});
|
||||
|
||||
server_.on("/api/contacts", HTTP_DELETE, [this](AsyncWebServerRequest* request) {
|
||||
DynamicJsonDocument doc(256);
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const int index = doc["idx"] | -1;
|
||||
if (index < 0 || static_cast<size_t>(index) >= contacts_.size()) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid index\"}");
|
||||
return;
|
||||
}
|
||||
contacts_.erase(contacts_.begin() + index);
|
||||
request->send(200, "application/json", "{\"result\":\"supprime\"}");
|
||||
});
|
||||
|
||||
server_.on("/api/contacts/sync_ble", HTTP_POST, [](AsyncWebServerRequest* request) {
|
||||
request->send(200, "application/json", "{\"result\":\"sync BLE en preparation\"}");
|
||||
});
|
||||
|
||||
server_.on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
||||
const String ip = request->client()->remoteIP().toString();
|
||||
if (isRateLimited(ip)) {
|
||||
request->send(429, "application/json", "{\"error\":\"rate limited\"}");
|
||||
return;
|
||||
}
|
||||
if (!authenticateRequest(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DynamicJsonDocument doc(384);
|
||||
doc["param1"] = config_param1_;
|
||||
doc["param2"] = config_param2_;
|
||||
doc["auth_enabled"] = isAuthEnabled();
|
||||
doc["auth_forced_disabled"] = kForceAuthDisabled;
|
||||
request->send(200, "application/json", toJsonString(doc));
|
||||
});
|
||||
|
||||
server_.on("/api/config", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
const String ip = request->client()->remoteIP().toString();
|
||||
if (isRateLimited(ip)) {
|
||||
request->send(429, "application/json", "{\"error\":\"rate limited\"}");
|
||||
return;
|
||||
}
|
||||
if (!authenticateRequest(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DynamicJsonDocument doc(512);
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
const String p1 = doc["param1"] | config_param1_;
|
||||
const String p2 = doc["param2"] | config_param2_;
|
||||
if (!isValidInput(p1, 64) || !isValidInput(p2, 64)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid input\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
config_param1_ = p1;
|
||||
config_param2_ = p2;
|
||||
|
||||
const String new_user = doc["auth_user"] | "";
|
||||
const String new_pass = doc["auth_pass"] | "";
|
||||
if (new_user.length() > 0 && new_pass.length() > 0) {
|
||||
setAuthCredentials(new_user, new_pass, true);
|
||||
}
|
||||
if (!kForceAuthDisabled && doc["auth_enabled"].is<bool>()) {
|
||||
setAuthEnabled(doc["auth_enabled"].as<bool>());
|
||||
}
|
||||
|
||||
request->send(200, "application/json", "{\"result\":\"ok\"}");
|
||||
});
|
||||
|
||||
server_.on("/api/logs", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
||||
const String ip = request->client()->remoteIP().toString();
|
||||
if (isRateLimited(ip)) {
|
||||
request->send(429, "application/json", "{\"error\":\"rate limited\"}");
|
||||
return;
|
||||
}
|
||||
if (!authenticateRequest(request)) {
|
||||
return;
|
||||
}
|
||||
String logs = "Log systeme RTC_BL_PHONE\n";
|
||||
logs += "Batterie OK\nAudio OK\nSLIC OK\nBluetooth OK\nWiFi OK\n";
|
||||
request->send(200, "text/plain", logs);
|
||||
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) {
|
||||
const String ip = request->client()->remoteIP().toString();
|
||||
if (isRateLimited(ip)) {
|
||||
request->send(429, "application/json", "{\"error\":\"rate limited\"}");
|
||||
return;
|
||||
}
|
||||
if (!authenticateRequest(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DynamicJsonDocument doc(512);
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
const String action = doc["action"] | "";
|
||||
if (!isValidInput(action, 32)) {
|
||||
if (!isValidInput(action, 128)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid action\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
bool handled = false;
|
||||
if (control_callback_) {
|
||||
handled = control_callback_(action, doc.as<JsonVariantConst>());
|
||||
}
|
||||
if (!handled && action == "call") {
|
||||
handled = true;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
request->send(200, "application/json", "{\"result\":\"action executee\"}");
|
||||
} else {
|
||||
request->send(400, "application/json", "{\"error\":\"unknown action\"}");
|
||||
}
|
||||
handleDispatch(request, action);
|
||||
});
|
||||
}
|
||||
|
||||
bool WebServerManager::isRateLimited(const String& ip) {
|
||||
const uint32_t now = millis();
|
||||
const auto it = last_request_ms_.find(ip);
|
||||
if (it != last_request_ms_.end() && now - it->second < rate_limit_ms_) {
|
||||
return true;
|
||||
}
|
||||
last_request_ms_[ip] = now;
|
||||
|
||||
if (last_request_ms_.size() > 128) {
|
||||
for (auto iter = last_request_ms_.begin(); iter != last_request_ms_.end();) {
|
||||
if (now - iter->second > 60000) {
|
||||
iter = last_request_ms_.erase(iter);
|
||||
} else {
|
||||
++iter;
|
||||
}
|
||||
// A252 config endpoints.
|
||||
server_.on("/api/config/pins", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "SLIC_CONFIG_GET"); });
|
||||
server_.on("/api/config/pins", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
handleDispatch(request, "SLIC_CONFIG_SET " + payload);
|
||||
});
|
||||
|
||||
server_.on("/api/config/audio", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "AUDIO_CONFIG_GET"); });
|
||||
server_.on("/api/config/audio", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
handleDispatch(request, "AUDIO_CONFIG_SET " + payload);
|
||||
});
|
||||
|
||||
server_.on("/api/config/mqtt", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "MQTT_STATUS"); });
|
||||
server_.on("/api/config/mqtt", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
handleDispatch(request, "MQTT_CONFIG_SET " + payload);
|
||||
});
|
||||
|
||||
// WiFi.
|
||||
server_.on("/api/network/wifi", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_STATUS"); });
|
||||
server_.on("/api/network/wifi/connect", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String ssid = doc["ssid"] | "";
|
||||
const String pass = doc["pass"] | "";
|
||||
if (!isValidInput(ssid, 64)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid ssid\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, "WIFI_CONNECT " + quoteArg(ssid) + " " + quoteArg(pass));
|
||||
});
|
||||
server_.on("/api/network/wifi/disconnect", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_DISCONNECT"); });
|
||||
server_.on("/api/network/wifi/reconnect", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_RECONNECT"); });
|
||||
server_.on("/api/network/wifi/scan", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_SCAN"); });
|
||||
|
||||
// MQTT.
|
||||
server_.on("/api/network/mqtt", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "MQTT_STATUS"); });
|
||||
server_.on("/api/network/mqtt/connect", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "MQTT_CONNECT"); });
|
||||
server_.on("/api/network/mqtt/disconnect", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "MQTT_DISCONNECT"); });
|
||||
server_.on("/api/network/mqtt/publish", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String topic = doc["topic"] | "";
|
||||
const String payload = doc["payload"] | "";
|
||||
if (!isValidInput(topic, 128)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid topic\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, "MQTT_PUB " + topic + " " + payload);
|
||||
});
|
||||
|
||||
// ESP-NOW.
|
||||
server_.on("/api/network/espnow", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_STATUS"); });
|
||||
server_.on("/api/network/espnow/on", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_ON"); });
|
||||
server_.on("/api/network/espnow/off", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_OFF"); });
|
||||
server_.on("/api/network/espnow/peer", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_PEER_LIST"); });
|
||||
server_.on("/api/network/espnow/peer", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String mac = doc["mac"] | "";
|
||||
if (!isValidInput(mac, 32)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, "ESPNOW_PEER_ADD " + mac);
|
||||
});
|
||||
server_.on("/api/network/espnow/peer", HTTP_DELETE, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String mac = doc["mac"] | "";
|
||||
if (!isValidInput(mac, 32)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, "ESPNOW_PEER_DEL " + mac);
|
||||
});
|
||||
server_.on("/api/network/espnow/send", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String mac = doc["mac"] | "broadcast";
|
||||
String 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 {
|
||||
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);
|
||||
});
|
||||
|
||||
// Bluetooth.
|
||||
server_.on("/api/bluetooth", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_STATUS"); });
|
||||
server_.on("/api/bluetooth/hfp/connect", HTTP_POST, [this](AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
if (!extractJsonBody(request, doc)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
|
||||
return;
|
||||
}
|
||||
const String addr = doc["addr"] | "";
|
||||
if (!isValidInput(addr, 32)) {
|
||||
request->send(400, "application/json", "{\"error\":\"invalid addr\"}");
|
||||
return;
|
||||
}
|
||||
handleDispatch(request, "BT_HFP_CONNECT " + addr);
|
||||
});
|
||||
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,
|
||||
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_BLE_STOP"); });
|
||||
}
|
||||
|
||||
bool WebServerManager::authenticateRequest(AsyncWebServerRequest* request) {
|
||||
if (!isAuthEnabled()) {
|
||||
bool WebServerManager::authenticateRequest(AsyncWebServerRequest* request) const {
|
||||
if (kForceAuthDisabled || !auth_enabled_) {
|
||||
return true;
|
||||
}
|
||||
if (!request->authenticate(auth_user_.c_str(), auth_pass_.c_str())) {
|
||||
@@ -317,12 +353,18 @@ bool WebServerManager::authenticateRequest(AsyncWebServerRequest* request) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebServerManager::extractJsonBody(AsyncWebServerRequest* request, DynamicJsonDocument& doc) {
|
||||
if (!request->hasParam("plain", true)) {
|
||||
return false;
|
||||
bool WebServerManager::extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc) {
|
||||
if (request->hasParam("plain", true)) {
|
||||
const String body = request->getParam("plain", true)->value();
|
||||
return deserializeJson(doc, body) == DeserializationError::Ok;
|
||||
}
|
||||
const String body = request->getParam("plain", true)->value();
|
||||
return deserializeJson(doc, body) == DeserializationError::Ok;
|
||||
return false;
|
||||
}
|
||||
|
||||
String WebServerManager::toJsonString(const JsonDocument& doc) {
|
||||
String out;
|
||||
serializeJson(doc, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool WebServerManager::isValidInput(const String& value, size_t max_len) {
|
||||
@@ -334,46 +376,124 @@ bool WebServerManager::isValidInput(const String& value, size_t max_len) {
|
||||
if (c < 32 || c > 126) {
|
||||
return false;
|
||||
}
|
||||
if (c == '<' || c == '>' || c == '\\' || c == '`') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String WebServerManager::toJsonString(const JsonDocument& doc) {
|
||||
String out;
|
||||
serializeJson(doc, out);
|
||||
return out;
|
||||
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::loadAuthCredentials() {
|
||||
auth_enabled_ = false;
|
||||
if (kForceAuthDisabled) {
|
||||
void WebServerManager::refreshStatusCache() {
|
||||
if (!status_callback_) {
|
||||
portENTER_CRITICAL(&status_cache_mux_);
|
||||
status_cache_ready_ = false;
|
||||
status_cache_json_ = "";
|
||||
portEXIT_CRITICAL(&status_cache_mux_);
|
||||
return;
|
||||
}
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin("web-auth", true)) {
|
||||
return;
|
||||
}
|
||||
auth_user_ = prefs.getString("user", auth_user_);
|
||||
auth_pass_ = prefs.getString("pass", auth_pass_);
|
||||
auth_enabled_ = prefs.getBool("enabled", auth_enabled_);
|
||||
prefs.end();
|
||||
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_);
|
||||
}
|
||||
|
||||
void WebServerManager::persistAuthCredentials() const {
|
||||
if (kForceAuthDisabled) {
|
||||
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,
|
||||
uint16_t error_code) {
|
||||
if (!authenticateRequest(request)) {
|
||||
return;
|
||||
}
|
||||
if (!command_executor_) {
|
||||
request->send(500, "application/json", "{\"error\":\"command executor not configured\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
Preferences prefs;
|
||||
if (!prefs.begin("web-auth", false)) {
|
||||
return;
|
||||
const DispatchResponse res = command_executor_(command_line);
|
||||
|
||||
if (!res.json.isEmpty()) {
|
||||
request->send(res.ok ? success_code : error_code, "application/json", res.json);
|
||||
} 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));
|
||||
}
|
||||
prefs.putString("user", auth_user_);
|
||||
prefs.putString("pass", auth_pass_);
|
||||
prefs.putBool("enabled", auth_enabled_);
|
||||
prefs.end();
|
||||
|
||||
publishDispatchEvent(command_line, res);
|
||||
}
|
||||
|
||||
+22
-19
@@ -4,16 +4,11 @@
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
struct WebContact {
|
||||
String nom;
|
||||
String numero;
|
||||
String type;
|
||||
};
|
||||
#include "core/CommandDispatcher.h"
|
||||
|
||||
class WebServerManager {
|
||||
public:
|
||||
@@ -26,30 +21,38 @@ public:
|
||||
bool isAuthEnabled() const;
|
||||
void setRateLimitMs(uint32_t rate_limit_ms);
|
||||
|
||||
void setControlCallback(std::function<bool(const String&, const JsonVariantConst&)> callback);
|
||||
void setStatusCallback(std::function<void(JsonObject)> callback);
|
||||
void setCommandExecutor(std::function<DispatchResponse(const String&)> callback);
|
||||
|
||||
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_;
|
||||
String config_param1_;
|
||||
String config_param2_;
|
||||
std::vector<WebContact> contacts_;
|
||||
std::map<String, uint32_t> last_request_ms_;
|
||||
std::function<bool(const String&, const JsonVariantConst&)> control_callback_;
|
||||
std::function<void(JsonObject)> status_callback_;
|
||||
std::function<DispatchResponse(const String&)> command_executor_;
|
||||
|
||||
void registerRoutes();
|
||||
bool isRateLimited(const String& ip);
|
||||
bool authenticateRequest(AsyncWebServerRequest* request);
|
||||
static bool extractJsonBody(AsyncWebServerRequest* request, DynamicJsonDocument& doc);
|
||||
static bool isValidInput(const String& value, size_t max_len);
|
||||
bool authenticateRequest(AsyncWebServerRequest* request) const;
|
||||
static bool extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc);
|
||||
static String toJsonString(const JsonDocument& doc);
|
||||
void loadAuthCredentials();
|
||||
void persistAuthCredentials() const;
|
||||
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,
|
||||
uint16_t error_code = 400);
|
||||
};
|
||||
|
||||
#endif // WEB_WEB_SERVER_MANAGER_H
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#ifndef WIFI_CREDENTIALS_STORAGE_H
|
||||
#define WIFI_CREDENTIALS_STORAGE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
class WifiCredentialsStorage {
|
||||
public:
|
||||
static bool load(String& ssid, String& password) {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin("wifi-creds", false)) return false;
|
||||
ssid = prefs.isKey("ssid") ? prefs.getString("ssid", "") : String("");
|
||||
password = prefs.isKey("password") ? prefs.getString("password", "") : String("");
|
||||
prefs.end();
|
||||
return !ssid.isEmpty();
|
||||
}
|
||||
|
||||
static void save(const String& ssid, const String& password) {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin("wifi-creds", false)) return;
|
||||
prefs.putString("ssid", ssid);
|
||||
prefs.putString("password", password);
|
||||
prefs.end();
|
||||
}
|
||||
};
|
||||
|
||||
#endif // WIFI_CREDENTIALS_STORAGE_H
|
||||
+298
-9
@@ -1,29 +1,318 @@
|
||||
#include "wifi/WifiManager.h"
|
||||
|
||||
WifiManager::WifiManager() : connected_(false), ssid_("") {}
|
||||
#include "core/AgentSupervisor.h"
|
||||
#include "wifi/WifiCredentialsStorage.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <esp_wifi.h>
|
||||
|
||||
namespace {
|
||||
constexpr char kFallbackApPrefix[] = "RTC_BL_A252";
|
||||
constexpr char kFallbackApPassword[] = "rtcblphone";
|
||||
constexpr uint8_t kFallbackApChannel = 6;
|
||||
constexpr uint8_t kFallbackApMaxConnections = 4;
|
||||
|
||||
void notifyWifi(const std::string& state, const std::string& error = "") {
|
||||
AgentStatus status{state, error, millis()};
|
||||
AgentSupervisor::instance().notify("wifi", status);
|
||||
}
|
||||
|
||||
String wifiStateToString(wl_status_t status) {
|
||||
switch (status) {
|
||||
case WL_CONNECTED:
|
||||
return "connected";
|
||||
case WL_IDLE_STATUS:
|
||||
return "idle";
|
||||
case WL_NO_SSID_AVAIL:
|
||||
return "no_ssid";
|
||||
case WL_SCAN_COMPLETED:
|
||||
return "scan_completed";
|
||||
case WL_CONNECT_FAILED:
|
||||
return "connect_failed";
|
||||
case WL_CONNECTION_LOST:
|
||||
return "connection_lost";
|
||||
case WL_DISCONNECTED:
|
||||
return "disconnected";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
String wifiModeToString(wifi_mode_t mode) {
|
||||
switch (mode) {
|
||||
case WIFI_MODE_NULL:
|
||||
return "null";
|
||||
case WIFI_MODE_STA:
|
||||
return "sta";
|
||||
case WIFI_MODE_AP:
|
||||
return "ap";
|
||||
case WIFI_MODE_APSTA:
|
||||
return "ap_sta";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
String buildFallbackApSsid() {
|
||||
const uint64_t chip_id = ESP.getEfuseMac();
|
||||
const unsigned long suffix = static_cast<unsigned long>(chip_id & 0xFFFFFFULL);
|
||||
char name[32];
|
||||
snprintf(name, sizeof(name), "%s_%06lX", kFallbackApPrefix, suffix);
|
||||
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()
|
||||
: connected_(false),
|
||||
ssid_(""),
|
||||
password_(""),
|
||||
ap_active_(false),
|
||||
ap_ssid_(buildFallbackApSsid()),
|
||||
ap_password_(kFallbackApPassword),
|
||||
next_auto_reconnect_ms_(0),
|
||||
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) {
|
||||
if (ssid == nullptr || ssid[0] == '\0') {
|
||||
return connect(ssid ? String(ssid) : "", password ? String(password) : "", timeout_ms, true);
|
||||
}
|
||||
|
||||
bool WifiManager::connect(const String& ssid, const String& password, uint32_t timeout_ms, bool persist) {
|
||||
if (ssid.isEmpty()) {
|
||||
connected_ = false;
|
||||
notifyWifi("init_failed", "no_ssid");
|
||||
startFallbackAp();
|
||||
return false;
|
||||
}
|
||||
|
||||
ssid_ = ssid;
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid, password);
|
||||
password_ = password;
|
||||
|
||||
const uint32_t start_ms = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && (millis() - start_ms) < timeout_ms) {
|
||||
delay(100);
|
||||
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_);
|
||||
}
|
||||
notifyWifi("connected");
|
||||
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_ = 0;
|
||||
startFallbackAp();
|
||||
}
|
||||
connected_ = WiFi.status() == WL_CONNECTED;
|
||||
return connected_;
|
||||
}
|
||||
|
||||
bool WifiManager::reconnect(uint32_t timeout_ms) {
|
||||
if (ssid_.isEmpty()) {
|
||||
String ssid;
|
||||
String password;
|
||||
if (!WifiCredentialsStorage::load(ssid, password)) {
|
||||
notifyWifi("reconnect_failed", "no_credentials");
|
||||
return false;
|
||||
}
|
||||
ssid_ = ssid;
|
||||
password_ = password;
|
||||
}
|
||||
return connect(ssid_, password_, timeout_ms, false);
|
||||
}
|
||||
|
||||
void WifiManager::disconnect(bool erase_credentials) {
|
||||
WiFi.disconnect(true, false);
|
||||
connected_ = false;
|
||||
next_auto_reconnect_ms_ = 0;
|
||||
if (erase_credentials) {
|
||||
WifiCredentialsStorage::save("", "");
|
||||
ssid_ = "";
|
||||
password_ = "";
|
||||
}
|
||||
startFallbackAp();
|
||||
notifyWifi("disconnected");
|
||||
}
|
||||
|
||||
void WifiManager::loop() {
|
||||
connected_ = WiFi.status() == WL_CONNECTED;
|
||||
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;
|
||||
stopFallbackAp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ap_active_) {
|
||||
startFallbackAp();
|
||||
}
|
||||
|
||||
// Manual reconnect only (WIFI_RECONNECT command).
|
||||
}
|
||||
|
||||
void WifiManager::ensureFallbackAp() {
|
||||
startFallbackAp();
|
||||
}
|
||||
|
||||
bool WifiManager::isConnected() const {
|
||||
return connected_;
|
||||
}
|
||||
|
||||
bool WifiManager::hasCredentials() const {
|
||||
if (!ssid_.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
String ssid;
|
||||
String password;
|
||||
return WifiCredentialsStorage::load(ssid, password);
|
||||
}
|
||||
|
||||
WifiStatusSnapshot WifiManager::status() const {
|
||||
WifiStatusSnapshot snap;
|
||||
const bool connected = (WiFi.status() == WL_CONNECTED);
|
||||
snap.connected = connected;
|
||||
snap.has_credentials = hasCredentials();
|
||||
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");
|
||||
snap.mode = wifiModeToString(WiFi.getMode());
|
||||
if (connected) {
|
||||
snap.state = "connected";
|
||||
} else if (snap.ap_active) {
|
||||
snap.state = "ap_fallback";
|
||||
} else {
|
||||
snap.state = wifiStateToString(WiFi.status());
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
void WifiManager::statusToJson(JsonObject obj) const {
|
||||
const WifiStatusSnapshot snap = status();
|
||||
obj["connected"] = snap.connected;
|
||||
obj["has_credentials"] = snap.has_credentials;
|
||||
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;
|
||||
obj["ap_ip"] = snap.ap_ip;
|
||||
obj["mode"] = snap.mode;
|
||||
}
|
||||
|
||||
void WifiManager::scanToJson(JsonArray arr, int max_networks) const {
|
||||
const int count = WiFi.scanNetworks(
|
||||
/*async=*/false,
|
||||
/*show_hidden=*/false,
|
||||
/*passive=*/false,
|
||||
/*max_ms_per_chan=*/80);
|
||||
const int limit = (max_networks > 0) ? max_networks : 20;
|
||||
for (int i = 0; i < count && i < limit; ++i) {
|
||||
JsonObject item = arr.add<JsonObject>();
|
||||
item["ssid"] = WiFi.SSID(i);
|
||||
item["rssi"] = WiFi.RSSI(i);
|
||||
item["chan"] = WiFi.channel(i);
|
||||
item["enc"] = static_cast<int>(WiFi.encryptionType(i));
|
||||
}
|
||||
WiFi.scanDelete();
|
||||
}
|
||||
|
||||
bool WifiManager::waitForConnection(uint32_t timeout_ms) {
|
||||
const uint32_t start_ms = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && (millis() - start_ms) < timeout_ms) {
|
||||
delay(100);
|
||||
}
|
||||
return WiFi.status() == WL_CONNECTED;
|
||||
}
|
||||
|
||||
bool WifiManager::startFallbackAp() {
|
||||
if (ap_active_) {
|
||||
return true;
|
||||
}
|
||||
if (ap_ssid_.isEmpty()) {
|
||||
ap_ssid_ = buildFallbackApSsid();
|
||||
}
|
||||
if (ap_password_.isEmpty()) {
|
||||
ap_password_ = kFallbackApPassword;
|
||||
}
|
||||
|
||||
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) {
|
||||
Serial.printf("[WifiManager] fallback AP active: ssid=%s ip=%s\n",
|
||||
ap_ssid_.c_str(),
|
||||
WiFi.softAPIP().toString().c_str());
|
||||
notifyWifi("ap_active");
|
||||
} else {
|
||||
notifyWifi("ap_failed");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void WifiManager::stopFallbackAp() {
|
||||
if (!ap_active_) {
|
||||
return;
|
||||
}
|
||||
WiFi.softAPdisconnect(true);
|
||||
ap_active_ = false;
|
||||
notifyWifi("ap_stopped");
|
||||
}
|
||||
|
||||
+41
-4
@@ -1,21 +1,58 @@
|
||||
// WifiManager.h
|
||||
// Gestion WiFi (connexion, OTA, logs)
|
||||
|
||||
#ifndef WIFIMANAGER_H
|
||||
#define WIFIMANAGER_H
|
||||
|
||||
#include <WiFi.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
struct WifiStatusSnapshot {
|
||||
bool connected = false;
|
||||
bool has_credentials = false;
|
||||
String ssid;
|
||||
String ip;
|
||||
int32_t rssi = 0;
|
||||
int32_t channel = 0;
|
||||
String bssid;
|
||||
String state;
|
||||
bool ap_active = false;
|
||||
String ap_ssid;
|
||||
String ap_ip;
|
||||
String mode;
|
||||
};
|
||||
|
||||
class WifiManager {
|
||||
public:
|
||||
WifiManager();
|
||||
|
||||
bool begin(const char* ssid, const char* password, uint32_t timeout_ms = 10000);
|
||||
bool connect(const String& ssid, const String& password, uint32_t timeout_ms = 10000,
|
||||
bool persist = true);
|
||||
bool reconnect(uint32_t timeout_ms = 10000);
|
||||
void disconnect(bool erase_credentials = false);
|
||||
void loop();
|
||||
void ensureFallbackAp();
|
||||
|
||||
bool isConnected() const;
|
||||
bool hasCredentials() const;
|
||||
WifiStatusSnapshot status() const;
|
||||
void statusToJson(JsonObject obj) const;
|
||||
void scanToJson(JsonArray arr, int max_networks = 20) const;
|
||||
|
||||
private:
|
||||
void enforceCoexPolicy() const;
|
||||
|
||||
bool connected_;
|
||||
String ssid_;
|
||||
String password_;
|
||||
bool ap_active_;
|
||||
String ap_ssid_;
|
||||
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();
|
||||
bool waitForConnection(uint32_t timeout_ms);
|
||||
};
|
||||
|
||||
#endif // WIFIMANAGER_H
|
||||
#endif // WIFIMANAGER_H
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user