diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c6336a..e302e7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,8 @@ on: branches: [main, release/stable] jobs: - build-and-test: + pre-merge: runs-on: ubuntu-latest - steps: - name: Checkout uses: actions/checkout@v4 @@ -19,11 +18,13 @@ jobs: with: python-version: "3.11" - - name: Test Web Route Parity Parser - run: python3 -m unittest scripts/test_check_web_route_parity.py + - name: Install PlatformIO + run: | + python -m pip install --upgrade pip + pip install platformio - - name: Check Web Route Parity - run: python3 scripts/check_web_route_parity.py --report-json artifacts/route_parity_report.json + - name: Run pre-merge gate + run: ./scripts/pre_merge.sh - name: Upload route parity report if: always() @@ -32,17 +33,3 @@ jobs: 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 esp32dev - - - name: Build unit tests (no upload) - run: platformio test --without-uploading --without-testing -e esp32dev diff --git a/.gitignore b/.gitignore index 1ef9394..7b6c2d4 100644 --- a/.gitignore +++ b/.gitignore @@ -428,4 +428,6 @@ coverage/ *~ # Project data +# If data/audio was previously committed, run: +# git rm --cached -r data/audio data/audio/ diff --git a/README.md b/README.md index a6328e7..e401390 100644 --- a/README.md +++ b/README.md @@ -3,61 +3,32 @@ Projet ESP32 : téléphone RTC, SLIC, audio, Bluetooth, WiFi, agentic. ## CI/CD automatisé -Le pipeline CI/CD est géré par GitHub Actions et PlatformIO : +Le pipeline CI/CD est géré par GitHub Actions + PlatformIO. -- **Déclenchement** : à chaque push ou pull request sur `main` ou `develop`. -- **Build** : compilation automatique du firmware via PlatformIO. -- **Tests** : exécution des tests unitaires avec `platformio test`. -- **Artefacts** : génération et upload automatique des binaires compilés. -- **Couverture** : rapport de couverture (optionnel, si supporté). -- **Livraison** : artefacts accessibles dans l’onglet Actions > workflow CI PlatformIO. +- **Déclenchement** : à chaque push ou pull request sur `main` ou `release/stable`. +- **Gate de validation** : exécution du script `scripts/pre_merge.sh` (ordre de checks unifié). +- **Vérifications** : tests Python/unit, tests hôte DTMF, parity WebUI/commandes, puis builds des cibles actives. +- **Artefact** : `artifacts/route_parity_report.json` généré par la gate. -### Structure du workflow -Le fichier `.github/workflows/ci.yml` contient : +### Gate de branche (qualité) +Le script de référence est `scripts/pre_merge.sh` (définition dans [docs/branch_quality_gate.md](docs/branch_quality_gate.md)). +Commande locale recommandée : -```yaml -name: CI PlatformIO -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] -jobs: - build-test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - name: Install PlatformIO - run: pip install platformio - - name: Run PlatformIO tests - run: platformio test - - name: Build firmware - run: platformio run - - name: Upload firmware artifact - uses: actions/upload-artifact@v3 - with: - name: firmware - path: .pio/build/*/*.bin - # Optionnel: Génération de la couverture si supportée - # - name: Generate coverage report - # run: platformio test --coverage - # - name: Upload coverage artifact - # uses: actions/upload-artifact@v3 - # with: - # name: coverage - # path: coverage-report/* +```bash +bash scripts/pre_merge.sh ``` -### Livraison +Exécuter uniquement les checks sans build (ex. pour un contrôle rapide local) : + +```bash +bash scripts/test_terminal.sh +``` + +## Livraison Après chaque build, les binaires sont disponibles en téléchargement dans les artefacts du workflow. ### Tests -Les tests sont lancés automatiquement à chaque commit. Voir les rapports dans l’onglet Actions. +Les tests sont lancés automatiquement à chaque commit dans `.github/workflows/ci.yml`. ### Références - [PlatformIO CI Docs](https://docs.platformio.org/en/latest/ci/index.html) diff --git a/docs/README.md b/docs/README.md index 9fdb839..9a00784 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,7 @@ Chaque fiche détaille : rôle, API, notifications, points de validation, synerg | QA moniteur série | protocole_test_qa_moniteur_serie.md | | Robustesse RTOS | audit_robustesse_rtos.md | | Sécurité web | audit_securite_web.md | +| Gate qualité | branch_quality_gate.md | ## Rapports | Rapport | Fichier | diff --git a/docs/branch_quality_gate.md b/docs/branch_quality_gate.md new file mode 100644 index 0000000..c9675c3 --- /dev/null +++ b/docs/branch_quality_gate.md @@ -0,0 +1,39 @@ +# Gate de branche (pré-validation) + +Ce document décrit l’ordonnancement unique des contrôles de conformité avant transfert. + +## Ordre d’exécution + +Le script `scripts/pre_merge.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` +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` + +## Cible CI + +- `.github/workflows/ci.yml` lance directement `scripts/pre_merge.sh`. +- L’artefact `artifacts/route_parity_report.json` est uploadé automatiquement. + +## Options utiles + +- `--skip-builds` : ignore uniquement la phase build. +- `--build-env ` : ajoute un env PlatformIO spécifique. +- `--build-envs ` : ajoute plusieurs envs. +- `--report-json ` : redirige le rapport parity JSON. + +Exemples : + +```bash +bash scripts/pre_merge.sh --skip-builds +bash scripts/pre_merge.sh --build-env esp32dev --build-env esp32-s3-devkitc-1 +``` diff --git a/platformio.ini b/platformio.ini index b219974..43311dc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -14,8 +14,11 @@ build_flags = lib_deps = bblanchon/ArduinoJson@^7.0.4 throwtheswitch/Unity@^2.6.1 + ESP32Async/AsyncTCP@^3.3.2 + ESP32Async/ESPAsyncWebServer@^3.6.0 https://github.com/pschatzmann/arduino-audio-tools.git https://github.com/bitluni/OsciDisplay.git + knolleary/PubSubClient@^2.8 lib_ignore = ESPAsyncTCP RPAsyncTCP @@ -43,6 +46,8 @@ build_src_filter = + + + + + + + [env:test] platform = espressif32 @@ -80,6 +85,8 @@ build_src_filter = + + + + + + + [env:esp32-s3-usb-host] board = esp32s3usbotg @@ -104,6 +111,8 @@ build_src_filter = + + + + + + + [env:esp32-s3-usb-msc] board = esp32-s3-devkitc-1 @@ -133,3 +142,5 @@ build_src_filter = + + + + + + + diff --git a/scripts/check_web_route_parity.py b/scripts/check_web_route_parity.py old mode 100755 new mode 100644 index b0c5e08..3d8e75c --- a/scripts/check_web_route_parity.py +++ b/scripts/check_web_route_parity.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check parity between backend API routes and WebUI API calls.""" +"""Check parity between frontend calls and backend routes/commands.""" from __future__ import annotations @@ -7,36 +7,59 @@ import argparse import json import re import sys +from dataclasses import dataclass from pathlib import Path -from typing import Iterable +from typing import Iterable, Optional Route = tuple[str, str] + +@dataclass(frozen=True) +class RouteCommand: + route: Route + command: Optional[str] + dynamic: bool + + BACKEND_ROUTE_RE = re.compile( - r'server_\.on\(\s*"(?P/api/[^"]+)"\s*,\s*HTTP_(?P[A-Z]+)' + r'server_\.on\(\s*"(?P/api/[^\"]+)"\s*,\s*HTTP_(?P[A-Z]+)' ) +BACKEND_DISPATCH_RE = re.compile( + r'handleDispatch\(\s*request\s*,\s*"(?P[A-Z0-9_]+)' +) +BACKEND_DISPATCH_DYNAMIC_RE = re.compile( + r'handleDispatch\(\s*request\s*,\s*[A-Za-z_][A-Za-z0-9_]*' +) +COMMAND_REG_RE = re.compile(r'registerCommand\(\s*"(?P[A-Z0-9_]+)"') FRONTEND_CALL_START_RE = re.compile(r"\brequestJson\(") -FRONTEND_PATH_ARG_RE = re.compile(r'^\s*(["\'])(?P/api/[^"\']+)\1') -METHOD_RE = re.compile(r'method\s*:\s*["\'](?P[A-Z]+)["\']') +FRONTEND_PATH_ARG_RE = re.compile(r"^\s*([\"'])(?P/api/[^\"']+)\1") +METHOD_RE = re.compile(r"method\s*:\s*[\"'](?P[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_delim(source: str, open_index: int, open_delim: str, close_delim: str) -> int: + if open_index < 0 or source[open_index] != open_delim: + return -1 - -def find_matching_paren(source: str, open_paren_index: int) -> int: depth = 1 in_string: str | None = None + in_single_line_comment = False + in_multi_line_comment = False escaped = False - for index in range(open_paren_index + 1, len(source)): + for index in range(open_index + 1, len(source)): char = source[index] + next_char = source[index + 1] if index + 1 < len(source) else "" + + if in_single_line_comment: + if char == "\n": + in_single_line_comment = False + continue + + if in_multi_line_comment: + if char == "*" and next_char == "/": + in_multi_line_comment = False + continue if in_string is not None: if escaped: @@ -49,14 +72,23 @@ def find_matching_paren(source: str, open_paren_index: int) -> int: in_string = None continue - if char in ('"', "'", "`"): + if char in ('"', "'"): in_string = char continue - if char == "(": + if char == "/" and next_char == "/": + in_single_line_comment = True + continue + + if char == "/" and next_char == "*": + in_multi_line_comment = True + continue + + if char == open_delim: depth += 1 continue - if char == ")": + + if char == close_delim: depth -= 1 if depth == 0: return index @@ -64,11 +96,52 @@ def find_matching_paren(source: str, open_paren_index: int) -> int: return -1 +def parse_backend_route_commands(source: str) -> dict[Route, RouteCommand]: + routes: dict[Route, RouteCommand] = {} + for match in BACKEND_ROUTE_RE.finditer(source): + route = (match.group("method"), match.group("path")) + if route in routes: + continue + + callback_open = source.find("{", match.end()) + if callback_open < 0: + routes[route] = RouteCommand(route=route, command=None, dynamic=False) + continue + + callback_close = find_matching_delim(source, callback_open, "{", "}") + if callback_close < 0: + routes[route] = RouteCommand(route=route, command=None, dynamic=False) + continue + + callback_block = source[callback_open:callback_close] + cmd_match = BACKEND_DISPATCH_RE.search(callback_block) + if cmd_match: + routes[route] = RouteCommand( + route=route, + command=cmd_match.group("command"), + dynamic=False, + ) + continue + + dynamic_match = BACKEND_DISPATCH_DYNAMIC_RE.search(callback_block) + routes[route] = RouteCommand(route=route, command=None, dynamic=bool(dynamic_match)) + + return routes + + +def parse_backend_routes(source: str) -> set[Route]: + return set(parse_backend_route_commands(source).keys()) + + 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) + open_paren = source.find("(", match.start()) + if open_paren < 0: + continue + + close_paren = find_matching_delim(source, open_paren, "(", ")") if close_paren < 0: continue @@ -84,6 +157,10 @@ def parse_frontend_routes(source: str) -> set[Route]: return routes +def parse_registered_commands(source: str) -> set[str]: + return {match.group("command") for match in COMMAND_REG_RE.finditer(source)} + + 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) @@ -94,14 +171,34 @@ def routes_to_payload(routes: Iterable[Route]) -> list[dict[str, str]]: return [{"method": method, "path": path} for method, path in ordered] +def missing_commands_to_payload(missing: Iterable[RouteCommand]) -> list[dict[str, object]]: + ordered = sorted(missing, key=lambda rc: (rc.route[1], rc.route[0])) + payload = [] + for item in ordered: + payload.append({ + "method": item.route[0], + "path": item.route[1], + "command": item.command or "", + "dynamic": item.dynamic, + }) + return payload + + def build_report( backend_routes: set[Route], frontend_routes: set[Route], missing_in_backend: set[Route], unused_backend: set[Route], + missing_commands: set[RouteCommand], strict_unused_backend: bool, status: str, ) -> dict[str, object]: + dynamic_routes = sorted( + [rc for rc in missing_commands if rc.dynamic], + key=lambda rc: (rc.route[1], rc.route[0]), + ) + missing_static_commands = [rc for rc in missing_commands if not rc.dynamic] + return { "backend_count": len(backend_routes), "frontend_count": len(frontend_routes), @@ -109,27 +206,32 @@ def build_report( "frontend_routes": routes_to_payload(frontend_routes), "missing_in_backend": routes_to_payload(missing_in_backend), "unused_backend": routes_to_payload(unused_backend), + "missing_mapped_commands": missing_commands_to_payload(set(missing_static_commands)), + "dynamic_routes": missing_commands_to_payload(set(dynamic_routes)), "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 collect_missing_commands( + backend_command_map: dict[Route, RouteCommand], + registered_commands: set[str], +) -> set[RouteCommand]: + missing: set[RouteCommand] = set() + for item in backend_command_map.values(): + if item.command is None: + continue + if item.command in registered_commands: + continue + missing.add(item) + return missing def main() -> int: parser = argparse.ArgumentParser( - description="Validate that every WebUI API call is backed by a firmware HTTP route." + description=( + "Validate that every WebUI API call is backed by a firmware HTTP route and command binding." + ) ) parser.add_argument( "--backend", @@ -141,6 +243,11 @@ def main() -> int: default="data/webui/script.js", help="Path to frontend source file.", ) + parser.add_argument( + "--commands", + default="src/main.cpp", + help="Source file containing registerCommand() calls.", + ) parser.add_argument( "--strict-unused-backend", action="store_true", @@ -155,11 +262,18 @@ def main() -> int: backend_path = Path(args.backend) frontend_path = Path(args.frontend) + command_path = Path(args.commands) backend_source = load_text(backend_path) frontend_source = load_text(frontend_path) + command_source = load_text(command_path) - backend_routes = parse_backend_routes(backend_source) + backend_route_commands = parse_backend_route_commands(backend_source) + backend_routes = set(backend_route_commands.keys()) frontend_routes = parse_frontend_routes(frontend_source) + registered_commands = parse_registered_commands(command_source) + missing_in_backend = frontend_routes - backend_routes + unused_backend = backend_routes - frontend_routes + missing_commands = collect_missing_commands(backend_route_commands, registered_commands) if not backend_routes: print("[route-parity] no backend /api routes detected", file=sys.stderr) @@ -167,8 +281,9 @@ def main() -> int: report = build_report( backend_routes, frontend_routes, - missing_in_backend=frontend_routes - backend_routes, - unused_backend=backend_routes - frontend_routes, + missing_in_backend=missing_in_backend, + unused_backend=unused_backend, + missing_commands=missing_commands, strict_unused_backend=args.strict_unused_backend, status="fail", ) @@ -180,70 +295,79 @@ def main() -> int: report = build_report( backend_routes, frontend_routes, - missing_in_backend=frontend_routes - backend_routes, - unused_backend=backend_routes - frontend_routes, + missing_in_backend=missing_in_backend, + unused_backend=unused_backend, + missing_commands=missing_commands, 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 + 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 missing_commands: + print("[route-parity] backend routes mapped to unregistered commands:", file=sys.stderr) + for item in sorted(missing_commands, key=lambda rc: (rc.route[1], rc.route[0])): + print(f" - {item.route[0]} {item.route[1]} -> {item.command}", file=sys.stderr) + + if args.strict_unused_backend and unused_backend: + print("[route-parity] backend routes currently unused by WebUI:", file=sys.stderr) + print(format_routes(unused_backend), file=sys.stderr) 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 missing_in_backend or (args.strict_unused_backend and unused_backend) or missing_commands: + status = "fail" 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( + write_report_json( + Path(args.report_json), + 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) + missing_in_backend=missing_in_backend, + unused_backend=unused_backend, + missing_commands=missing_commands, + strict_unused_backend=args.strict_unused_backend, + status=status, + ), + ) + return 1 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), + build_report( + backend_routes, + frontend_routes, + missing_in_backend=missing_in_backend, + unused_backend=unused_backend, + missing_commands=missing_commands, + strict_unused_backend=args.strict_unused_backend, + status="pass", + ), ) - write_report_json(Path(args.report_json), report) print("[route-parity] parity check passed") return 0 +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 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") + + if __name__ == "__main__": sys.exit(main()) diff --git a/scripts/pre_merge.sh b/scripts/pre_merge.sh new file mode 100755 index 0000000..4316026 --- /dev/null +++ b/scripts/pre_merge.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +DEFAULT_BUILD_ENVS=( + "esp32dev" + "esp32-s3-devkitc-1" + "esp32-s3-usb-host" + "esp32-s3-usb-msc" +) +REPORT_JSON="${ARTIFACT_REPORT_PATH:-artifacts/route_parity_report.json}" + +BUILD_ENVS=() +SKIP_BUILDS=0 + +log() { + echo "[pre-merge] $*" +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "[pre-merge] erreur: commande manquante: $1" >&2 + exit 1 + fi +} + +usage() { + cat <<'EOF' +Usage: scripts/pre_merge.sh [options] + +Exécute la chaîne de vérification pré-merge dans un ordre déterministe. + +Options: + --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 : + esp32dev, esp32-s3-devkitc-1, esp32-s3-usb-host, esp32-s3-usb-msc +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --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 "[pre-merge] 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" + python3 -m unittest scripts/test_check_web_route_parity.py scripts/test_runtime_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 + BUILD_ENVS=("${DEFAULT_BUILD_ENVS[@]}") + fi + + 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 "pré-merge terminé avec succès" diff --git a/scripts/test_check_web_route_parity.py b/scripts/test_check_web_route_parity.py index 5249785..99485de 100644 --- a/scripts/test_check_web_route_parity.py +++ b/scripts/test_check_web_route_parity.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Unit tests for check_web_route_parity parser helpers.""" +"""Unit tests for web route / command parity checker.""" from __future__ import annotations @@ -8,125 +8,161 @@ import tempfile import unittest from pathlib import Path -from scripts.check_web_route_parity import build_report, parse_frontend_routes, write_report_json +from scripts.check_web_route_parity import ( + RouteCommand, + build_report, + collect_missing_commands, + parse_backend_route_commands, + parse_frontend_routes, + parse_registered_commands, + write_report_json, +) -class ParseFrontendRoutesTest(unittest.TestCase): - def test_detects_direct_calls(self) -> None: +class RouteParsingTest(unittest.TestCase): + def test_detects_frontend_request_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([ + const [wifi, mqtt] = await Promise.all([ requestJson("/api/network/wifi"), - requestJson("/api/network/mqtt"), - requestJson("/api/network/espnow"), - requestJson("/api/network/espnow/peer"), + requestJson("/api/network/mqtt", { method: "POST" }), ]); """ 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) + self.assertIn(("POST", "/api/network/mqtt"), routes) - def test_handles_nested_parentheses_in_payload(self) -> None: + def test_detects_frontend_payload_requests(self) -> None: source = """ await requestJson("/api/network/mqtt/publish", { method: "POST", - body: JSON.stringify({ - topic: "rtc/test", - payload: JSON.stringify({ ping: true }), - }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic: "t" }), }); """ routes = parse_frontend_routes(source) self.assertIn(("POST", "/api/network/mqtt/publish"), routes) + def test_detects_single_quotes(self) -> None: + source = """ + await requestJson('/api/network/wifi/scan', { method: 'POST' }); + """ + routes = parse_frontend_routes(source) + self.assertIn(("POST", "/api/network/wifi/scan"), 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", + +class BackendParsingTest(unittest.TestCase): + def test_backend_route_mapping_extracts_command_id(self) -> None: + source = """ + server_.on("/api/network/wifi/connect", HTTP_POST, [this](AsyncWebServerRequest* request) { + handleDispatch(request, "WIFI_CONNECT " + quoteArg(ssid)); + }); + """ + routes = parse_backend_route_commands(source) + self.assertEqual( + routes.get(("POST", "/api/network/wifi/connect")), + RouteCommand(route=("POST", "/api/network/wifi/connect"), command="WIFI_CONNECT", dynamic=False), ) - 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_backend_route_detects_dynamic_dispatch(self) -> None: + source = """ + server_.on("/api/dispatch", HTTP_POST, [this](AsyncWebServerRequest* request) { + handleDispatch(request, command_line); + }); + """ + routes = parse_backend_route_commands(source) + self.assertEqual( + routes.get(("POST", "/api/dispatch")), + RouteCommand(route=("POST", "/api/dispatch"), command=None, dynamic=True), + ) - 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"), + def test_registered_command_detection(self) -> None: + source = """ + g_dispatcher.registerCommand("WIFI_CONNECT", [](const String&) {}); + g_dispatcher.registerCommand("MQTT_STATUS", [](const String&) {}); + g_dispatcher.registerCommand("PLAY", [](const String&) {}); + """ + commands = parse_registered_commands(source) + self.assertEqual(commands, {"WIFI_CONNECT", "MQTT_STATUS", "PLAY"}) + + +class ParityReportTest(unittest.TestCase): + def test_collects_missing_static_commands(self) -> None: + backend = { + ("POST", "/api/network/wifi/connect"): RouteCommand( + route=("POST", "/api/network/wifi/connect"), command="WIFI_CONNECT", dynamic=False + ), + ("POST", "/api/control"): RouteCommand( + route=("POST", "/api/control"), command="UNKNOWN", dynamic=False + ), + ("POST", "/api/relay"): RouteCommand( + route=("POST", "/api/relay"), command="ESPNOW_SEND", dynamic=True + ), } + registered = {"WIFI_CONNECT"} + missing = collect_missing_commands(backend, registered) + + command_payload = {m.command for m in missing if not m.dynamic} + dynamic_payload = {m.route for m in missing if m.dynamic} + self.assertIn("UNKNOWN", command_payload) + self.assertIn(("POST", "/api/relay"), dynamic_payload) + + def test_build_report_includes_mapping_fields(self) -> None: + backend = { + ("GET", "/api/status"), + } + frontend = { + ("GET", "/api/status"), + ("POST", "/api/network/wifi/scan"), + } + missing_in_backend = frontend - backend + backend_commands = { + ("GET", "/api/status"): RouteCommand(("GET", "/api/status"), "STATUS", False), + ("POST", "/api/network/wifi/scan"): RouteCommand( + ("POST", "/api/network/wifi/scan"), "WIFI_SCAN", False + ), + ("POST", "/api/network/espnow/send"): RouteCommand( + ("POST", "/api/network/espnow/send"), "ESPNOW_SEND", False + ), + } + missing_commands = collect_missing_commands(backend_commands, {"STATUS"}) report = build_report( - backend_routes=backend_routes, - frontend_routes=set(), - missing_in_backend=set(), - unused_backend=backend_routes, - strict_unused_backend=True, + backend_routes=backend, + frontend_routes=frontend, + missing_in_backend=missing_in_backend, + unused_backend=set(), + missing_commands=missing_commands, + strict_unused_backend=False, status="fail", ) + self.assertIn("missing_mapped_commands", report) + self.assertIn("dynamic_routes", report) self.assertEqual( - report["backend_routes"], + report["missing_mapped_commands"], [ - {"method": "DELETE", "path": "/api/network/espnow/peer"}, - {"method": "POST", "path": "/api/network/mqtt/connect"}, - {"method": "GET", "path": "/api/status"}, + { + "command": "ESPNOW_SEND", + "dynamic": False, + "method": "POST", + "path": "/api/network/espnow/send", + }, + { + "command": "WIFI_SCAN", + "dynamic": False, + "method": "POST", + "path": "/api/network/wifi/scan", + } ], ) - - 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: + def test_write_report_json_writes_json(self) -> None: report = build_report( backend_routes={("GET", "/api/status")}, frontend_routes={("GET", "/api/status")}, missing_in_backend=set(), unused_backend=set(), + missing_commands=set(), strict_unused_backend=False, status="pass", ) @@ -136,6 +172,7 @@ class ReportParityJsonTest(unittest.TestCase): loaded = json.loads(path.read_text(encoding="utf-8")) self.assertEqual(loaded["status"], "pass") self.assertEqual(loaded["backend_count"], 1) + self.assertEqual(loaded["missing_mapped_commands"], []) if __name__ == "__main__": diff --git a/scripts/test_runtime_contracts.py b/scripts/test_runtime_contracts.py new file mode 100644 index 0000000..92eba82 --- /dev/null +++ b/scripts/test_runtime_contracts.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Extra contract tests for firmware runtime/API guards.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def read_source(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +def extract_route_block(source: str, route: str) -> str: + marker = f'server_.on("{route}"' + start = source.find(marker) + assert start >= 0, f"route {route} not found" + + brace_start = source.find("{", start) + if brace_start < 0: + return source[start : start + 4096] + + depth = 0 + in_line_comment = False + in_block_comment = False + in_string = None + in_char = False + escaped = False + + end = brace_start + for index in range(brace_start, len(source)): + ch = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + + if in_line_comment: + if ch == "\n": + in_line_comment = False + continue + + if in_block_comment: + if ch == "*" and nxt == "/": + in_block_comment = False + continue + + if in_string is not None: + if escaped: + escaped = False + continue + if ch == "\\": + escaped = True + continue + if ch == '"': + in_string = None + continue + + if in_char: + if escaped: + escaped = False + continue + if ch == "\\": + escaped = True + continue + if ch == "'": + in_char = False + continue + + if ch == '/' and nxt == '/': + in_line_comment = True + continue + if ch == '/' and nxt == '*': + in_block_comment = True + continue + if ch == '"': + in_string = '"' + continue + if ch == "'": + in_char = True + continue + if ch == '{': + depth += 1 + continue + if ch == '}': + depth -= 1 + if depth == 0: + end = index + break + + return source[start : end + 1] + + +def read_main() -> str: + return read_source("src", "main.cpp") + + +def read_webserver() -> str: + return read_source("src", "web", "WebServerManager.cpp") + + +def read_dispatcher() -> str: + return read_source("src", "config", "A252ConfigStore.cpp") + + +class RuntimeContractTests(unittest.TestCase): + def test_unknown_dispatched_command_is_rejected(self) -> None: + src = read_webserver() + self.assertIn("!isCommandRegistered(command_line, command_validator_)", src) + self.assertIn("unsupported_command", src) + + def test_espnow_send_requires_explicit_mac(self) -> None: + src = read_webserver() + block = extract_route_block(src, "/api/network/espnow/send") + self.assertIn('const String mac = doc["mac"] | "";', block) + self.assertIn("isValidInput(mac, 32)", block) + self.assertIn("ESPNOW_SEND", block) + + def test_wifi_loop_is_invoked(self) -> None: + src = read_main() + self.assertRegex(src, r"\bg_wifi\.loop\(\);") + + def test_mqtt_publish_route_maps_to_registered_command(self) -> None: + web = read_webserver() + self.assertIn('handleDispatch(request, "MQTT_PUBLISH "', web) + main = read_main() + self.assertIn('registerCommand("MQTT_PUBLISH",', main) + + def test_auth_is_disabled_for_dispatch_paths_by_default(self) -> None: + main = read_main() + self.assertIn("kWebAuthEnabledByDefault", main) + web = read_webserver() + self.assertIn("kWebAuthEnabledByDefault", main) + self.assertIn("kWebAuthEnabledByDefault = false", main) + self.assertIn("authenticateRequest(request)", web) + + def test_dev_auth_override_is_local_flagged(self) -> None: + main = read_main() + self.assertIn("RTC_WEB_AUTH_DEV_DISABLE", main) + self.assertIn("!kWebAuthLocalDisableEnabled", main) + + def test_gpio_validation_blocks_invalid_values_in_source(self) -> None: + src = read_dispatcher() + self.assertIn("const int required_pins[]", src) + self.assertIn("pin < 0", src) + self.assertIn("cfg.slic_adc_in", src) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_terminal.sh b/scripts/test_terminal.sh index 72b9072..ff2d4d1 100755 --- a/scripts/test_terminal.sh +++ b/scripts/test_terminal.sh @@ -1,21 +1,5 @@ #!/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" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/pre_merge.sh" --skip-builds "$@" diff --git a/src/config/A252ConfigStore.cpp b/src/config/A252ConfigStore.cpp index a3e8da4..e2d53f5 100644 --- a/src/config/A252ConfigStore.cpp +++ b/src/config/A252ConfigStore.cpp @@ -417,17 +417,14 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) { cfg.slic_fr, cfg.slic_shk, cfg.slic_pd, + cfg.slic_adc_in, cfg.pcm_flt, cfg.pcm_demp, cfg.pcm_xsmt, cfg.pcm_fmt, - cfg.slic_adc_in, }; for (int pin : required_pins) { - if (pin < 0) { - continue; - } if (pin < 0 || pin > 39) { error = "invalid_pin_range"; return false; diff --git a/src/core/CommandDispatcher.cpp b/src/core/CommandDispatcher.cpp index 563fd82..868505e 100644 --- a/src/core/CommandDispatcher.cpp +++ b/src/core/CommandDispatcher.cpp @@ -43,6 +43,10 @@ DispatchResponse CommandDispatcher::dispatch(const String& line) const { return it->second(args); } +bool CommandDispatcher::hasCommand(const String& name) const { + return handlers_.find(normalizeCommand(name)) != handlers_.end(); +} + String CommandDispatcher::helpText() const { String out; out.reserve(order_.size() * 24); diff --git a/src/core/CommandDispatcher.h b/src/core/CommandDispatcher.h index ad5b113..3ae8797 100644 --- a/src/core/CommandDispatcher.h +++ b/src/core/CommandDispatcher.h @@ -20,6 +20,7 @@ public: void registerCommand(const String& name, Handler handler); DispatchResponse dispatch(const String& line) const; + bool hasCommand(const String& name) const; String helpText() const; std::vector commands() const; diff --git a/src/main.cpp b/src/main.cpp index 9ba0c8a..8b287a8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,8 +9,10 @@ #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" #include "visual/ScopeDisplay.h" #include "wifi/WifiManagerInstance.h" #include "usb/UsbHostRuntime.h" @@ -23,12 +25,21 @@ 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. +constexpr bool kWebAuthEnabledByDefault = false; + +#ifdef RTC_WEB_AUTH_DEV_DISABLE +constexpr bool kWebAuthLocalDisableEnabled = true; +#else +constexpr bool kWebAuthLocalDisableEnabled = false; +#endif BoardProfile g_profile = detectBoardProfile(); 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; @@ -40,9 +51,11 @@ 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; DispatchResponse makeResponse(bool ok, const String& code) { DispatchResponse res; @@ -453,10 +466,17 @@ void applyPcm5102ControlPins(const A252PinsConfig& pins_cfg) { } bool applyHardwareConfig() { + 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()); + return false; + } + + auto u8_pin = [](int pin) { return static_cast(pin); }; const SlicPins slic_pins = { - .pin_rm = static_cast(g_pins_cfg.slic_rm), - .pin_fr = static_cast(g_pins_cfg.slic_fr), - .pin_shk = static_cast(g_pins_cfg.slic_shk), + .pin_rm = u8_pin(g_pins_cfg.slic_rm), + .pin_fr = u8_pin(g_pins_cfg.slic_fr), + .pin_shk = u8_pin(g_pins_cfg.slic_shk), .pin_line_enable = static_cast(-1), .pin_pd = static_cast(g_pins_cfg.slic_pd), .hook_active_high = g_pins_cfg.hook_active_high, @@ -554,6 +574,10 @@ 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 config = root["config"].to(); A252ConfigStore::pinsToJson(g_pins_cfg, config["pins"].to()); A252ConfigStore::audioToJson(g_audio_cfg, config["audio"].to()); @@ -738,6 +762,41 @@ 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"); @@ -857,6 +916,26 @@ void registerCommands() { return jsonResponse(doc); }); + g_dispatcher.registerCommand("WIFI_CONNECT", [](const String& args) { + String ssid; + String password; + if (!splitFirstToken(args, ssid, password)) { + return makeResponse(false, "WIFI_CONNECT invalid_args"); + } + if (ssid.isEmpty()) { + return makeResponse(false, "WIFI_CONNECT invalid_ssid"); + } + const bool ok = g_wifi.connect(ssid, password); + return makeResponse(ok, ok ? "WIFI_CONNECT" : "WIFI_CONNECT failed"); + }); + + g_dispatcher.registerCommand("WIFI_SCAN", [](const String&) { + JsonDocument doc; + JsonArray networks = doc.to(); + g_wifi.scanToJson(networks, 20); + return jsonResponse(doc); + }); + g_dispatcher.registerCommand("WIFI_DISCONNECT", [](const String&) { g_wifi.disconnect(false); return makeResponse(true, "WIFI_DISCONNECT"); @@ -867,6 +946,112 @@ 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"); @@ -1309,6 +1494,17 @@ void pollSerial() { } } +void configureCommandServer() { + g_web_server.setCommandExecutor(executeCommandLine); + g_web_server.setCommandValidator([](const String& command_id) { + return g_dispatcher.hasCommand(command_id); + }); + g_web_server.setAuthEnabled(kWebAuthEnabledByDefault && !kWebAuthLocalDisableEnabled); + g_web_server.setStatusCallback([](JsonObject obj) { + fillStatusSnapshot(obj); + }); +} + } // namespace void setup() { @@ -1336,6 +1532,7 @@ 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)) { @@ -1353,6 +1550,12 @@ void setup() { 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(); Serial.printf("[RTC_BL_PHONE] Boot: profile=%s full_duplex=%s\n", boardProfileToString(g_profile), @@ -1363,8 +1566,11 @@ void setup() { } void loop() { + g_wifi.loop(); g_telephony.tick(); g_scope_display.tick(); + g_web_server.handle(); + g_props_bridge.tick(); g_espnow.tick(); pollSerial(); delay(1); diff --git a/src/props/EspNowBridge.cpp b/src/props/EspNowBridge.cpp index 23cea13..bf516f8 100644 --- a/src/props/EspNowBridge.cpp +++ b/src/props/EspNowBridge.cpp @@ -20,6 +20,23 @@ void enforceEspNowCoexPolicy() { static_cast(err)); } } + +bool isBroadcastTarget(const String& target) { + return target.equalsIgnoreCase("broadcast"); +} + +bool parseTargetMac(const String& target, uint8_t out[6], bool& is_broadcast) { + const String normalized = A252ConfigStore::normalizeMac(target); + is_broadcast = false; + if (isBroadcastTarget(target)) { + is_broadcast = true; + return true; + } + if (normalized.isEmpty()) { + return false; + } + return A252ConfigStore::parseMac(normalized, out); +} } EspNowBridge::EspNowBridge() { @@ -89,19 +106,49 @@ const std::vector& EspNowBridge::peers() const { bool EspNowBridge::sendJson(const String& target, const String& json_payload) { if (!ready_) { + Serial.printf("[EspNowBridge] send rejected: bridge not started\n"); + tx_fail_++; + return false; + } + + String normalized_target = target; + normalized_target.trim(); + if (normalized_target.isEmpty()) { + Serial.printf("[EspNowBridge] send rejected: empty target\n"); + tx_fail_++; return false; } if (json_payload.length() > kEspNowMaxPayloadBytes) { Serial.printf("[EspNowBridge] send rejected: payload too large=%u bytes\n", static_cast(json_payload.length())); + tx_fail_++; return false; } - (void)target; + bool is_broadcast = false; + uint8_t target_mac[6] = {0}; + if (!parseTargetMac(normalized_target, target_mac, is_broadcast)) { + Serial.printf("[EspNowBridge] send rejected: invalid target '%s'\n", normalized_target.c_str()); + tx_fail_++; + return false; + } - const uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; - return sendToMac(broadcast_mac, json_payload); + if (!is_broadcast) { + const String normalized_source = A252ConfigStore::normalizeMac(normalized_target); + if (std::find(store_.peers.begin(), store_.peers.end(), normalized_source) == store_.peers.end()) { + Serial.printf("[EspNowBridge] send rejected: target not configured '%s'\n", normalized_source.c_str()); + tx_fail_++; + return false; + } + } + + if (is_broadcast) { + const uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + return sendToMac(broadcast_mac, json_payload); + } + + return sendToMac(target_mac, json_payload); } bool EspNowBridge::isReady() const { diff --git a/src/web/WebServerManager 2.cpp b/src/web/WebServerManager 2.cpp deleted file mode 100644 index c1005f5..0000000 --- a/src/web/WebServerManager 2.cpp +++ /dev/null @@ -1,451 +0,0 @@ -#include "web/WebServerManager.h" - -#ifdef USB_MSC_BOOT_ENABLE -#include -#else -#include -#endif - -namespace { -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), - events_("/api/events"), - rate_limit_ms_(250), - last_status_push_ms_(0), - status_cache_json_(""), - status_cache_ready_(false), - status_cache_mux_(portMUX_INITIALIZER_UNLOCKED), - auth_enabled_(false), - auth_user_("admin"), - auth_pass_("admin") {} - -void WebServerManager::begin() { -#ifdef USB_MSC_BOOT_ENABLE - if (FFat.begin(true, "/usbmsc", 10, "usbmsc")) { - server_.serveStatic("/", FFat, "/webui/").setDefaultFile("index.html"); - } else { - Serial.println("[WebServerManager] FFat mount failed (label usbmsc)"); - } -#else - if (!SPIFFS.begin(true)) { - Serial.println("[WebServerManager] SPIFFS mount failed"); - } else { - server_.serveStatic("/", SPIFFS, "/webui/").setDefaultFile("index.html"); - } -#endif - - registerRoutes(); - server_.begin(); - Serial.println("[WebServerManager] HTTP server started"); -} - -void WebServerManager::handle() { - 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; -} - -void WebServerManager::setAuthEnabled(bool enabled) { - if (kForceAuthDisabled) { - auth_enabled_ = false; - return; - } - auth_enabled_ = enabled; -} - -bool WebServerManager::isAuthEnabled() const { - return auth_enabled_; -} - -void WebServerManager::setRateLimitMs(uint32_t rate_limit_ms) { - rate_limit_ms_ = rate_limit_ms; -} - -void WebServerManager::setStatusCallback(std::function callback) { - status_callback_ = std::move(callback); -} - -void WebServerManager::setCommandExecutor(std::function 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) { - bool ready = false; - const String cached = snapshotStatusCache(&ready); - if (ready) { - request->send(200, "application/json", cached); - return; - } - - 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) { - JsonDocument doc; - if (!extractJsonBody(request, doc)) { - request->send(400, "application/json", "{\"error\":\"invalid json body\"}"); - return; - } - const String action = doc["action"] | ""; - if (!isValidInput(action, 128)) { - request->send(400, "application/json", "{\"error\":\"invalid action\"}"); - return; - } - handleDispatch(request, action); - }); - - // 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; - } - 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 input_mac = doc["mac"] | "broadcast"; - String mac = "broadcast"; - if (!input_mac.isEmpty() && !input_mac.equalsIgnoreCase("broadcast")) { - Serial.println(F("[WebServer] ESPNOW send mac field is ignored and normalized to broadcast")); - } - String payload; - JsonVariantConst payload_variant = doc["payload"].as(); - bool already_enveloped = false; - if (payload_variant.is()) { - JsonObjectConst payload_obj = payload_variant.as(); - already_enveloped = payload_obj["msg_id"].is() && payload_obj["type"].is(); - } - - 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(); - } - serializeJson(envelope, payload); - } - handleDispatch(request, "ESPNOW_SEND " + mac + " " + payload); - }); - -bool WebServerManager::authenticateRequest(AsyncWebServerRequest* request) const { - if (kForceAuthDisabled || !auth_enabled_) { - return true; - } - if (!request->authenticate(auth_user_.c_str(), auth_pass_.c_str())) { - request->requestAuthentication(); - return false; - } - return true; -} - -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; - } - 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) { - if (value.isEmpty() || value.length() > max_len) { - return false; - } - for (size_t i = 0; i < value.length(); ++i) { - const char c = value[i]; - if (c < 32 || c > 126) { - return false; - } - } - return true; -} - -bool WebServerManager::isEffectCommand(const String& command_line) { - String token = command_line; - const int sep = token.indexOf(' '); - if (sep > 0) { - token = token.substring(0, sep); - } - token.trim(); - token.toUpperCase(); - - return token == "CALL" || token == "PLAY" || token == "CAPTURE_START" || token == "CAPTURE_STOP"; -} - -void WebServerManager::refreshStatusCache() { - if (!status_callback_) { - portENTER_CRITICAL(&status_cache_mux_); - status_cache_ready_ = false; - status_cache_json_ = ""; - portEXIT_CRITICAL(&status_cache_mux_); - return; - } - - JsonDocument doc; - doc["auth_enabled"] = isAuthEnabled(); - status_callback_(doc.to()); - const String payload = toJsonString(doc); - - portENTER_CRITICAL(&status_cache_mux_); - status_cache_json_ = payload; - status_cache_ready_ = true; - portEXIT_CRITICAL(&status_cache_mux_); -} - -String WebServerManager::snapshotStatusCache(bool* ready) { - portENTER_CRITICAL(&status_cache_mux_); - const bool has_data = status_cache_ready_; - const String payload = status_cache_json_; - portEXIT_CRITICAL(&status_cache_mux_); - if (ready != nullptr) { - *ready = has_data; - } - return payload; -} - -void WebServerManager::publishRealtimeEvent(const char* event_name, const String& payload_json) { - if (!kEnableRealtimeEvents) { - return; - } - events_.send(payload_json.c_str(), event_name, millis()); -} - -void WebServerManager::publishRealtimeStatus() { - bool ready = false; - const String cached = snapshotStatusCache(&ready); - if (!ready) { - return; - } - publishRealtimeEvent("status", cached); -} - -void WebServerManager::publishDispatchEvent(const String& command_line, const DispatchResponse& res) { - JsonDocument doc; - doc["command"] = command_line; - doc["ok"] = res.ok; - if (!res.code.isEmpty()) { - doc["code"] = res.code; - } - if (!res.raw.isEmpty()) { - doc["raw"] = res.raw; - } - if (!res.json.isEmpty()) { - JsonDocument parsed; - if (deserializeJson(parsed, res.json) == DeserializationError::Ok) { - doc["json"].set(parsed.as()); - } 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; - } - - 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)); - } - - publishDispatchEvent(command_line, res); -} diff --git a/src/web/WebServerManager 2.h b/src/web/WebServerManager 2.h deleted file mode 100644 index 52a4151..0000000 --- a/src/web/WebServerManager 2.h +++ /dev/null @@ -1,58 +0,0 @@ -#ifndef WEB_WEB_SERVER_MANAGER_H -#define WEB_WEB_SERVER_MANAGER_H - -#include -#include -#include -#include - -#include - -#include "core/CommandDispatcher.h" - -class WebServerManager { -public: - explicit WebServerManager(uint16_t port = 80); - void begin(); - void handle(); - - void setAuthCredentials(const String& user, const String& pass, bool persist_to_nvs = false); - void setAuthEnabled(bool enabled); - bool isAuthEnabled() const; - void setRateLimitMs(uint32_t rate_limit_ms); - - void setStatusCallback(std::function callback); - void setCommandExecutor(std::function 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_; - std::function status_callback_; - std::function command_executor_; - - void registerRoutes(); - bool authenticateRequest(AsyncWebServerRequest* request) const; - static bool extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc); - static String toJsonString(const JsonDocument& doc); - static bool isValidInput(const String& value, size_t max_len); - static bool isEffectCommand(const String& command_line); - String snapshotStatusCache(bool* ready = nullptr); - void refreshStatusCache(); - void publishRealtimeEvent(const char* event_name, const String& payload_json); - void publishRealtimeStatus(); - void publishDispatchEvent(const String& command_line, const DispatchResponse& res); - void handleDispatch(AsyncWebServerRequest* request, - const String& command_line, - uint16_t success_code = 200, - uint16_t error_code = 400); -}; - -#endif // WEB_WEB_SERVER_MANAGER_H diff --git a/src/web/WebServerManager.cpp b/src/web/WebServerManager.cpp index a4347a9..6a1b29c 100644 --- a/src/web/WebServerManager.cpp +++ b/src/web/WebServerManager.cpp @@ -1,9 +1,13 @@ #include "web/WebServerManager.h" +#ifdef USB_MSC_BOOT_ENABLE +#include +#else #include +#endif namespace { -constexpr bool kForceAuthDisabled = true; +constexpr bool kForceAuthDisabled = false; constexpr bool kEnableRealtimeEvents = true; String quoteArg(const String& value) { @@ -22,17 +26,24 @@ WebServerManager::WebServerManager(uint16_t port) status_cache_json_(""), status_cache_ready_(false), status_cache_mux_(portMUX_INITIALIZER_UNLOCKED), - auth_enabled_(false), + auth_enabled_(true), auth_user_("admin"), auth_pass_("admin") {} void WebServerManager::begin() { +#ifdef USB_MSC_BOOT_ENABLE + if (FFat.begin(true, "/usbmsc", 10, "usbmsc")) { + server_.serveStatic("/", FFat, "/webui/").setDefaultFile("index.html"); + } else { + Serial.println("[WebServerManager] FFat mount failed (label usbmsc)"); + } +#else if (!SPIFFS.begin(true)) { Serial.println("[WebServerManager] SPIFFS mount failed"); } else { server_.serveStatic("/", SPIFFS, "/webui/").setDefaultFile("index.html"); } - +#endif registerRoutes(); server_.begin(); Serial.println("[WebServerManager] HTTP server started"); @@ -59,15 +70,24 @@ void WebServerManager::setAuthCredentials(const String& user, const String& pass void WebServerManager::setAuthEnabled(bool enabled) { if (kForceAuthDisabled) { auth_enabled_ = false; + auth_override_set_ = true; return; } + auth_override_set_ = true; auth_enabled_ = enabled; } bool WebServerManager::isAuthEnabled() const { + if (kForceAuthDisabled && !auth_override_set_) { + return false; + } return auth_enabled_; } +void WebServerManager::setCommandValidator(std::function callback) { + command_validator_ = std::move(callback); +} + void WebServerManager::setRateLimitMs(uint32_t rate_limit_ms) { rate_limit_ms_ = rate_limit_ms; } @@ -209,7 +229,7 @@ void WebServerManager::registerRoutes() { request->send(400, "application/json", "{\"error\":\"invalid topic\"}"); return; } - handleDispatch(request, "MQTT_PUB " + topic + " " + payload); + handleDispatch(request, "MQTT_PUBLISH " + topic + " " + payload); }); // ESP-NOW. @@ -253,7 +273,11 @@ void WebServerManager::registerRoutes() { request->send(400, "application/json", "{\"error\":\"invalid json body\"}"); return; } - const String mac = doc["mac"] | "broadcast"; + const String mac = doc["mac"] | ""; + if (!isValidInput(mac, 32)) { + request->send(400, "application/json", "{\"error\":\"invalid mac\"}"); + return; + } String payload; JsonVariantConst payload_variant = doc["payload"].as(); bool already_enveloped = false; @@ -394,6 +418,62 @@ bool WebServerManager::isEffectCommand(const String& command_line) { token == "BT_HANGUP"; } +bool WebServerManager::extractCommandId(const String& command_line, String& command_id) { + command_id = ""; + String line = command_line; + line.trim(); + if (line.isEmpty()) { + return false; + } + + int sep = -1; + const int len = line.length(); + bool in_quote = false; + bool escaped = false; + for (int i = 0; i < len; ++i) { + const char c = line[i]; + if (in_quote) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + in_quote = false; + } + continue; + } + if (c == '"') { + in_quote = true; + continue; + } + if (c == ' ') { + sep = i; + break; + } + } + + if (sep < 0) { + command_id = line; + } else { + command_id = line.substring(0, sep); + } + command_id.trim(); + command_id.toUpperCase(); + return !command_id.isEmpty(); +} + +bool WebServerManager::isCommandRegistered(const String& command_line, + const std::function& validator) { + if (!validator) { + return true; + } + String command_id; + if (!extractCommandId(command_line, command_id)) { + return false; + } + return validator(command_id); +} + void WebServerManager::refreshStatusCache() { if (!status_callback_) { portENTER_CRITICAL(&status_cache_mux_); @@ -468,9 +548,9 @@ void WebServerManager::publishDispatchEvent(const String& command_line, const Di } void WebServerManager::handleDispatch(AsyncWebServerRequest* request, - const String& command_line, - uint16_t success_code, - uint16_t error_code) { + const String& command_line, + uint16_t success_code, + uint16_t error_code) { if (!authenticateRequest(request)) { return; } @@ -479,6 +559,16 @@ void WebServerManager::handleDispatch(AsyncWebServerRequest* request, return; } + if (!isCommandRegistered(command_line, command_validator_)) { + JsonDocument invalid; + invalid["ok"] = false; + invalid["error"] = "unsupported_command"; + invalid["command"] = command_line; + invalid["path"] = request->url(); + request->send(400, "application/json", toJsonString(invalid)); + return; + } + const DispatchResponse res = command_executor_(command_line); if (!res.json.isEmpty()) { diff --git a/src/web/WebServerManager.h b/src/web/WebServerManager.h index 52a4151..02cfe8c 100644 --- a/src/web/WebServerManager.h +++ b/src/web/WebServerManager.h @@ -23,6 +23,7 @@ public: void setStatusCallback(std::function callback); void setCommandExecutor(std::function callback); + void setCommandValidator(std::function callback); private: AsyncWebServer server_; @@ -33,11 +34,16 @@ private: bool status_cache_ready_; portMUX_TYPE status_cache_mux_; bool auth_enabled_; + bool auth_override_set_ = false; String auth_user_; String auth_pass_; std::function status_callback_; std::function command_executor_; + std::function command_validator_; + static bool extractCommandId(const String& command_line, String& command_id); + static bool isCommandRegistered(const String& command_line, + const std::function& validator); void registerRoutes(); bool authenticateRequest(AsyncWebServerRequest* request) const; static bool extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc); diff --git a/src/webui/index.html b/src/webui/index.html deleted file mode 100644 index b2e0681..0000000 --- a/src/webui/index.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - RTC_BL_PHONE WebUI - - - -
-

