feat: Refactor WebServerManager and remove unused web UI files

- Deleted WebServerManager.h and WebServerManager.cpp files to streamline the codebase.
- Updated WebServerManager.cpp to improve authentication handling and command validation.
- Added command validator functionality to WebServerManager.
- Removed old web UI files (index.html, script.js, style.css) as they are no longer needed.
- Modified WifiManager to open fallback AP by default to avoid lockout.
- Introduced a pre-merge script for automated checks and builds.
- Added runtime contract tests for firmware API guards to ensure robustness.
This commit is contained in:
Clément SAILLANT
2026-02-25 10:58:48 +01:00
parent 1ce018e6eb
commit d2befa2c8d
24 changed files with 1059 additions and 1180 deletions
+7 -20
View File
@@ -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
+2
View File
@@ -428,4 +428,6 @@ coverage/
*~
# Project data
# If data/audio was previously committed, run:
# git rm --cached -r data/audio
data/audio/
+18 -47
View File
@@ -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 longlet 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 longlet 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)
+1
View File
@@ -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 |
+39
View File
@@ -0,0 +1,39 @@
# Gate de branche (pré-validation)
Ce document décrit lordonnancement unique des contrôles de conformité avant transfert.
## Ordre dexécution
Le script `scripts/pre_merge.sh` exécute les contrôles suivants, dans lordre :
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`.
- Lartefact `artifacts/route_parity_report.json` est uploadé automatiquement.
## Options utiles
- `--skip-builds` : ignore uniquement la phase build.
- `--build-env <env>` : ajoute un env PlatformIO spécifique.
- `--build-envs <env1,env2>` : ajoute plusieurs envs.
- `--report-json <path>` : 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
```
+11
View File
@@ -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 =
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+<props/PropsBridge.cpp>
+<web/WebServerManager.cpp>
[env:test]
platform = espressif32
@@ -80,6 +85,8 @@ build_src_filter =
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+<props/PropsBridge.cpp>
+<web/WebServerManager.cpp>
[env:esp32-s3-usb-host]
board = esp32s3usbotg
@@ -104,6 +111,8 @@ build_src_filter =
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+<props/PropsBridge.cpp>
+<web/WebServerManager.cpp>
[env:esp32-s3-usb-msc]
board = esp32-s3-devkitc-1
@@ -133,3 +142,5 @@ build_src_filter =
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+<props/PropsBridge.cpp>
+<web/WebServerManager.cpp>
+201 -77
View File
@@ -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<path>/api/[^"]+)"\s*,\s*HTTP_(?P<method>[A-Z]+)'
r'server_\.on\(\s*"(?P<path>/api/[^\"]+)"\s*,\s*HTTP_(?P<method>[A-Z]+)'
)
BACKEND_DISPATCH_RE = re.compile(
r'handleDispatch\(\s*request\s*,\s*"(?P<command>[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<command>[A-Z0-9_]+)"')
FRONTEND_CALL_START_RE = re.compile(r"\brequestJson\(")
FRONTEND_PATH_ARG_RE = re.compile(r'^\s*(["\'])(?P<path>/api/[^"\']+)\1')
METHOD_RE = re.compile(r'method\s*:\s*["\'](?P<method>[A-Z]+)["\']')
FRONTEND_PATH_ARG_RE = re.compile(r"^\s*([\"'])(?P<path>/api/[^\"']+)\1")
METHOD_RE = re.compile(r"method\s*:\s*[\"'](?P<method>[A-Z]+)[\"']")
def parse_backend_routes(source: str) -> set[Route]:
routes: set[Route] = set()
for match in BACKEND_ROUTE_RE.finditer(source):
method = match.group("method").upper()
path = match.group("path")
routes.add((method, path))
return routes
def find_matching_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())
+137
View File
@@ -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 <env> Ajouter explicitement un env PlatformIO (peut se répéter).
--build-envs <env1,env2> Ajouter plusieurs envs en une fois (séparés par des virgules).
--report-json <path> 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"
+120 -83
View File
@@ -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__":
+150
View File
@@ -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()
+2 -18
View File
@@ -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 "$@"
+1 -4
View File
@@ -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;
+4
View File
@@ -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);
+1
View File
@@ -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<String> commands() const;
+209 -3
View File
@@ -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<uint8_t>(pin); };
const SlicPins slic_pins = {
.pin_rm = static_cast<uint8_t>(g_pins_cfg.slic_rm),
.pin_fr = static_cast<uint8_t>(g_pins_cfg.slic_fr),
.pin_shk = static_cast<uint8_t>(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<int8_t>(-1),
.pin_pd = static_cast<int8_t>(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<JsonObject>();
g_espnow.statusToJson(espnow);
JsonObject mqtt = root["mqtt"].to<JsonObject>();
g_props_bridge.statusToJson(mqtt);
A252ConfigStore::mqttToJson(g_mqtt_cfg, mqtt["config"].to<JsonObject>());
JsonObject config = root["config"].to<JsonObject>();
A252ConfigStore::pinsToJson(g_pins_cfg, config["pins"].to<JsonObject>());
A252ConfigStore::audioToJson(g_audio_cfg, config["audio"].to<JsonObject>());
@@ -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<bool>()) {
next.enabled = patch["enabled"].as<bool>();
}
if (patch["host"].is<const char*>()) {
next.host = patch["host"].as<const char*>();
}
if (patch["port"].is<uint16_t>()) {
next.port = patch["port"].as<uint16_t>();
} else if (patch["port"].is<uint32_t>()) {
const uint32_t port = patch["port"].as<uint32_t>();
if (port > UINT16_MAX) {
error = "MQTT_CONFIG_SET invalid_port";
return false;
}
next.port = static_cast<uint16_t>(port);
}
if (patch["user"].is<const char*>()) {
next.user = patch["user"].as<const char*>();
}
if (patch["pass"].is<const char*>()) {
next.pass = patch["pass"].as<const char*>();
}
if (patch["base_topic"].is<const char*>()) {
next.base_topic = patch["base_topic"].as<const char*>();
}
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<JsonArray>();
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<JsonObject>();
g_props_bridge.statusToJson(root["bridge"].to<JsonObject>());
A252ConfigStore::mqttToJson(g_mqtt_cfg, root["config"].to<JsonObject>());
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<JsonObject>()) {
return makeResponse(false, "MQTT_CONFIG_SET invalid_json");
}
MqttConfig next = g_mqtt_cfg;
String error;
if (!applyMqttPatch(doc.as<JsonVariantConst>(), 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);
+50 -3
View File
@@ -20,6 +20,23 @@ void enforceEspNowCoexPolicy() {
static_cast<unsigned>(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<String>& 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<unsigned>(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 {
-451
View File
@@ -1,451 +0,0 @@
#include "web/WebServerManager.h"
#ifdef USB_MSC_BOOT_ENABLE
#include <FFat.h>
#else
#include <SPIFFS.h>
#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<void(JsonObject)> callback) {
status_callback_ = std::move(callback);
}
void WebServerManager::setCommandExecutor(std::function<DispatchResponse(const String&)> callback) {
command_executor_ = std::move(callback);
}
void WebServerManager::registerRoutes() {
if (kEnableRealtimeEvents) {
events_.onConnect([this](AsyncEventSourceClient* client) {
JsonDocument hello;
hello["transport"] = "sse";
hello["connected"] = true;
hello["ts"] = millis();
const String payload = toJsonString(hello);
client->send(payload.c_str(), "hello", millis());
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (ready) {
client->send(cached.c_str(), "status", millis());
}
});
server_.addHandler(&events_);
}
server_.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
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<JsonVariantConst>();
bool already_enveloped = false;
if (payload_variant.is<JsonObjectConst>()) {
JsonObjectConst payload_obj = payload_variant.as<JsonObjectConst>();
already_enveloped = payload_obj["msg_id"].is<const char*>() && payload_obj["type"].is<const char*>();
}
if (already_enveloped) {
serializeJson(payload_variant, payload);
} else {
JsonDocument envelope;
envelope["msg_id"] = String("web-") + String(millis());
envelope["seq"] = millis();
envelope["type"] = "command";
envelope["ack"] = true;
if (!payload_variant.isNull()) {
envelope["payload"].set(payload_variant);
} else {
envelope["payload"].to<JsonObject>();
}
serializeJson(envelope, payload);
}
handleDispatch(request, "ESPNOW_SEND " + mac + " " + payload);
});
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<JsonObject>());
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<JsonVariantConst>());
} else {
doc["json_raw"] = res.json;
}
}
const String payload = toJsonString(doc);
publishRealtimeEvent("dispatch", payload);
if (isEffectCommand(command_line)) {
publishRealtimeEvent("effect", payload);
}
}
void WebServerManager::handleDispatch(AsyncWebServerRequest* request,
const String& command_line,
uint16_t success_code,
uint16_t error_code) {
if (!authenticateRequest(request)) {
return;
}
if (!command_executor_) {
request->send(500, "application/json", "{\"error\":\"command executor not configured\"}");
return;
}
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);
}
-58
View File
@@ -1,58 +0,0 @@
#ifndef WEB_WEB_SERVER_MANAGER_H
#define WEB_WEB_SERVER_MANAGER_H
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <freertos/FreeRTOS.h>
#include <functional>
#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<void(JsonObject)> callback);
void setCommandExecutor(std::function<DispatchResponse(const String&)> callback);
private:
AsyncWebServer server_;
AsyncEventSource events_;
uint32_t rate_limit_ms_;
uint32_t last_status_push_ms_;
String status_cache_json_;
bool status_cache_ready_;
portMUX_TYPE status_cache_mux_;
bool auth_enabled_;
String auth_user_;
String auth_pass_;
std::function<void(JsonObject)> status_callback_;
std::function<DispatchResponse(const String&)> 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
+98 -8
View File
@@ -1,9 +1,13 @@
#include "web/WebServerManager.h"
#ifdef USB_MSC_BOOT_ENABLE
#include <FFat.h>
#else
#include <SPIFFS.h>
#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<bool(const String&)> 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<JsonVariantConst>();
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<bool(const String&)>& 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()) {
+6
View File
@@ -23,6 +23,7 @@ public:
void setStatusCallback(std::function<void(JsonObject)> callback);
void setCommandExecutor(std::function<DispatchResponse(const String&)> callback);
void setCommandValidator(std::function<bool(const String&)> 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<void(JsonObject)> status_callback_;
std::function<DispatchResponse(const String&)> command_executor_;
std::function<bool(const String&)> command_validator_;
static bool extractCommandId(const String& command_line, String& command_id);
static bool isCommandRegistered(const String& command_line,
const std::function<bool(const String&)>& validator);
void registerRoutes();
bool authenticateRequest(AsyncWebServerRequest* request) const;
static bool extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc);
-68
View File
@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RTC_BL_PHONE WebUI</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>RTC_BL_PHONE Dashboard</h1>
<p id="status">Chargement...</p>
</header>
<nav>
<button data-section="contacts">Annuaire</button>
<button data-section="config">Configuration</button>
<button data-section="logs">Logs</button>
<button data-section="control">Contrôle</button>
<button id="refreshStatusBtn">Rafraîchir statut</button>
</nav>
<main>
<section id="contactsSection" class="active">
<h2>Annuaire Téléphone</h2>
<input id="searchContact" type="text" placeholder="Recherche..." />
<div id="contactsList"></div>
<h3>Ajouter / Modifier un contact</h3>
<form id="contactForm">
<input type="text" name="nom" placeholder="Nom" required>
<input type="text" name="numero" placeholder="Numéro" required>
<select name="type">
<option value="mobile">Mobile</option>
<option value="fixe">Fixe</option>
</select>
<button type="submit">Enregistrer</button>
</form>
<div id="contactFeedback"></div>
</section>
<section id="configSection">
<h2>Configuration</h2>
<pre id="config"></pre>
<form id="configForm">
<input type="text" name="param1" placeholder="Paramètre 1">
<input type="text" name="param2" placeholder="Paramètre 2">
<button type="submit">Enregistrer</button>
</form>
</section>
<section id="logsSection">
<h2>Logs</h2>
<pre id="logs"></pre>
<button id="refreshLogsBtn">Rafraîchir logs</button>
</section>
<section id="controlSection">
<h2>Contrôle</h2>
<button data-action="call">Déclencher sonnerie</button>
<button data-action="capture_start">Démarrer capture</button>
<button data-action="capture_stop">Arrêter capture</button>
<pre id="controlResult"></pre>
</section>
</main>
<script src="script.js"></script>
</body>
</html>
-230
View File
@@ -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 = `<b>${c.nom}</b><br><span>${c.numero}</span><br><span>${c.type}</span>`;
const actions = document.createElement("div");
actions.className = "contact-actions";
actions.innerHTML =
`<button data-call="${c.numero}">Appeler</button>` +
`<button data-edit="${idx}">Modifier</button>` +
`<button data-delete="${idx}">Supprimer</button>`;
card.appendChild(actions);
list.appendChild(card);
});
}
function editContact(idx) {
const c = contactsData[idx];
if (!c) {
return;
}
const form = document.getElementById("contactForm");
form.nom.value = c.nom;
form.numero.value = c.numero;
form.type.value = c.type;
form.dataset.editIdx = String(idx);
}
async function deleteContact(idx) {
const response = await fetch("/api/contacts", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idx }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await loadContacts();
document.getElementById("contactFeedback").textContent = "Contact supprimé";
}
async function callContact(numero) {
await sendControl("call", { numero });
document.getElementById("contactFeedback").textContent = `Appel lancé vers ${numero}`;
}
async function loadConfig() {
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");
});
-109
View File
@@ -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);
}
+2 -1
View File
@@ -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;