diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e302e7d..182aee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [main, release/stable] jobs: - pre-merge: + branch-gate: runs-on: ubuntu-latest steps: - name: Checkout @@ -23,8 +23,8 @@ jobs: python -m pip install --upgrade pip pip install platformio - - name: Run pre-merge gate - run: ./scripts/pre_merge.sh + - name: Run branch gate + run: ./scripts/branch_gate.sh --profile a252 - name: Upload route parity report if: always() diff --git a/docs/a252_strict_gate_summary.md b/docs/a252_strict_gate_summary.md new file mode 100644 index 0000000..2bac69b --- /dev/null +++ b/docs/a252_strict_gate_summary.md @@ -0,0 +1,37 @@ +# A252 Strict Gate Summary + +- Date UTC: 2026-02-25T13:33:04.090306+00:00 +- Verdict global: PASS +- Port A252: `/dev/cu.usbserial-0001` +- ZeroClaw: `http://127.0.0.1:8788` + +## Étapes + +| Étape | État | +|---|---| +| zeroclaw_hw_preflight | PASS | +| zeroclaw_orchestrator_health | PASS | +| branch_gate_profile_a252 | PASS | +| hw_validation_a252 | PASS | + +## Stacks A252 (hw_validation) + +| Stack/Scénario | État | +|---|---| +| serial_smoke | PASS | +| serial_hook_ring_audio | PASS | +| serial_network_stack | PASS | +| http_endpoints | PASS | +| manual_hook_transition | MANUAL_SKIP | +| manual_ring_behavior | MANUAL_SKIP | +| manual_audio_path | MANUAL_SKIP | +| manual_hfp_pairing | MANUAL_SKIP | + +## ZeroClaw Docker + +| Check | État | +|---|---| +| status | PASS | +| agents | PASS | +| workflows | PASS | +| provider_scan | PASS | diff --git a/docs/branch_quality_gate.md b/docs/branch_quality_gate.md index c9675c3..d11b93f 100644 --- a/docs/branch_quality_gate.md +++ b/docs/branch_quality_gate.md @@ -4,29 +4,34 @@ Ce document décrit l’ordonnancement unique des contrôles de conformité avan ## Ordre d’exécution -Le script `scripts/pre_merge.sh` exécute les contrôles suivants, dans l’ordre : +Le script `scripts/branch_gate.sh` exécute les contrôles suivants, dans l’ordre : 1. `python3 -m py_compile scripts/hw_validation.py` 2. `platformio test --without-uploading --without-testing -e esp32dev` 3. Compilation + exécution du test hôte DTMF: - `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` -4. `python3 -m unittest scripts/test_check_web_route_parity.py scripts/test_runtime_contracts.py` +4. `python3 -m unittest scripts/test_check_web_route_parity.py scripts/test_runtime_contracts.py scripts/test_hw_validation_contracts.py` 5. `python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json` -6. Build PlatformIO des cibles (par défaut) : - - `esp32dev` - - `esp32-s3-devkitc-1` - - `esp32-s3-usb-host` - - `esp32-s3-usb-msc` +6. Build PlatformIO des cibles selon le profil : + - Profil `a252` (défaut): `esp32dev` + - Profil `full`: `esp32dev`, `esp32-s3-devkitc-1`, `esp32-s3-usb-host`, `esp32-s3-usb-msc` + +## Contrat de cette branche + +- **ESP-NOW obligatoire** : seule la pile de transport activée est prise en compte pour les scénarios de validation. +- **Bluetooth retiré** : aucun endpoint/commande Bluetooth n’est attendu, ni traité. +- **Auth web Wi-Fi désactivée** : la validation considère les endpoints web accessibles sans authentification basique. ## Cible CI -- `.github/workflows/ci.yml` lance directement `scripts/pre_merge.sh`. +- `.github/workflows/ci.yml` lance `scripts/branch_gate.sh --profile a252`. - L’artefact `artifacts/route_parity_report.json` est uploadé automatiquement. ## Options utiles - `--skip-builds` : ignore uniquement la phase build. +- `--profile ` : sélectionne le profil de build par défaut. - `--build-env ` : ajoute un env PlatformIO spécifique. - `--build-envs ` : ajoute plusieurs envs. - `--report-json ` : redirige le rapport parity JSON. @@ -34,6 +39,17 @@ Le script `scripts/pre_merge.sh` exécute les contrôles suivants, dans l’ordr Exemples : ```bash -bash scripts/pre_merge.sh --skip-builds -bash scripts/pre_merge.sh --build-env esp32dev --build-env esp32-s3-devkitc-1 +bash scripts/branch_gate.sh --skip-builds +bash scripts/branch_gate.sh --profile a252 +bash scripts/branch_gate.sh --profile full +bash scripts/branch_gate.sh --build-env esp32dev --build-env esp32-s3-devkitc-1 ``` + +## Profil standard + +- Le profil standard de cette branche est `a252` (A252 strict). +- Le profil `full` reste disponible pour les campagnes de compatibilité multi-cartes. + +## Redirection historique + +- `docs/pre_merge_checks.md` est conservé comme redirection vers ce document. diff --git a/docs/hw_validation_a252_report.md b/docs/hw_validation_a252_report.md new file mode 100644 index 0000000..3a4b5f6 --- /dev/null +++ b/docs/hw_validation_a252_report.md @@ -0,0 +1,15 @@ +# Rapport validation HW (A252) + +- Date UTC: 2026-02-25T13:33:03.932349+00:00 +- Verdict global: PASS + +| Scénario | État | Détails | +|---|---|---| +| serial_smoke | PASS | `{"ping": {"ok": true, "result": "PONG"}, "status": {"board_profile": "ESP32_A252", "active_scene": "", "telephony": {"state": "IDLE", "hook": "ON_HOOK", "pending_espnow_call": false, "pending_espnow_call_audio": ""}, "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, "ready": true, "dial_tone_active": false, "playing": false, "sd_ready": false, "frames": 0, "underrun": 0, "drop": 0, "latence_ms": 0, "adc_fft_peak_bin": 0, "adc_fft_peak_freq_hz": 0, "adc_fft_peak_mag": 0}, "scope_display": {"supported": false, "enabled": false, "frequency": 1200, "amplitude": 48}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "hw": {"init_ok": true, "slic_ready": true, "codec_ready": true, "audio_ready": true}, "config": {"pins": {"i2s": {"bck": 27, "ws": 25, "dout": 26, "din": 35}, "codec_i2c": {"sda": 33, "scl": 32}, "slic": {"rm": 18, "fr": 5, "shk": 23, "line": -1, "pd": 19, "adc_in": -1, "hook_active_high": true}, "pcm": {"flt": -1, "demp": -1, "xsmt": -1, "fmt": -1}}, "audio": {"sample_rate": 16000, "bits_per_sample": 16, "enable_capture": false, "adc_dsp_enabled": true, "adc_fft_enabled": true, "adc_dsp_fft_downsample": 2, "adc_fft_ignore_low_bin": 1, "adc_fft_ignore_high_bin": 1, "volume": 100, "mute": false, "route": "rtc"}, "espnow_call_map": {}, "espnow_peers": ["10:20:BA:58:C7:48"]}}, "call": {"ok": true, "line": "OK CALL"}, "capture_start": {"ok": true, "line": "OK CAPTURE_START"}, "capture_stop": {"ok": true, "line": "OK CAPTURE_STOP"}, "reset_metrics": {"ok": true, "line": "OK RESET_METRICS"}, "required_checks": ["PING", "STATUS", "CALL", "CAPTURE_STOP", "RESET_METRICS"], "failed_checks": [], "warnings": []}` | +| serial_hook_ring_audio | PASS | `{"status_before": {"board_profile": "ESP32_A252", "active_scene": "", "telephony": {"state": "RINGING", "hook": "ON_HOOK", "pending_espnow_call": false, "pending_espnow_call_audio": ""}, "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, "ready": true, "dial_tone_active": false, "playing": false, "sd_ready": false, "frames": 0, "underrun": 0, "drop": 0, "latence_ms": 0, "adc_fft_peak_bin": 0, "adc_fft_peak_freq_hz": 0, "adc_fft_peak_mag": 0}, "scope_display": {"supported": false, "enabled": false, "frequency": 1200, "amplitude": 48}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "hw": {"init_ok": true, "slic_ready": true, "codec_ready": true, "audio_ready": true}, "config": {"pins": {"i2s": {"bck": 27, "ws": 25, "dout": 26, "din": 35}, "codec_i2c": {"sda": 33, "scl": 32}, "slic": {"rm": 18, "fr": 5, "shk": 23, "line": -1, "pd": 19, "adc_in": -1, "hook_active_high": true}, "pcm": {"flt": -1, "demp": -1, "xsmt": -1, "fmt": -1}}, "audio": {"sample_rate": 16000, "bits_per_sample": 16, "enable_capture": false, "adc_dsp_enabled": true, "adc_fft_enabled": true, "adc_dsp_fft_downsample": 2, "adc_fft_ignore_low_bin": 1, "adc_fft_ignore_high_bin": 1, "volume": 100, "mute": false, "route": "rtc"}, "espnow_call_map": {}, "espnow_peers": ["10:20:BA:58:C7:48"]}}, "ring": {"ok": true, "line": "OK RING"}, "tone_on": {"ok": true, "line": "OK TONE_ON"}, "status_tone_on": {"board_profile": "ESP32_A252", "active_scene": "", "telephony": {"state": "RINGING", "hook": "ON_HOOK", "pending_espnow_call": false, "pending_espnow_call_audio": ""}, "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, "ready": true, "dial_tone_active": true, "playing": false, "sd_ready": false, "frames": 0, "underrun": 0, "drop": 0, "latence_ms": 0, "adc_fft_peak_bin": 0, "adc_fft_peak_freq_hz": 0, "adc_fft_peak_mag": 0}, "scope_display": {"supported": false, "enabled": false, "frequency": 1200, "amplitude": 48}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "hw": {"init_ok": true, "slic_ready": true, "codec_ready": true, "audio_ready": true}, "config": {"pins": {"i2s": {"bck": 27, "ws": 25, "dout": 26, "din": 35}, "codec_i2c": {"sda": 33, "scl": 32}, "slic": {"rm": 18, "fr": 5, "shk": 23, "line": -1, "pd": 19, "adc_in": -1, "hook_active_high": true}, "pcm": {"flt": -1, "demp": -1, "xsmt": -1, "fmt": -1}}, "audio": {"sample_rate": 16000, "bits_per_sample": 16, "enable_capture": false, "adc_dsp_enabled": true, "adc_fft_enabled": true, "adc_dsp_fft_downsample": 2, "adc_fft_ignore_low_bin": 1, "adc_fft_ignore_high_bin": 1, "volume": 100, "mute": false, "route": "rtc"}, "espnow_call_map": {}, "espnow_peers": ["10:20:BA:58:C7:48"]}}, "tone_off": {"ok": true, "line": "OK TONE_OFF"}, "status_tone_off": {"board_profile": "ESP32_A252", "active_scene": "", "telephony": {"state": "RINGING", "hook": "ON_HOOK", "pending_espnow_call": false, "pending_espnow_call_audio": ""}, "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, "ready": true, "dial_tone_active": false, "playing": false, "sd_ready": false, "frames": 0, "underrun": 0, "drop": 0, "latence_ms": 0, "adc_fft_peak_bin": 0, "adc_fft_peak_freq_hz": 0, "adc_fft_peak_mag": 0}, "scope_display": {"supported": false, "enabled": false, "frequency": 1200, "amplitude": 48}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "hw": {"init_ok": true, "slic_ready": true, "codec_ready": true, "audio_ready": true}, "config": {"pins": {"i2s": {"bck": 27, "ws": 25, "dout": 26, "din": 35}, "codec_i2c": {"sda": 33, "scl": 32}, "slic": {"rm": 18, "fr": 5, "shk": 23, "line": -1, "pd": 19, "adc_in": -1, "hook_active_high": true}, "pcm": {"flt": -1, "demp": -1, "xsmt": -1, "fmt": -1}}, "audio": {"sample_rate": 16000, "bits_per_sample": 16, "enable_capture": false, "adc_dsp_enabled": true, "adc_fft_enabled": true, "adc_dsp_fft_downsample": 2, "adc_fft_ignore_low_bin": 1, "adc_fft_ignore_high_bin": 1, "volume": 100, "mute": false, "route": "rtc"}, "espnow_call_map": {}, "espnow_peers": ["10:20:BA:58:C7:48"]}}, "hook_values_observed": ["OFF_HOOK", "ON_HOOK"], "hook_observe_seconds": 45, "required_hook_states": ["ON_HOOK", "OFF_HOOK"], "checks": {"ring_ok": true, "tone_on_ok": true, "tone_active_after_on": true, "tone_off_ok": true, "tone_inactive_after_off": true, "hook_ok": true}}` | +| serial_network_stack | PASS | `{"wifi_status_before": {"connected": false, "status": 6, "ssid": "", "ip": "", "rssi": 0, "channel": 0, "mode": "APSTA"}, "wifi_scan": [{"ssid": "Freenove-Setup", "rssi": -29, "chan": 1, "enc": 0}, {"ssid": "Les cils", "rssi": -46, "chan": 11, "enc": 4}, {"ssid": "CILS House", "rssi": -64, "chan": 7, "enc": 7}, {"ssid": "Les cils", "rssi": -71, "chan": 1, "enc": 4}, {"ssid": "Les cils", "rssi": -77, "chan": 6, "enc": 3}, {"ssid": "La-clic", "rssi": -80, "chan": 1, "enc": 3}], "wifi_connect": {"ok": true, "line": "OK WIFI_CONNECT"}, "wifi_status_after": {"connected": true, "status": 3, "ssid": "Les cils", "ip": "192.168.0.103", "rssi": -67, "channel": 1, "mode": "STA"}, "espnow_status": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}}` | +| http_endpoints | PASS | `{"base_url": "http://192.168.0.103", "status": {"board_profile": "ESP32_A252", "active_scene": "", "telephony": {"state": "IDLE", "hook": "ON_HOOK", "pending_espnow_call": false, "pending_espnow_call_audio": ""}, "audio_frames_requested": 51200, "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, "ready": true, "dial_tone_active": false, "playing": false, "sd_ready": false, "frames": 0, "underrun": 0, "drop": 0, "latence_ms": 0, "adc_fft_peak_bin": 0, "adc_fft_peak_freq_hz": 0, "adc_fft_peak_mag": 0}, "scope_display": {"supported": false, "enabled": false, "frequency": 1200, "amplitude": 48}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "hw": {"init_ok": true, "slic_ready": true, "codec_ready": true, "audio_ready": true}, "config": {"pins": {"i2s": {"bck": 27, "ws": 25, "dout": 26, "din": 35}, "codec_i2c": {"sda": 33, "scl": 32}, "slic": {"rm": 18, "fr": 5, "shk": 23, "line": -1, "pd": 19, "adc_in": -1, "hook_active_high": true}, "pcm": {"flt": -1, "demp": -1, "xsmt": -1, "fmt": -1}}, "audio": {"sample_rate": 16000, "bits_per_sample": 16, "enable_capture": false, "adc_dsp_enabled": true, "adc_fft_enabled": true, "adc_dsp_fft_downsample": 2, "adc_fft_ignore_low_bin": 1, "adc_fft_ignore_high_bin": 1, "volume": 100, "mute": false, "route": "rtc"}, "espnow_call_map": {}, "espnow_peers": ["10:20:BA:58:C7:48"]}}, "wifi": {"connected": true, "status": 3, "ssid": "Les cils", "ip": "192.168.0.103", "rssi": -68, "channel": 1, "mode": "STA"}, "espnow": {"ready": true, "peer_count": 1, "tx_ok": 0, "tx_fail": 0, "rx_count": 0, "last_rx_mac": "", "last_rx_payload": "", "peers": ["10:20:BA:58:C7:48"]}, "control_call": {"ok": true, "code": "CALL"}}` | +| manual_hook_transition | MANUAL_SKIP | `{"note": "validated manually"}` | +| manual_ring_behavior | MANUAL_SKIP | `{"note": "validated manually"}` | +| manual_audio_path | MANUAL_SKIP | `{"note": "validated manually"}` | +| manual_hfp_pairing | MANUAL_SKIP | `{"note": "validated manually"}` | diff --git a/docs/pre_merge_checks.md b/docs/pre_merge_checks.md new file mode 100644 index 0000000..b0e6bd5 --- /dev/null +++ b/docs/pre_merge_checks.md @@ -0,0 +1,7 @@ +# Pré-merge checks (redirect) + +Ce document est un alias conservé pour compatibilité IDE/outils. + +Référence active: + +- [docs/branch_quality_gate.md](./branch_quality_gate.md) diff --git a/docs/zeroclaw_orchestration.md b/docs/zeroclaw_orchestration.md index de3f646..e250eb3 100644 --- a/docs/zeroclaw_orchestration.md +++ b/docs/zeroclaw_orchestration.md @@ -1,6 +1,6 @@ # Orchestration ZeroClaw (RTC) -Dernière mise à jour: 2026-02-21. +Dernière mise à jour: 2026-02-25. ## Objectif @@ -10,6 +10,37 @@ Rendre les boucles hardware RTC plus robustes: - session agent ZeroClaw ciblée sur le repo RTC, - exécution coordonnée avec `Kill_LIFE` (orchestrateur dual-repo). +## Mode retenu: Docker orchestration + hardware hôte + +Stack active: + +- orchestrateur: `http://127.0.0.1:8788` +- n8n: `http://127.0.0.1:5678` +- OpenWebUI: `http://127.0.0.1:3001` + +Utilisation recommandée: + +1. Pilotage agents/subagents via API orchestrateur (`GET /api/agents`, `GET /api/workflows`, `GET /api/status`, `POST /api/run`). +2. Build/flash/monitor RTC_A252 exécutés localement sur macOS (hors conteneur). + +Contrainte validée: + +- Ne pas lancer `rtc_firmware_loop` depuis l'orchestrateur Docker pour cette passe RTC: + - conteneur sans chemin repo RTC hôte monté (`/Users/.../RTC_BL_PHONE`), + - `pio` non installé dans l'image orchestrateur, + - pas d'accès direct aux ports série macOS. + +Actions Docker autorisées dans ce mode: + +- `provider_scan` +- actions webhook (`rtc_webhook`, `zacus_webhook`, `general_prompt_fanout`) + +Check health recommandé: + +```bash +python3 scripts/zeroclaw_orchestrator_health.py --base-url "${ZEROCLAW_ORCH:-http://127.0.0.1:8788}" +``` + ## 1) Préflight hardware local Lancer avant `autoflash.py`, `hw_validation.py` ou tout upload PlatformIO: @@ -50,13 +81,13 @@ Si credentials manquants, choisir un provider: Contexte bench actuel: carte `esp32dev` (audio dev), sans cible S3 connectee. -Commande de reference: +Commande de référence A252 (USB-Serial) : ```bash cd /Users/cils/Documents/Lelectron_rare/RTC_BL_PHONE python3 scripts/zeroclaw_hw_preflight.py --require-port --zeroclaw-bin /Users/cils/Documents/Lelectron_rare/Kill_LIFE/zeroclaw/target/release/zeroclaw pio run -e esp32dev -pio run -e esp32dev -t upload --upload-port /dev/cu.SLAB_USBtoUART +pio run -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001 ``` Smoke terminal attendu: @@ -69,7 +100,29 @@ Note technique: - Sur `esp32dev`, il faut initialiser le stack reseau avant `AsyncWebServer::begin()` pour eviter le crash `lwIP Invalid mbox`. -## 5) Preuve boucle complète (2026-02-21) +## 5) Gate stricte A252 (local) + +Variables attendues: + +- `A252_PORT` (ex: `/dev/cu.usbserial-0001`) +- `A252_WIFI_SSID` +- `A252_WIFI_PASSWORD` +- `ZEROCLAW_ORCH` (défaut `http://127.0.0.1:8788`) + +Exécution: + +```bash +bash scripts/a252_strict_gate.sh +``` + +Sorties: + +- `artifacts/zeroclaw_orchestrator_health.json` +- `artifacts/hw_validation_a252_report.json` +- `docs/hw_validation_a252_report.md` +- `docs/a252_strict_gate_summary.md` + +## 6) Preuve boucle complète (2026-02-21) Etat courant: diff --git a/scripts/a252_strict_gate.sh b/scripts/a252_strict_gate.sh new file mode 100755 index 0000000..a429d63 --- /dev/null +++ b/scripts/a252_strict_gate.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -uo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +A252_PORT="${A252_PORT:-/dev/cu.usbserial-0001}" +ZEROCLAW_ORCH="${ZEROCLAW_ORCH:-http://127.0.0.1:8788}" +ZEROCLAW_BIN="${ZEROCLAW_BIN:-zeroclaw}" +A252_HOOK_OBSERVE_SECONDS="${A252_HOOK_OBSERVE_SECONDS:-45}" + +HW_REPORT_JSON="artifacts/hw_validation_a252_report.json" +HW_REPORT_MD="docs/hw_validation_a252_report.md" +ZEROCLAW_REPORT_JSON="artifacts/zeroclaw_orchestrator_health.json" +SUMMARY_MD="docs/a252_strict_gate_summary.md" + +STATUS_PREFLIGHT="NOT_RUN" +STATUS_ZEROCLAW="NOT_RUN" +STATUS_BRANCH_GATE="NOT_RUN" +STATUS_HW_VALIDATION="NOT_RUN" +OVERALL="FAIL" + +log() { + echo "[a252-strict-gate] $*" +} + +write_summary() { + STATUS_PREFLIGHT="${STATUS_PREFLIGHT}" \ + STATUS_ZEROCLAW="${STATUS_ZEROCLAW}" \ + STATUS_BRANCH_GATE="${STATUS_BRANCH_GATE}" \ + STATUS_HW_VALIDATION="${STATUS_HW_VALIDATION}" \ + OVERALL="${OVERALL}" \ + ZEROCLAW_ORCH="${ZEROCLAW_ORCH}" \ + A252_PORT="${A252_PORT}" \ + HW_REPORT_JSON="${HW_REPORT_JSON}" \ + ZEROCLAW_REPORT_JSON="${ZEROCLAW_REPORT_JSON}" \ + SUMMARY_MD="${SUMMARY_MD}" \ + python3 - <<'PY' +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +summary_path = Path(os.environ["SUMMARY_MD"]) +summary_path.parent.mkdir(parents=True, exist_ok=True) + +def load_json(path: Path): + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + +hw_payload = load_json(Path(os.environ["HW_REPORT_JSON"])) +zc_payload = load_json(Path(os.environ["ZEROCLAW_REPORT_JSON"])) + +lines = [ + "# A252 Strict Gate Summary", + "", + f"- Date UTC: {datetime.now(timezone.utc).isoformat()}", + f"- Verdict global: {os.environ['OVERALL']}", + f"- Port A252: `{os.environ['A252_PORT']}`", + f"- ZeroClaw: `{os.environ['ZEROCLAW_ORCH']}`", + "", + "## Étapes", + "", + "| Étape | État |", + "|---|---|", + f"| zeroclaw_hw_preflight | {os.environ['STATUS_PREFLIGHT']} |", + f"| zeroclaw_orchestrator_health | {os.environ['STATUS_ZEROCLAW']} |", + f"| branch_gate_profile_a252 | {os.environ['STATUS_BRANCH_GATE']} |", + f"| hw_validation_a252 | {os.environ['STATUS_HW_VALIDATION']} |", +] + +if hw_payload: + lines.extend([ + "", + "## Stacks A252 (hw_validation)", + "", + "| Stack/Scénario | État |", + "|---|---|", + ]) + for item in hw_payload.get("results", []): + name = item.get("name", "") + state = item.get("state", "") + lines.append(f"| {name} | {state} |") + +if zc_payload: + lines.extend([ + "", + "## ZeroClaw Docker", + "", + "| Check | État |", + "|---|---|", + ]) + for item in zc_payload.get("results", []): + name = item.get("name", "") + state = item.get("state", "") + lines.append(f"| {name} | {state} |") + +summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8") +PY +} + +run_step() { + local step_var="$1" + local description="$2" + shift 2 + log "${description}" + if "$@"; then + printf -v "${step_var}" "PASS" + return 0 + fi + printf -v "${step_var}" "FAIL" + OVERALL="FAIL" + write_summary + return 1 +} + +if [[ -z "${A252_WIFI_SSID:-}" || -z "${A252_WIFI_PASSWORD:-}" ]]; then + log "erreur: A252_WIFI_SSID et A252_WIFI_PASSWORD sont requis" + write_summary + exit 2 +fi + +run_step STATUS_PREFLIGHT "ZeroClaw hardware preflight" \ + python3 scripts/zeroclaw_hw_preflight.py \ + --zeroclaw-bin "${ZEROCLAW_BIN}" \ + --require-port \ + --port "${A252_PORT}" || exit 1 + +run_step STATUS_ZEROCLAW "ZeroClaw orchestrator health + provider_scan" \ + python3 scripts/zeroclaw_orchestrator_health.py \ + --base-url "${ZEROCLAW_ORCH}" \ + --report-json "${ZEROCLAW_REPORT_JSON}" || exit 1 + +run_step STATUS_BRANCH_GATE "Branch gate profile a252" \ + bash scripts/branch_gate.sh --profile a252 || exit 1 + +run_step STATUS_HW_VALIDATION "Hardware validation A252 strict" \ + python3 scripts/hw_validation.py \ + --port-a252 "${A252_PORT}" \ + --flash \ + --wifi-ssid "${A252_WIFI_SSID}" \ + --wifi-password "${A252_WIFI_PASSWORD}" \ + --strict-serial-smoke \ + --allow-capture-fail-when-disabled \ + --require-hook-toggle \ + --hook-observe-seconds "${A252_HOOK_OBSERVE_SECONDS}" \ + --report-json "${HW_REPORT_JSON}" \ + --report-md "${HW_REPORT_MD}" || exit 1 + +OVERALL="PASS" +write_summary +log "gate A252 strict terminé avec succès" diff --git a/scripts/branch_gate.sh b/scripts/branch_gate.sh new file mode 100755 index 0000000..31dcb16 --- /dev/null +++ b/scripts/branch_gate.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +DEFAULT_BUILD_ENVS_FULL=( + "esp32dev" + "esp32-s3-devkitc-1" + "esp32-s3-usb-host" + "esp32-s3-usb-msc" +) +DEFAULT_BUILD_ENVS_A252=( + "esp32dev" +) +REPORT_JSON="${ARTIFACT_REPORT_PATH:-artifacts/route_parity_report.json}" + +BUILD_ENVS=() +SKIP_BUILDS=0 +PROFILE="a252" + +log() { + echo "[branch-gate] $*" +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "[branch-gate] erreur: commande manquante: $1" >&2 + exit 1 + fi +} + +usage() { + cat <<'EOF' +Usage: scripts/branch_gate.sh [options] + +Exécute la chaîne de validation de branche dans un ordre déterministe. + +Options: + --profile Profil de build par défaut (a252: esp32dev seulement). + --skip-builds Ignore la phase de build PlatformIO. + --build-env Ajouter explicitement un env PlatformIO (peut se répéter). + --build-envs Ajouter plusieurs envs en une fois (séparés par des virgules). + --report-json Emplacement du rapport parity JSON (défaut: artifacts/route_parity_report.json). + --help Affiche cette aide. + +Sans --build-env ni --build-envs, la séquence build cible : + - profile a252: esp32dev + - profile full: esp32dev, esp32-s3-devkitc-1, esp32-s3-usb-host, esp32-s3-usb-msc +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) + if [[ $# -lt 2 ]]; then + usage + exit 1 + fi + case "$2" in + a252|full) + PROFILE="$2" + ;; + *) + echo "[branch-gate] profile invalide: $2 (attendu: a252|full)" >&2 + exit 1 + ;; + esac + shift 2 + ;; + --skip-builds) + SKIP_BUILDS=1 + shift + ;; + --build-env) + if [[ $# -lt 2 ]]; then + usage + exit 1 + fi + BUILD_ENVS+=("$2") + shift 2 + ;; + --build-envs) + if [[ $# -lt 2 ]]; then + usage + exit 1 + fi + IFS=',' read -r -a extra_envs <<< "$2" + for env_name in "${extra_envs[@]}"; do + if [[ -n "${env_name}" ]]; then + BUILD_ENVS+=("${env_name}") + fi + done + shift 2 + ;; + --report-json) + if [[ $# -lt 2 ]]; then + usage + exit 1 + fi + REPORT_JSON="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "[branch-gate] option inconnue: $1" >&2 + usage + exit 1 + ;; + esac +done + +run_checks() { + log "vérification syntaxe scripts (python)" + python3 -m py_compile scripts/hw_validation.py + + log "tests unitaires PlatformIO (esp32dev, mode host)" + platformio test --without-uploading --without-testing -e esp32dev + + log "tests host DTMF" + 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 + + log "tests contrat parity/runtime/hw_validation" + python3 -m unittest \ + scripts/test_check_web_route_parity.py \ + scripts/test_runtime_contracts.py \ + scripts/test_hw_validation_contracts.py + + log "contrôle route/command parity avec rapport JSON" + mkdir -p "$(dirname "${REPORT_JSON}")" + python3 scripts/check_web_route_parity.py --report-json "${REPORT_JSON}" +} + +run_builds() { + if (( SKIP_BUILDS )); then + log "phase build ignorée (--skip-builds)" + return + fi + + if (( ${#BUILD_ENVS[@]} == 0 )); then + if [[ "${PROFILE}" == "full" ]]; then + BUILD_ENVS=("${DEFAULT_BUILD_ENVS_FULL[@]}") + else + BUILD_ENVS=("${DEFAULT_BUILD_ENVS_A252[@]}") + fi + fi + + log "profil build actif: ${PROFILE}" + for env_name in "${BUILD_ENVS[@]}"; do + log "build PlatformIO: ${env_name}" + platformio run -e "${env_name}" + done +} + +require_cmd python3 +require_cmd platformio +require_cmd c++ + +run_checks +run_builds + +log "validation de branche terminée avec succès" diff --git a/scripts/hw_validation.py b/scripts/hw_validation.py index 7900fd8..34b8b37 100755 --- a/scripts/hw_validation.py +++ b/scripts/hw_validation.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import glob import json import subprocess import sys @@ -21,6 +22,7 @@ except ImportError: # pragma: no cover VALID_STATES = {"PASS", "FAIL", "MANUAL_PASS", "MANUAL_FAIL", "MANUAL_SKIP"} +OPTIONAL_SERIAL_COMMANDS = {"WIFI_SCAN", "ESPNOW_STATUS"} @dataclass @@ -77,7 +79,7 @@ class SerialEndpoint: continue last_line = line print(f"[{self.port}] {line}") - if line.startswith("{") and line.endswith("}"): + if line and line[0] in ("{", "[") and line[-1] in ("}", "]"): if expect in {"any", "json"}: try: return json.loads(line) @@ -106,6 +108,26 @@ class SerialEndpoint: raise RuntimeError(f"serial sync failed: {last_error}") +def resolve_a252_port(explicit_port: str | None) -> str: + if explicit_port: + return explicit_port + + preferred_patterns = ( + "/dev/cu.usbserial*", + "/dev/tty.usbserial*", + ) + + for pattern in preferred_patterns: + candidates = sorted(glob.glob(pattern)) + if candidates: + print(f"[hw_validation] A252 locked to USB-Serial port: {candidates[0]}") + return candidates[0] + + raise RuntimeError( + "No USB-Serial port found for A252. Expected /dev/tty.usbserial* or /dev/cu.usbserial*." + ) + + def fetch_json(url: str) -> Dict[str, Any]: req = request.Request(url, method="GET") with request.urlopen(req, timeout=5) as resp: @@ -137,7 +159,11 @@ def post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]: return json.loads(resp.read().decode("utf-8")) -def scenario_serial_smoke(dev: SerialEndpoint) -> ScenarioResult: +def scenario_serial_smoke( + dev: SerialEndpoint, + strict_serial_smoke: bool, + allow_capture_fail_when_disabled: bool, +) -> ScenarioResult: details: Dict[str, Any] = {} try: details["ping"] = dev.command("PING", expect="pong") @@ -146,28 +172,179 @@ def scenario_serial_smoke(dev: SerialEndpoint) -> ScenarioResult: 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) + state, required_checks, failed_checks, warnings = evaluate_serial_smoke_contract( + details, + strict_serial_smoke=strict_serial_smoke, + allow_capture_fail_when_disabled=allow_capture_fail_when_disabled, + ) + details["required_checks"] = required_checks + details["failed_checks"] = failed_checks + details["warnings"] = warnings + return ScenarioResult("serial_smoke", state, details) except Exception as exc: return ScenarioResult("serial_smoke", "FAIL", {"error": str(exc)}) def _is_success_response(resp: Dict[str, Any]) -> bool: + if "line" in resp: + line = str(resp.get("line")).strip().upper() + if line.startswith("OK "): + return True + if line.startswith("ERR "): + return False + return False if "ok" in resp: return bool(resp.get("ok")) return True +def _has_espnow_capability(resp: Dict[str, Any]) -> bool: + if not isinstance(resp, dict): + return False + if not bool(resp.get("ready")): + return False + peers = resp.get("peer_count") + return isinstance(peers, int) and peers > 0 + + +def _normalize_command(command: str) -> str: + return command.strip().upper() + + +def _is_soft_unsupported(command: str, resp: Dict[str, Any]) -> bool: + normalized_command = _normalize_command(command) + if normalized_command not in OPTIONAL_SERIAL_COMMANDS: + return False + + if not isinstance(resp, dict): + return False + + if isinstance(resp.get("line"), str): + line = resp["line"].strip().upper() + return line.startswith("ERR UNSUPPORTED_COMMAND") or "UNSUPPORTED" in line + + if isinstance(resp.get("code"), str): + code = resp["code"].strip().upper() + return "UNSUPPORTED" in code + + return False + + +def _is_acceptable_response(command: str, resp: Dict[str, Any], required: bool) -> bool: + if required: + return _is_success_response(resp) + if _is_soft_unsupported(command, resp): + return True + return _is_success_response(resp) + + +def _extract_capture_enabled(status_payload: Dict[str, Any]) -> bool: + if not isinstance(status_payload, dict): + return True + config = status_payload.get("config") + if not isinstance(config, dict): + return True + audio = config.get("audio") + if not isinstance(audio, dict): + return True + value = audio.get("enable_capture") + if isinstance(value, bool): + return value + return True + + +def evaluate_serial_smoke_contract( + details: Dict[str, Any], + *, + strict_serial_smoke: bool, + allow_capture_fail_when_disabled: bool, +) -> tuple[str, List[str], List[str], List[str]]: + warnings: List[str] = [] + failed_checks: List[str] = [] + + ping_ok = bool(details.get("ping", {}).get("ok")) + status_payload = details.get("status", {}) + status_ok = isinstance(status_payload, dict) and "telephony" in status_payload + call_ok = _is_success_response(details.get("call", {})) + capture_start_ok = _is_success_response(details.get("capture_start", {})) + capture_stop_ok = _is_success_response(details.get("capture_stop", {})) + reset_metrics_ok = _is_success_response(details.get("reset_metrics", {})) + + capture_enabled = _extract_capture_enabled(status_payload if isinstance(status_payload, dict) else {}) + capture_start_required = capture_enabled or (not allow_capture_fail_when_disabled) + + required_checks: List[str] = ["PING", "STATUS", "CALL", "CAPTURE_STOP", "RESET_METRICS"] + if capture_start_required: + required_checks.append("CAPTURE_START") + + if not ping_ok: + failed_checks.append("PING") + if not status_ok: + failed_checks.append("STATUS") + if not call_ok: + failed_checks.append("CALL") + if not capture_stop_ok: + failed_checks.append("CAPTURE_STOP") + if not reset_metrics_ok: + failed_checks.append("RESET_METRICS") + if capture_start_required and not capture_start_ok: + failed_checks.append("CAPTURE_START") + + if not capture_enabled and not capture_start_ok: + if allow_capture_fail_when_disabled: + warnings.append("capture_start_failed_capture_disabled") + else: + warnings.append("capture_start_required_even_when_capture_disabled") + + if strict_serial_smoke: + return ("PASS" if not failed_checks else "FAIL", required_checks, failed_checks, warnings) + + minimum_failures = [check for check in failed_checks if check in {"PING", "STATUS"}] + if failed_checks and not minimum_failures: + warnings.append("strict_serial_smoke_disabled") + return ("PASS" if not minimum_failures else "FAIL", required_checks, failed_checks, warnings) + + def _quote_arg(value: str) -> str: escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' +def _command_with_retry( + dev: SerialEndpoint, + command: str, + *, + expect: str = "any", + timeout_s: float = 6.0, + attempts: int = 1, + retry_delay_s: float = 0.5, +) -> Dict[str, Any]: + last_error: Optional[Exception] = None + effective_attempts = max(1, attempts) + for attempt in range(effective_attempts): + try: + return dev.command(command, timeout_s=timeout_s, expect=expect) + except Exception as exc: + last_error = exc + if attempt + 1 >= effective_attempts: + break + time.sleep(retry_delay_s) + raise RuntimeError(str(last_error) if last_error else f"command failed: {command}") + + def scenario_serial_network(dev: SerialEndpoint, wifi_ssid: str, wifi_password: str) -> ScenarioResult: details: Dict[str, Any] = {} try: details["wifi_status_before"] = dev.command("WIFI_STATUS", expect="json") - details["wifi_scan"] = dev.command("WIFI_SCAN", expect="json") + # Wi-Fi scan can be slow right after boot/flash; retry with a longer timeout. + details["wifi_scan"] = _command_with_retry( + dev, + "WIFI_SCAN", + expect="any", + timeout_s=12.0, + attempts=2, + retry_delay_s=1.0, + ) if wifi_ssid: already_connected = ( bool(details["wifi_status_before"].get("connected")) @@ -184,27 +361,159 @@ def scenario_serial_network(dev: SerialEndpoint, wifi_ssid: str, wifi_password: ) 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") + details["espnow_status"] = _command_with_retry( + dev, + "ESPNOW_STATUS", + expect="any", + timeout_s=8.0, + attempts=2, + retry_delay_s=0.5, + ) + + checks = [ + ("WIFI_STATUS", details["wifi_status_before"], True), + ("WIFI_SCAN", details["wifi_scan"], False), + ("ESPNOW_STATUS", details["espnow_status"], True), + ] + if wifi_ssid and "wifi_status_after" in details: + if "wifi_connect" in details: + checks.append(("WIFI_CONNECT", details["wifi_connect"], True)) + checks.append(("WIFI_STATUS", details["wifi_status_after"], True)) + ok = True - for value in details.values(): - if isinstance(value, dict) and not _is_success_response(value): + for command, value, required in checks: + if command == "ESPNOW_STATUS" and not _has_espnow_capability(value): ok = False break + if not _is_acceptable_response(command, value, required): + ok = False + break + + if ok and wifi_ssid and "wifi_status_after" in details: + wifi_after = details["wifi_status_after"] + if not ( + isinstance(wifi_after, dict) + and bool(wifi_after.get("connected")) + and str(wifi_after.get("ssid", "")) == wifi_ssid + ): + ok = False 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}) +def _extract_hook_state(status_payload: Dict[str, Any]) -> str: + if not isinstance(status_payload, dict): + return "" + telephony = status_payload.get("telephony") + if not isinstance(telephony, dict): + return "" + hook = telephony.get("hook") + if not isinstance(hook, str): + return "" + value = hook.strip().upper() + if value in {"ON_HOOK", "OFF_HOOK"}: + return value + return "" + + +def _extract_dial_tone_active(status_payload: Dict[str, Any]) -> Optional[bool]: + if not isinstance(status_payload, dict): + return None + audio = status_payload.get("audio") + if not isinstance(audio, dict): + return None + value = audio.get("dial_tone_active") + if isinstance(value, bool): + return value + return None + + +def scenario_serial_hook_ring_audio( + dev: SerialEndpoint, + *, + require_hook_toggle: bool, + hook_observe_seconds: int, +) -> ScenarioResult: + details: Dict[str, Any] = {} + observed_hooks: List[str] = [] + + try: + details["status_before"] = dev.command("STATUS", expect="json") + initial_hook = _extract_hook_state(details["status_before"]) + if initial_hook: + observed_hooks.append(initial_hook) + + details["ring"] = dev.command("RING", expect="ack") + details["tone_on"] = dev.command("TONE_ON", expect="ack") + time.sleep(0.4) + details["status_tone_on"] = dev.command("STATUS", expect="json") + hook_after_tone_on = _extract_hook_state(details["status_tone_on"]) + if hook_after_tone_on: + observed_hooks.append(hook_after_tone_on) + + details["tone_off"] = dev.command("TONE_OFF", expect="ack") + time.sleep(0.3) + details["status_tone_off"] = dev.command("STATUS", expect="json") + hook_after_tone_off = _extract_hook_state(details["status_tone_off"]) + if hook_after_tone_off: + observed_hooks.append(hook_after_tone_off) + + observe_seconds = max(0, hook_observe_seconds) + poll_deadline = time.time() + float(observe_seconds) + while time.time() < poll_deadline: + poll_status = dev.command("STATUS", expect="json") + hook = _extract_hook_state(poll_status) + if hook: + observed_hooks.append(hook) + time.sleep(0.8) + + unique_hooks = sorted(set(observed_hooks)) + details["hook_values_observed"] = unique_hooks + details["hook_observe_seconds"] = observe_seconds + + ring_ok = _is_success_response(details["ring"]) + tone_on_ok = _is_success_response(details["tone_on"]) + tone_off_ok = _is_success_response(details["tone_off"]) + tone_active_after_on = _extract_dial_tone_active(details["status_tone_on"]) is True + tone_inactive_after_off = _extract_dial_tone_active(details["status_tone_off"]) is False + + if require_hook_toggle: + required_hooks = ["ON_HOOK", "OFF_HOOK"] + hook_ok = all(state in unique_hooks for state in required_hooks) + details["required_hook_states"] = required_hooks + else: + hook_ok = len(unique_hooks) > 0 + details["required_hook_states"] = ["ON_HOOK|OFF_HOOK"] + + details["checks"] = { + "ring_ok": ring_ok, + "tone_on_ok": tone_on_ok, + "tone_active_after_on": tone_active_after_on, + "tone_off_ok": tone_off_ok, + "tone_inactive_after_off": tone_inactive_after_off, + "hook_ok": hook_ok, + } + + ok = ( + ring_ok + and tone_on_ok + and tone_active_after_on + and tone_off_ok + and tone_inactive_after_off + and hook_ok + ) + return ScenarioResult("serial_hook_ring_audio", "PASS" if ok else "FAIL", details) + except Exception as exc: + return ScenarioResult("serial_hook_ring_audio", "FAIL", {"error": str(exc), **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: @@ -248,7 +557,14 @@ def write_reports(results: List[ScenarioResult], report_json: Path, report_md: P def parse_args() -> argparse.Namespace: 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( + "--port-a252", + default="", + help=( + "serial port for A252 target. If omitted, auto-detects first /dev/*usbserial* on macOS/" + "Linux." + ), + ) 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)") @@ -263,23 +579,64 @@ def parse_args() -> argparse.Namespace: 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") + parser.add_argument( + "--strict-serial-smoke", + action=argparse.BooleanOptionalAction, + default=True, + help="if enabled, fail serial_smoke when required command checks fail (default: enabled)", + ) + parser.add_argument( + "--allow-capture-fail-when-disabled", + action=argparse.BooleanOptionalAction, + default=True, + help=( + "if enabled, CAPTURE_START failure is tolerated when STATUS reports audio.enable_capture=false " + "(default: enabled)" + ), + ) + parser.add_argument( + "--require-hook-toggle", + action=argparse.BooleanOptionalAction, + default=True, + help="if enabled, serial_hook_ring_audio requires both ON_HOOK and OFF_HOOK states", + ) + parser.add_argument( + "--hook-observe-seconds", + type=int, + default=45, + help="hook observation window in seconds for serial_hook_ring_audio (default: 45)", + ) return parser.parse_args() def main() -> int: args = parse_args() + resolved_port = resolve_a252_port(args.port_a252.strip() or None) if args.flash: run_cmd(["pio", "run", "-e", "esp32dev"]) - run_cmd(["pio", "run", "-e", "esp32dev", "-t", "upload", "--upload-port", args.port_a252]) + run_cmd(["pio", "run", "-e", "esp32dev", "-t", "upload", "--upload-port", resolved_port]) results: List[ScenarioResult] = [] network_result: Optional[ScenarioResult] = None try: - with SerialEndpoint(args.port_a252, args.baud) as dev: + with SerialEndpoint(resolved_port, args.baud) as dev: dev.sync() - results.append(scenario_serial_smoke(dev)) + results.append( + scenario_serial_smoke( + dev, + strict_serial_smoke=args.strict_serial_smoke, + allow_capture_fail_when_disabled=args.allow_capture_fail_when_disabled, + ) + ) + results.append( + scenario_serial_hook_ring_audio( + dev, + require_hook_toggle=args.require_hook_toggle, + hook_observe_seconds=args.hook_observe_seconds, + ) + ) network_result = scenario_serial_network(dev, args.wifi_ssid, args.wifi_password) results.append(network_result) except Exception as exc: diff --git a/scripts/test_hw_validation_contracts.py b/scripts/test_hw_validation_contracts.py new file mode 100644 index 0000000..2c31f4f --- /dev/null +++ b/scripts/test_hw_validation_contracts.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Unit tests for hw_validation serial smoke contract behavior.""" + +from __future__ import annotations + +import unittest + +from scripts.hw_validation import ( + evaluate_serial_smoke_contract, + scenario_serial_hook_ring_audio, + scenario_serial_network, +) + + +def _ack(ok: bool, command: str) -> dict[str, object]: + return {"ok": ok, "line": ("OK " if ok else "ERR ") + command} + + +def _make_serial_details( + *, + enable_capture: bool, + capture_start_ok: bool, + call_ok: bool = True, + ping_ok: bool = True, +) -> dict[str, object]: + return { + "ping": {"ok": ping_ok, "result": "PONG" if ping_ok else "ERR"}, + "status": { + "telephony": {"state": "IDLE"}, + "config": { + "audio": { + "enable_capture": enable_capture, + } + }, + }, + "call": _ack(call_ok, "CALL"), + "capture_start": _ack(capture_start_ok, "CAPTURE_START"), + "capture_stop": _ack(True, "CAPTURE_STOP"), + "reset_metrics": _ack(True, "RESET_METRICS"), + } + + +class SerialSmokeContractTest(unittest.TestCase): + def test_capture_start_failure_fails_when_capture_enabled(self) -> None: + details = _make_serial_details(enable_capture=True, capture_start_ok=False) + state, required_checks, failed_checks, warnings = evaluate_serial_smoke_contract( + details, + strict_serial_smoke=True, + allow_capture_fail_when_disabled=True, + ) + self.assertEqual(state, "FAIL") + self.assertIn("CAPTURE_START", required_checks) + self.assertIn("CAPTURE_START", failed_checks) + self.assertEqual(warnings, []) + + def test_capture_start_failure_passes_with_warning_when_capture_disabled(self) -> None: + details = _make_serial_details(enable_capture=False, capture_start_ok=False) + state, required_checks, failed_checks, warnings = evaluate_serial_smoke_contract( + details, + strict_serial_smoke=True, + allow_capture_fail_when_disabled=True, + ) + self.assertEqual(state, "PASS") + self.assertNotIn("CAPTURE_START", required_checks) + self.assertNotIn("CAPTURE_START", failed_checks) + self.assertIn("capture_start_failed_capture_disabled", warnings) + + def test_capture_start_failure_can_be_forced_to_fail_when_capture_disabled(self) -> None: + details = _make_serial_details(enable_capture=False, capture_start_ok=False) + state, required_checks, failed_checks, warnings = evaluate_serial_smoke_contract( + details, + strict_serial_smoke=True, + allow_capture_fail_when_disabled=False, + ) + self.assertEqual(state, "FAIL") + self.assertIn("CAPTURE_START", required_checks) + self.assertIn("CAPTURE_START", failed_checks) + self.assertIn("capture_start_required_even_when_capture_disabled", warnings) + + def test_non_strict_mode_warns_but_can_pass_non_critical_failures(self) -> None: + details = _make_serial_details(enable_capture=True, capture_start_ok=True, call_ok=False) + state, _, failed_checks, warnings = evaluate_serial_smoke_contract( + details, + strict_serial_smoke=False, + allow_capture_fail_when_disabled=True, + ) + self.assertEqual(state, "PASS") + self.assertIn("CALL", failed_checks) + self.assertIn("strict_serial_smoke_disabled", warnings) + + def test_non_strict_mode_still_fails_on_minimum_contract(self) -> None: + details = _make_serial_details(enable_capture=True, capture_start_ok=True, ping_ok=False) + state, _, failed_checks, warnings = evaluate_serial_smoke_contract( + details, + strict_serial_smoke=False, + allow_capture_fail_when_disabled=True, + ) + self.assertEqual(state, "FAIL") + self.assertIn("PING", failed_checks) + self.assertNotIn("strict_serial_smoke_disabled", warnings) + + +class _FakeSerialEndpoint: + def __init__(self, responses: list[object]) -> None: + self._responses = responses + self._index = 0 + + def command(self, cmd: str, timeout_s: float = 6.0, expect: str = "any") -> dict[str, object]: + (timeout_s, expect) # silence unused + if self._index >= len(self._responses): + raise RuntimeError(f"no response configured for command: {cmd}") + value = self._responses[self._index] + self._index += 1 + if isinstance(value, Exception): + raise value + return value + + +class SerialNetworkContractTest(unittest.TestCase): + def test_wifi_connect_failure_marks_network_fail(self) -> None: + fake = _FakeSerialEndpoint( + [ + {"connected": False, "ssid": "", "status": 6}, + [{"ssid": "Les cils", "rssi": -42, "chan": 11, "enc": 4}], + {"ok": False, "line": "ERR WIFI_CONNECT failed"}, + {"connected": False, "ssid": "", "status": 6}, + {"ready": True, "peer_count": 1}, + ] + ) + result = scenario_serial_network(fake, "Les cils", "mascarade") + self.assertEqual(result.state, "FAIL") + self.assertEqual(result.name, "serial_network_stack") + self.assertEqual(result.details.get("wifi_connect", {}).get("ok"), False) + + def test_wifi_connect_success_marks_network_pass(self) -> None: + fake = _FakeSerialEndpoint( + [ + {"connected": False, "ssid": "", "status": 6}, + [{"ssid": "Les cils", "rssi": -42, "chan": 11, "enc": 4}], + {"ok": True, "line": "OK WIFI_CONNECT"}, + {"connected": True, "ssid": "Les cils", "status": 3, "ip": "192.168.1.42"}, + {"ready": True, "peer_count": 1}, + ] + ) + result = scenario_serial_network(fake, "Les cils", "mascarade") + self.assertEqual(result.state, "PASS") + self.assertEqual(result.name, "serial_network_stack") + + +class SerialHookRingAudioContractTest(unittest.TestCase): + def test_tone_on_failure_marks_hook_ring_audio_fail(self) -> None: + fake = _FakeSerialEndpoint( + [ + {"telephony": {"hook": "ON_HOOK"}, "audio": {"dial_tone_active": False}}, + {"ok": True, "line": "OK RING"}, + {"ok": False, "line": "ERR TONE_ON audio_not_ready"}, + {"telephony": {"hook": "ON_HOOK"}, "audio": {"dial_tone_active": False}}, + {"ok": True, "line": "OK TONE_OFF"}, + {"telephony": {"hook": "ON_HOOK"}, "audio": {"dial_tone_active": False}}, + ] + ) + result = scenario_serial_hook_ring_audio(fake, require_hook_toggle=True, hook_observe_seconds=0) + self.assertEqual(result.state, "FAIL") + self.assertEqual(result.name, "serial_hook_ring_audio") + self.assertEqual(result.details.get("checks", {}).get("tone_on_ok"), False) + + def test_hook_toggle_is_required_when_enabled(self) -> None: + fake = _FakeSerialEndpoint( + [ + {"telephony": {"hook": "ON_HOOK"}, "audio": {"dial_tone_active": False}}, + {"ok": True, "line": "OK RING"}, + {"ok": True, "line": "OK TONE_ON"}, + {"telephony": {"hook": "ON_HOOK"}, "audio": {"dial_tone_active": True}}, + {"ok": True, "line": "OK TONE_OFF"}, + {"telephony": {"hook": "ON_HOOK"}, "audio": {"dial_tone_active": False}}, + ] + ) + result = scenario_serial_hook_ring_audio(fake, require_hook_toggle=True, hook_observe_seconds=0) + self.assertEqual(result.state, "FAIL") + self.assertEqual(result.details.get("checks", {}).get("hook_ok"), False) + + def test_hook_toggle_passes_when_both_states_are_seen(self) -> None: + fake = _FakeSerialEndpoint( + [ + {"telephony": {"hook": "ON_HOOK"}, "audio": {"dial_tone_active": False}}, + {"ok": True, "line": "OK RING"}, + {"ok": True, "line": "OK TONE_ON"}, + {"telephony": {"hook": "OFF_HOOK"}, "audio": {"dial_tone_active": True}}, + {"ok": True, "line": "OK TONE_OFF"}, + {"telephony": {"hook": "OFF_HOOK"}, "audio": {"dial_tone_active": False}}, + ] + ) + result = scenario_serial_hook_ring_audio(fake, require_hook_toggle=True, hook_observe_seconds=0) + self.assertEqual(result.state, "PASS") + self.assertEqual(result.name, "serial_hook_ring_audio") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/zeroclaw_orchestrator_health.py b/scripts/zeroclaw_orchestrator_health.py new file mode 100755 index 0000000..b653d47 --- /dev/null +++ b/scripts/zeroclaw_orchestrator_health.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""ZeroClaw Docker orchestrator health check for A252 strict gate.""" + +from __future__ import annotations + +import argparse +import json +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional +from urllib import error, request + + +@dataclass +class StepResult: + name: str + ok: bool + details: Dict[str, Any] + + +def ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def request_json(method: str, url: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + body = None + headers: Dict[str, str] = {} + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = request.Request(url, method=method, data=body, headers=headers) + with request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode("utf-8") + if not raw: + return {} + return json.loads(raw) + + +def run_step(name: str, method: str, url: str, payload: Optional[Dict[str, Any]] = None) -> StepResult: + try: + data = request_json(method, url, payload) + return StepResult(name=name, ok=True, details={"url": url, "response": data}) + except error.HTTPError as exc: + details: Dict[str, Any] = {"url": url, "error": f"HTTP {exc.code}"} + try: + details["body"] = exc.read().decode("utf-8") + except Exception: + details["body"] = "" + return StepResult(name=name, ok=False, details=details) + except Exception as exc: + return StepResult(name=name, ok=False, details={"url": url, "error": str(exc)}) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ZeroClaw orchestrator health check") + parser.add_argument( + "--base-url", + default=os.environ.get("ZEROCLAW_ORCH", "http://127.0.0.1:8788"), + help="ZeroClaw orchestrator base URL (default: $ZEROCLAW_ORCH or http://127.0.0.1:8788)", + ) + parser.add_argument( + "--report-json", + default="artifacts/zeroclaw_orchestrator_health.json", + help="JSON report output path", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + base_url = args.base_url.rstrip("/") + + steps = [ + run_step("status", "GET", f"{base_url}/api/status"), + run_step("agents", "GET", f"{base_url}/api/agents"), + run_step("workflows", "GET", f"{base_url}/api/workflows"), + run_step("provider_scan", "POST", f"{base_url}/api/run", {"action": "provider_scan"}), + ] + + overall_ok = all(step.ok for step in steps) + report = { + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "base_url": base_url, + "overall_passed": overall_ok, + "results": [ + {"name": step.name, "state": "PASS" if step.ok else "FAIL", "details": step.details} for step in steps + ], + } + + report_path = Path(args.report_json) + ensure_parent(report_path) + report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + return 0 if overall_ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index c92a8e3..6b0d553 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -501,7 +501,18 @@ bool AudioEngine::begin(const AudioConfig& config) { i2s_cfg.auto_clear = true; if (!i2s_stream_.begin(i2s_cfg)) { - Serial.println("[AudioEngine] i2s begin failed"); + Serial.printf("[AudioEngine] i2s begin failed: port=%d mode=%d sr=%u bits=%d ch=%d bck=%d ws=%d dout=%d din=%d dma_cnt=%u dma_len=%u\n", + static_cast(i2s_cfg.port_no), + static_cast(mode), + static_cast(i2s_cfg.sample_rate), + static_cast(i2s_cfg.bits_per_sample), + static_cast(i2s_cfg.channels), + static_cast(i2s_cfg.pin_bck), + static_cast(i2s_cfg.pin_ws), + static_cast(i2s_cfg.pin_data), + static_cast(i2s_cfg.pin_data_rx), + static_cast(i2s_cfg.buffer_count), + static_cast(i2s_cfg.buffer_size)); driver_installed_ = false; return false; } @@ -847,6 +858,10 @@ bool AudioEngine::isSdReady() const { return audio_fs_ready_; } +bool AudioEngine::isReady() const { + return driver_installed_; +} + AudioRuntimeMetrics AudioEngine::metrics() const { return metrics_; } diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index 7ee8188..e89f6bc 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -72,6 +72,7 @@ public: virtual bool supportsFullDuplex() const; virtual bool isPlaying() const; virtual bool isSdReady() const; + virtual bool isReady() const; virtual AudioRuntimeMetrics metrics() const; virtual void resetMetrics(); virtual void tick(); diff --git a/src/config/A252ConfigStore.cpp b/src/config/A252ConfigStore.cpp index e2d53f5..f04a4d9 100644 --- a/src/config/A252ConfigStore.cpp +++ b/src/config/A252ConfigStore.cpp @@ -9,7 +9,6 @@ namespace { constexpr const char* kPinsNs = "a252-pins"; constexpr const char* kAudioNs = "a252-audio"; -constexpr const char* kMqttNs = "mqtt"; constexpr const char* kEspNowNs = "espnow"; constexpr const char* kEspNowCallMapNs = "espnow-call"; @@ -89,10 +88,6 @@ A252AudioConfig A252ConfigStore::defaultAudio() { return A252AudioConfig{}; } -MqttConfig A252ConfigStore::defaultMqtt() { - return MqttConfig{}; -} - bool A252ConfigStore::loadPins(A252PinsConfig& out) { out = defaultPins(); Preferences prefs; @@ -233,64 +228,6 @@ bool A252ConfigStore::saveAudio(const A252AudioConfig& cfg, String* error) { 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(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(); @@ -406,7 +343,7 @@ bool A252ConfigStore::saveEspNowPeers(const EspNowPeerStore& store, String* erro bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) { std::vector used; - used.reserve(11); + used.reserve(14); const int required_pins[] = { cfg.i2s_bck, @@ -417,6 +354,9 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) { cfg.slic_fr, cfg.slic_shk, cfg.slic_pd, + }; + + const int optional_pins[] = { cfg.slic_adc_in, cfg.pcm_flt, cfg.pcm_demp, @@ -436,6 +376,21 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) { used.push_back(pin); } + for (int pin : optional_pins) { + if (pin == -1) { + continue; + } + 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); + } + if (detectBoardProfile() == BoardProfile::ESP32_A252) { if (cfg.es8388_sda < 0 || cfg.es8388_scl < 0) { error = "invalid_pin_range"; @@ -526,21 +481,6 @@ bool A252ConfigStore::validateAudio(const A252AudioConfig& cfg, String& 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(); i2s["bck"] = cfg.i2s_bck; @@ -582,17 +522,6 @@ void A252ConfigStore::audioToJson(const A252AudioConfig& cfg, JsonObject obj) { 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); diff --git a/src/main.cpp b/src/main.cpp index 8b287a8..42748c5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,7 +9,6 @@ #include "core/CommandDispatcher.h" #include "core/PlatformProfile.h" #include "props/EspNowBridge.h" -#include "props/PropsBridge.h" #include "slic/Ks0835SlicController.h" #include "telephony/TelephonyService.h" #include "web/WebServerManager.h" @@ -25,7 +24,7 @@ constexpr uint32_t kSerialBaud = 115200; constexpr int kAudioAmpEnablePin = 21; constexpr char kBootLogTag[] = "RTC_BOOT"; constexpr bool kPrintHelpOnBoot = false; -// API web access is intentionally left open by default to avoid hard lock on Wi-Fi. +// Branch lock: API web access remains open (no Wi-Fi basic auth) for this flow. constexpr bool kWebAuthEnabledByDefault = false; #ifdef RTC_WEB_AUTH_DEV_DISABLE @@ -39,7 +38,6 @@ FeatureMatrix g_features = getFeatureMatrix(g_profile); A252PinsConfig g_pins_cfg = A252ConfigStore::defaultPins(); A252AudioConfig g_audio_cfg = A252ConfigStore::defaultAudio(); -MqttConfig g_mqtt_cfg = A252ConfigStore::defaultMqtt(); EspNowPeerStore g_peer_store; EspNowCallMap g_espnow_call_map; String g_active_scene_id; @@ -51,12 +49,20 @@ AudioEngine g_audio; Es8388Driver g_codec; TelephonyService g_telephony; EspNowBridge g_espnow; -PropsBridge g_props_bridge; CommandDispatcher g_dispatcher; ScopeDisplay g_scope_display; String g_serial_line; WebServerManager g_web_server; +struct HardwareInitStatus { + bool init_ok = false; + bool slic_ready = false; + bool codec_ready = false; + bool audio_ready = false; +}; + +HardwareInitStatus g_hw_status; + DispatchResponse makeResponse(bool ok, const String& code) { DispatchResponse res; res.ok = ok; @@ -466,6 +472,7 @@ void applyPcm5102ControlPins(const A252PinsConfig& pins_cfg) { } bool applyHardwareConfig() { + g_hw_status = HardwareInitStatus{}; String pin_validation_error; if (!A252ConfigStore::validatePins(g_pins_cfg, pin_validation_error)) { Serial.printf("[RTC_BL_PHONE] invalid pins configuration: %s\n", pin_validation_error.c_str()); @@ -496,7 +503,11 @@ bool applyHardwareConfig() { applyPcm5102ControlPins(g_pins_cfg); const AudioConfig audio = buildI2sConfig(g_pins_cfg, g_audio_cfg); - const bool audio_ok = g_audio.begin(audio); + bool audio_ok = g_audio.begin(audio); + if (!audio_ok) { + Serial.println("[RTC_BL_PHONE] audio init failed, retrying once"); + audio_ok = g_audio.begin(audio); + } g_audio.resetMetrics(); g_telephony.begin(g_profile, g_slic, g_audio); @@ -521,12 +532,18 @@ bool applyHardwareConfig() { return ok; }); - Serial.printf("[RTC_BL_PHONE] HW init slic=%s codec=%s audio=%s\n", + g_hw_status.slic_ready = slic_ok; + g_hw_status.codec_ready = codec_ok; + g_hw_status.audio_ready = audio_ok; + g_hw_status.init_ok = slic_ok && codec_ok && audio_ok; + + Serial.printf("[RTC_BL_PHONE] HW init slic=%s codec=%s audio=%s init=%s\n", slic_ok ? "ok" : "fail", codec_ok ? "ok" : "fail", - audio_ok ? "ok" : "fail"); + audio_ok ? "ok" : "fail", + g_hw_status.init_ok ? "ok" : "fail"); - return slic_ok && codec_ok && audio_ok; + return g_hw_status.init_ok; } void appendAudioMetrics(JsonObject root) { @@ -541,6 +558,7 @@ void appendAudioMetrics(JsonObject root) { JsonObject audio = root["audio"].to(); audio["full_duplex"] = g_audio.supportsFullDuplex(); + audio["ready"] = g_audio.isReady(); audio["dial_tone_active"] = g_audio.isDialToneActive(); audio["playing"] = g_audio.isPlaying(); audio["sd_ready"] = g_audio.isSdReady(); @@ -574,9 +592,11 @@ void fillStatusSnapshot(JsonObject root) { JsonObject espnow = root["espnow"].to(); g_espnow.statusToJson(espnow); - JsonObject mqtt = root["mqtt"].to(); - g_props_bridge.statusToJson(mqtt); - A252ConfigStore::mqttToJson(g_mqtt_cfg, mqtt["config"].to()); + JsonObject hw = root["hw"].to(); + hw["init_ok"] = g_hw_status.init_ok; + hw["slic_ready"] = g_hw_status.slic_ready; + hw["codec_ready"] = g_hw_status.codec_ready; + hw["audio_ready"] = g_hw_status.audio_ready; JsonObject config = root["config"].to(); A252ConfigStore::pinsToJson(g_pins_cfg, config["pins"].to()); @@ -762,41 +782,6 @@ bool applyAudioPatch(JsonVariantConst patch, A252AudioConfig& target, String& er return true; } -bool applyMqttPatch(JsonVariantConst patch, MqttConfig& target, String& error) { - MqttConfig next = target; - if (patch["enabled"].is()) { - next.enabled = patch["enabled"].as(); - } - if (patch["host"].is()) { - next.host = patch["host"].as(); - } - if (patch["port"].is()) { - next.port = patch["port"].as(); - } else if (patch["port"].is()) { - const uint32_t port = patch["port"].as(); - if (port > UINT16_MAX) { - error = "MQTT_CONFIG_SET invalid_port"; - return false; - } - next.port = static_cast(port); - } - if (patch["user"].is()) { - next.user = patch["user"].as(); - } - if (patch["pass"].is()) { - next.pass = patch["pass"].as(); - } - if (patch["base_topic"].is()) { - next.base_topic = patch["base_topic"].as(); - } - - if (!A252ConfigStore::validateMqtt(next, error)) { - return false; - } - target = next; - return true; -} - DispatchResponse applyEspNowCallMapSet(const String& args) { if (args.isEmpty()) { return makeResponse(false, "ESPNOW_CALL_MAP_SET invalid_json"); @@ -898,10 +883,12 @@ void registerCommands() { root["connected"] = connected; root["status"] = status; if (connected) { + root["ssid"] = WiFi.SSID(); root["ip"] = WiFi.localIP().toString(); root["rssi"] = WiFi.RSSI(); root["channel"] = WiFi.channel(); } else { + root["ssid"] = ""; root["ip"] = ""; root["rssi"] = 0; root["channel"] = 0; @@ -918,13 +905,20 @@ void registerCommands() { g_dispatcher.registerCommand("WIFI_CONNECT", [](const String& args) { String ssid; - String password; - if (!splitFirstToken(args, ssid, password)) { + String rest; + if (!splitFirstToken(args, ssid, rest)) { return makeResponse(false, "WIFI_CONNECT invalid_args"); } if (ssid.isEmpty()) { return makeResponse(false, "WIFI_CONNECT invalid_ssid"); } + String password; + if (!rest.isEmpty()) { + String trailing; + if (!splitFirstToken(rest, password, trailing) || !trailing.isEmpty()) { + return makeResponse(false, "WIFI_CONNECT invalid_args"); + } + } const bool ok = g_wifi.connect(ssid, password); return makeResponse(ok, ok ? "WIFI_CONNECT" : "WIFI_CONNECT failed"); }); @@ -946,112 +940,6 @@ void registerCommands() { return makeResponse(ok, ok ? "WIFI_RECONNECT" : "WIFI_RECONNECT no_credentials"); }); - g_dispatcher.registerCommand("MQTT_STATUS", [](const String&) { - JsonDocument doc; - JsonObject root = doc.to(); - g_props_bridge.statusToJson(root["bridge"].to()); - A252ConfigStore::mqttToJson(g_mqtt_cfg, root["config"].to()); - return jsonResponse(doc); - }); - - g_dispatcher.registerCommand("MQTT_CONNECT", [](const String&) { - if (!g_mqtt_cfg.enabled) { - return makeResponse(false, "MQTT_CONNECT disabled"); - } - const bool ok = g_props_bridge.connectNow(); - return makeResponse(ok, ok ? "MQTT_CONNECT" : "MQTT_CONNECT failed"); - }); - - g_dispatcher.registerCommand("MQTT_DISCONNECT", [](const String&) { - g_props_bridge.disconnect(); - return makeResponse(true, "MQTT_DISCONNECT"); - }); - - const auto mqttPublishHandler = [](const String& args) { - String topic; - String payload; - if (!splitFirstToken(args, topic, payload) || topic.isEmpty()) { - return makeResponse(false, "MQTT_PUBLISH invalid_args"); - } - return makeResponse(g_props_bridge.publish(topic, payload), "MQTT_PUB"); - }; - - g_dispatcher.registerCommand("MQTT_PUB", mqttPublishHandler); - g_dispatcher.registerCommand("MQTT_PUBLISH", mqttPublishHandler); - - g_dispatcher.registerCommand("MQTT_CONFIG_SET", [](const String& args) { - if (args.isEmpty()) { - return makeResponse(false, "MQTT_CONFIG_SET invalid_json"); - } - - JsonDocument doc; - if (deserializeJson(doc, args) != DeserializationError::Ok || !doc.is()) { - return makeResponse(false, "MQTT_CONFIG_SET invalid_json"); - } - - MqttConfig next = g_mqtt_cfg; - String error; - if (!applyMqttPatch(doc.as(), next, error)) { - return makeResponse(false, "MQTT_CONFIG_SET " + error); - } - if (!A252ConfigStore::saveMqtt(next, &error)) { - return makeResponse(false, "MQTT_CONFIG_SET " + error); - } - g_mqtt_cfg = next; - g_props_bridge.setConfig(g_mqtt_cfg); - if (!g_mqtt_cfg.enabled) { - return makeResponse(true, "MQTT_CONFIG_SET disabled"); - } - const bool connected = g_props_bridge.connectNow(); - return makeResponse(connected, connected ? "MQTT_CONFIG_SET" : "MQTT_CONFIG_SET connect_failed"); - }); - - g_dispatcher.registerCommand("BT_STATUS", [](const String&) { - return makeResponse(false, "BT_STATUS unsupported"); - }); - g_dispatcher.registerCommand("BT_HFP_CONNECT", [](const String&) { - return makeResponse(false, "BT_HFP_CONNECT unsupported"); - }); - g_dispatcher.registerCommand("BT_HFP_DISCONNECT", [](const String&) { - return makeResponse(false, "BT_HFP_DISCONNECT unsupported"); - }); - g_dispatcher.registerCommand("BT_AUTO_RECONNECT_ON", [](const String&) { - return makeResponse(false, "BT_AUTO_RECONNECT_ON unsupported"); - }); - g_dispatcher.registerCommand("BT_AUTO_RECONNECT_OFF", [](const String&) { - return makeResponse(false, "BT_AUTO_RECONNECT_OFF unsupported"); - }); - g_dispatcher.registerCommand("BT_DISCOVERABLE_ON", [](const String&) { - return makeResponse(false, "BT_DISCOVERABLE_ON unsupported"); - }); - g_dispatcher.registerCommand("BT_DISCOVERABLE_OFF", [](const String&) { - return makeResponse(false, "BT_DISCOVERABLE_OFF unsupported"); - }); - g_dispatcher.registerCommand("BT_DIAL", [](const String&) { - return makeResponse(false, "BT_DIAL unsupported"); - }); - g_dispatcher.registerCommand("BT_REDIAL", [](const String&) { - return makeResponse(false, "BT_REDIAL unsupported"); - }); - g_dispatcher.registerCommand("BT_ANSWER", [](const String&) { - return makeResponse(false, "BT_ANSWER unsupported"); - }); - g_dispatcher.registerCommand("BT_HANGUP", [](const String&) { - return makeResponse(false, "BT_HANGUP unsupported"); - }); - g_dispatcher.registerCommand("BT_CALLS", [](const String&) { - return makeResponse(false, "BT_CALLS unsupported"); - }); - g_dispatcher.registerCommand("BT_PBAP_SYNC", [](const String&) { - return makeResponse(false, "BT_PBAP_SYNC unsupported"); - }); - g_dispatcher.registerCommand("BT_BLE_START", [](const String&) { - return makeResponse(false, "BT_BLE_START unsupported"); - }); - g_dispatcher.registerCommand("BT_BLE_STOP", [](const String&) { - return makeResponse(false, "BT_BLE_STOP unsupported"); - }); - g_dispatcher.registerCommand("UNLOCK", [](const String&) { g_slic.setLineEnabled(true); return makeResponse(true, "UNLOCK"); @@ -1155,7 +1043,11 @@ void registerCommands() { }); g_dispatcher.registerCommand("TONE_ON", [](const String&) { - return makeResponse(g_audio.startDialTone(), "TONE_ON"); + if (!g_audio.isReady()) { + return makeResponse(false, "TONE_ON audio_not_ready"); + } + const bool ok = g_audio.startDialTone(); + return makeResponse(ok, ok ? "TONE_ON" : "TONE_ON failed"); }); g_dispatcher.registerCommand("TONE_OFF", [](const String&) { @@ -1319,6 +1211,8 @@ void registerCommands() { g_codec.setRoute(g_audio_cfg.route); } const bool audio_ok = g_audio.begin(buildI2sConfig(g_pins_cfg, g_audio_cfg)); + g_hw_status.audio_ready = audio_ok; + g_hw_status.init_ok = g_hw_status.slic_ready && g_hw_status.codec_ready && g_hw_status.audio_ready; return makeResponse(audio_ok, "AUDIO_CONFIG_SET"); }); } @@ -1532,7 +1426,6 @@ void setup() { A252ConfigStore::loadPins(g_pins_cfg); g_pins_cfg.slic_line = -1; A252ConfigStore::loadAudio(g_audio_cfg); - A252ConfigStore::loadMqtt(g_mqtt_cfg); A252ConfigStore::loadEspNowPeers(g_peer_store); initDefaultEspNowCallMap(g_espnow_call_map); if (!A252ConfigStore::loadEspNowCallMap(g_espnow_call_map)) { @@ -1543,17 +1436,16 @@ void setup() { pinMode(kAudioAmpEnablePin, OUTPUT); digitalWrite(kAudioAmpEnablePin, LOW); - applyHardwareConfig(); + const bool hw_init_ok = applyHardwareConfig(); + if (!hw_init_ok) { + Serial.println("[RTC_BL_PHONE] hardware init failed"); + } registerCommands(); g_espnow.begin(g_peer_store); g_espnow.setCommandCallback([](const String& source, const JsonVariantConst& payload) { processInboundBridgeCommand(source, payload); }); - g_props_bridge.setCommandCallback([](const String& source, const JsonVariantConst& payload) { - processInboundBridgeCommand(source, payload); - }); - g_props_bridge.begin(g_mqtt_cfg); configureCommandServer(); g_web_server.begin(); @@ -1570,7 +1462,6 @@ void loop() { g_telephony.tick(); g_scope_display.tick(); g_web_server.handle(); - g_props_bridge.tick(); g_espnow.tick(); pollSerial(); delay(1);