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:
@@ -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
|
||||
|
||||
@@ -428,4 +428,6 @@ coverage/
|
||||
*~
|
||||
|
||||
# Project data
|
||||
# If data/audio was previously committed, run:
|
||||
# git rm --cached -r data/audio
|
||||
data/audio/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 <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
|
||||
```
|
||||
@@ -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>
|
||||
|
||||
Executable → Regular
+201
-77
@@ -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())
|
||||
|
||||
Executable
+137
@@ -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"
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user