RTC_BL_PHONE Dashboard

-

Chargement...

-
- - - -
-
-

Annuaire Téléphone

- -
-

Ajouter / Modifier un contact

-
- - - - -
-
-
- -
-

Configuration

-

-      
- - - -
-
- -
-

Logs

-

-      
-    
- -
-

Contrôle

- - - -

-    
-
- - - - diff --git a/src/webui/script 2.js b/src/webui/script 2.js deleted file mode 100644 index 6a04322..0000000 --- a/src/webui/script 2.js +++ /dev/null @@ -1,230 +0,0 @@ -let contactsData = []; - -function showSection(section) { - const map = { - contacts: "contactsSection", - config: "configSection", - logs: "logsSection", - control: "controlSection", - }; - Object.values(map).forEach((id) => { - const el = document.getElementById(id); - if (el) { - el.classList.remove("active"); - } - }); - const sectionEl = document.getElementById(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}`); - } - return response.json(); -} - -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}`; - } catch (error) { - status.textContent = `Erreur statut: ${error.message}`; - } -} - -async function loadContacts() { - try { - contactsData = await safeFetchJson("/api/contacts"); - renderContacts(); - } catch (error) { - document.getElementById("contactFeedback").textContent = `Erreur contacts: ${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 = `${c.nom}
${c.numero}
${c.type}`; - - const actions = document.createElement("div"); - actions.className = "contact-actions"; - actions.innerHTML = - `` + - `` + - ``; - 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() { - try { - const data = await safeFetchJson("/api/config"); - document.getElementById("config").textContent = JSON.stringify(data, null, 2); - } catch (error) { - document.getElementById("config").textContent = `Erreur config: ${error.message}`; - } -} - -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", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - 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; -} - -function bindEvents() { - document.querySelectorAll("nav button[data-section]").forEach((button) => { - 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("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; - } - 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; - } - 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)); - } - } catch (error) { - document.getElementById("contactFeedback").textContent = error.message; - } - }); - - document.querySelectorAll("#controlSection button[data-action]").forEach((button) => { - button.addEventListener("click", async () => { - try { - await sendControl(button.dataset.action); - } catch (error) { - document.getElementById("controlResult").textContent = error.message; - } - }); - }); -} - -document.addEventListener("DOMContentLoaded", async () => { - bindEvents(); - await Promise.all([refreshStatus(), loadContacts(), loadConfig(), refreshLogs()]); - showSection("contacts"); -}); diff --git a/src/webui/style.css b/src/webui/style.css deleted file mode 100644 index 7cf69aa..0000000 --- a/src/webui/style.css +++ /dev/null @@ -1,109 +0,0 @@ -:root { - --bg: #f4f6f8; - --panel: #ffffff; - --text: #1f2933; - --accent: #0f6ab6; - --accent-strong: #084a81; - --ok: #19753d; - --border: #d8dee4; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 24px; - font-family: "Segoe UI", Tahoma, sans-serif; - background: linear-gradient(135deg, #edf4fa, #f8f9fb); - color: var(--text); -} - -header { - margin-bottom: 16px; -} - -h1 { - margin: 0 0 8px; - color: var(--accent); -} - -nav { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 16px; -} - -button { - background: var(--accent); - color: #fff; - border: none; - border-radius: 6px; - padding: 8px 12px; - cursor: pointer; -} - -button:hover { - background: var(--accent-strong); -} - -section { - display: none; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 16px; -} - -section.active { - display: block; -} - -#contactsList { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 12px; - margin-top: 10px; -} - -.contact-card { - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px; - background: #fcfdff; -} - -.contact-actions { - margin-top: 8px; - display: flex; - gap: 6px; -} - -input, -select { - padding: 8px; - border: 1px solid var(--border); - border-radius: 6px; -} - -form { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 10px; -} - -pre { - background: #1f2933; - color: #e8eef4; - border-radius: 6px; - padding: 12px; - overflow-x: auto; -} - -#contactFeedback { - margin-top: 8px; - color: var(--ok); -} diff --git a/src/wifi/WifiManager.cpp b/src/wifi/WifiManager.cpp index 4fba78a..08b45f7 100644 --- a/src/wifi/WifiManager.cpp +++ b/src/wifi/WifiManager.cpp @@ -8,7 +8,8 @@ namespace { constexpr char kFallbackApPrefix[] = "RTC_BL_A252"; -constexpr char kFallbackApPassword[] = "rtcblphone"; +// Open fallback AP by default to avoid lockout in local recovery mode. +constexpr char kFallbackApPassword[] = ""; constexpr uint8_t kFallbackApChannel = 6; constexpr uint8_t kFallbackApMaxConnections = 4;