This commit is contained in:
Clément SAILLANT
2026-02-25 09:34:04 +01:00
parent fca57a133d
commit 1ce018e6eb
18 changed files with 3033 additions and 56 deletions
+3
View File
@@ -426,3 +426,6 @@ coverage/
.history/
*.swp
*~
# Project data
data/audio/
+1 -1
View File
@@ -97,8 +97,8 @@
<button type="button" id="espnowDelBtn">Supprimer pair</button>
</form>
<form id="espnowSendForm">
<input type="text" id="espnowTarget" placeholder="broadcast ou MAC" value="broadcast" required>
<textarea id="espnowPayload" rows="3" placeholder="Payload (texte ou JSON)">{\"cmd\":\"STATUS\"}</textarea>
<p>ESPNOW: envoi en broadcast uniquement</p>
<button type="submit">Envoyer</button>
</form>
<div class="inline-actions">
+1 -2
View File
@@ -460,14 +460,13 @@ function bindEvents() {
document.getElementById("espnowSendForm").addEventListener("submit", async (event) => {
event.preventDefault();
const target = document.getElementById("espnowTarget").value.trim();
const payloadRaw = document.getElementById("espnowPayload").value;
const payload = parsePayloadValue(payloadRaw);
try {
const result = await requestJson("/api/network/espnow/send", {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ mac: target, payload }),
body: JSON.stringify({ payload }),
});
setJson("actionResult", result);
await refreshNetwork();
+218
View File
@@ -0,0 +1,218 @@
{
"SCENE_CAM": {
"1.wav": {
"profile": "gentle",
"text": "Caméra activée.",
"voice": "Jacques"
},
"2.wav": {
"profile": "aggressive",
"text": "Caméra en cours d'analyse.",
"voice": "Thomas"
},
"3.wav": {
"profile": "gentle",
"text": "Caméra terminée.",
"voice": "Amélie"
}
},
"SCENE_FOU_DETECTOR": {
"1.wav": {
"profile": "aggressive",
"text": "Détecteur FOU actif.",
"voice": "Rocko"
},
"2.wav": {
"profile": "gentle",
"text": "Détecteur FOU étape deux.",
"voice": "Thomas"
},
"3.wav": {
"profile": "aggressive",
"text": "Détecteur FOU terminé.",
"voice": "Reed"
}
},
"SCENE_LA_DETECTOR": {
"1.wav": {
"profile": "aggressive",
"text": "Détecteur LA actif.",
"voice": "Eddy"
},
"2.wav": {
"profile": "gentle",
"text": "Détecteur LA étape deux.",
"voice": "Grandma"
},
"3.wav": {
"profile": "aggressive",
"text": "Détecteur LA terminé.",
"voice": "Rocko"
}
},
"SCENE_LA_OK": {
"1.wav": {
"profile": "gentle",
"text": "La détection est validée.",
"voice": "Sandy"
},
"2.wav": {
"profile": "gentle",
"text": "Étape LA OK suivante.",
"voice": "Flo"
},
"3.wav": {
"profile": "aggressive",
"text": "Succès LA OK.",
"voice": "Rocko"
}
},
"SCENE_LOCKED": {
"1.wav": {
"profile": "gentle",
"text": "Scène verrouillée. Étape 1.",
"voice": "Thomas"
},
"2.wav": {
"profile": "aggressive",
"text": "Scène verrouillée. Étape 2.",
"voice": "Rocko"
},
"3.wav": {
"profile": "gentle",
"text": "Scène verrouillée. Étape 3.",
"voice": "Sandy"
}
},
"SCENE_WINNER": {
"1.wav": {
"profile": "aggressive",
"text": "Félicitations, vous gagnez.",
"voice": "Jacques"
},
"2.wav": {
"profile": "gentle",
"text": "Bravo. Prochaine étape.",
"voice": "Thomas"
},
"3.wav": {
"profile": "gentle",
"text": "Fin de la séquence.",
"voice": "Sandy"
}
},
"SCENE_WIN_ETAPE": {
"1.wav": {
"profile": "aggressive",
"text": "Scène WIN étape 1.",
"voice": "Thomas"
},
"2.wav": {
"profile": "aggressive",
"text": "Scène WIN étape 2.",
"voice": "Amélie"
},
"3.wav": {
"profile": "gentle",
"text": "Scène WIN étape 3.",
"voice": "Reed"
}
},
"global": {
"bip.wav": {
"profile": "aggressive",
"text": "Bip.",
"voice": "Thomas"
},
"la_busy.wav": {
"text": "Ligne occupée. Veuillez réessayer.",
"voice": "Flo"
},
"la_ok.wav": {
"text": "Message d'acquittement détecté.",
"voice": "Amélie"
},
"musique.wav": {
"profile": "aggressive",
"text": "Musique d'attente.",
"voice": "Reed"
},
"note.wav": {
"profile": "gentle",
"text": "Note de service.",
"voice": "Jacques"
},
"radio.wav": {
"profile": "gentle",
"text": "Signal radio.",
"voice": "Rocko"
},
"souffle.wav": {
"profile": "gentle",
"text": "Souffle léger.",
"voice": "Sandy"
},
"welcome.wav": {
"text": "Bienvenue. Fichier de test audio.",
"voice": "Thomas"
}
},
"meta": {
"default_channels": 1,
"default_profile": "gentle",
"default_rate": 180,
"default_sample_rate": 16000,
"default_voice": "Thomas",
"scenes": [
"SCENE_LOCKED",
"SCENE_LA_DETECTOR",
"SCENE_WIN_ETAPE",
"SCENE_LA_OK",
"SCENE_WINNER",
"SCENE_FOU_DETECTOR",
"SCENE_CAM"
]
},
"profiles": {
"aggressive": {
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 35,
"normalize_framelen_ms": 500,
"normalize_max_gain": 18,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.97,
"normalize_target_rms_db": -16,
"trim": true,
"trim_start_duration": 0.04,
"trim_start_threshold_db": -46,
"trim_stop_duration": 0.08,
"trim_stop_threshold_db": -46
},
"rate": 210,
"sample_rate": 22050,
"voice": "Thomas"
},
"gentle": {
"post": {
"enabled": true,
"normalize": true,
"normalize_compress": 25,
"normalize_framelen_ms": 500,
"normalize_max_gain": 10,
"normalize_mode": "dynaudnorm",
"normalize_peak": 0.94,
"normalize_target_rms_db": -19,
"trim": true,
"trim_start_duration": 0.08,
"trim_start_threshold_db": -52,
"trim_stop_duration": 0.12,
"trim_stop_threshold_db": -52
},
"rate": 170,
"sample_rate": 16000,
"voice": "Thomas"
}
}
}
+1355
View File
File diff suppressed because it is too large Load Diff
+36 -7
View File
@@ -6,6 +6,7 @@ import os
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
@@ -87,12 +88,39 @@ def _find_usbmsc_partition(partitions_path: Path) -> tuple[int, int]:
raise RuntimeError("Could not find 'usbmsc' partition in partition table.")
def _build_usbmsc_image(mkfatfs: str, source_dir: Path, image_path: Path, image_size: int) -> None:
if not source_dir.is_dir():
raise RuntimeError(f"WebUI folder not found: {source_dir}")
if image_path.exists():
image_path.unlink()
_run([mkfatfs, "-c", str(source_dir), "-t", "fatfs", "-s", str(image_size), str(image_path)])
def _build_usbmsc_image(
mkfatfs: str, webui_dir: Path, audio_dir: Path, image_path: Path, image_size: int
) -> None:
with tempfile.TemporaryDirectory(prefix="usbmsc_", dir=None) as staging_root:
staging_dir = Path(staging_root)
if webui_dir.is_dir():
shutil.copytree(webui_dir, staging_dir / "webui")
else:
raise RuntimeError(f"WebUI folder not found: {webui_dir}")
if audio_dir.is_dir():
for item in audio_dir.iterdir():
target = staging_dir / item.name
if item.is_dir():
if target.exists():
# Merge directories when both roots share a path.
for sub in item.rglob("*"):
rel = sub.relative_to(item)
if sub.is_dir():
(target / rel).mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(sub, target / rel)
else:
shutil.copytree(item, target)
elif item.is_file():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
if image_path.exists():
image_path.unlink()
_run([mkfatfs, "-c", str(staging_dir), "-t", "fatfs", "-s", str(image_size), str(image_path)])
def _upload_usbmsc_image(
@@ -149,10 +177,11 @@ def _target_upload_usbmsc(source, target, env):
raise RuntimeError("USB-MSC partition has invalid size.")
webui_dir = project_dir / "data" / "webui"
audio_dir = project_dir / "data" / "audio"
image_path = build_dir / "usbmsc.bin"
mkfatfs = _find_tool("mkfatfs", project_dir, env)
_build_usbmsc_image(mkfatfs, webui_dir, image_path, partition_size)
_build_usbmsc_image(mkfatfs, webui_dir, audio_dir, image_path, partition_size)
print(
"[upload_usbmsc] built image "
f"{image_path} ({image_path.stat().st_size} bytes, partition 0x{partition_offset:06x} / 0x{partition_size:06x})"
+37 -13
View File
@@ -1,5 +1,6 @@
#include "audio/AudioEngine.h"
#include <FFat.h>
#include <SD_MMC.h>
#include <esp_dsp.h>
@@ -189,17 +190,38 @@ void AudioEngine::unlockI2s() const {
}
}
bool AudioEngine::ensureSdMounted() {
if (sd_mount_attempted_) {
return sd_ready_;
bool AudioEngine::ensureAudioStorageMounted() {
if (audio_fs_mount_attempted_) {
return audio_fs_ready_;
}
sd_mount_attempted_ = true;
sd_ready_ = SD_MMC.begin();
if (!sd_ready_) {
Serial.println("[AudioEngine] SD_MMC mount failed");
audio_fs_mount_attempted_ = true;
audio_fs_ready_ = false;
audio_fs_is_fat_ = false;
audio_fs_ = nullptr;
#ifdef USB_MSC_BOOT_ENABLE
audio_fs_is_fat_ = FFat.begin(true, "/usbmsc", 10, "usbmsc");
if (audio_fs_is_fat_) {
audio_fs_ = &FFat;
audio_fs_ready_ = true;
} else {
Serial.println("[AudioEngine] FFat begin failed, fallback SD_MMC");
}
return sd_ready_;
#else
(void)audio_fs_is_fat_;
#endif
if (!audio_fs_ready_) {
audio_fs_ready_ = SD_MMC.begin();
if (!audio_fs_ready_) {
Serial.println("[AudioEngine] SD_MMC begin failed");
return false;
}
audio_fs_ = &SD_MMC;
}
return audio_fs_ready_;
}
void AudioEngine::stopPlaybackFile() {
@@ -584,16 +606,18 @@ bool AudioEngine::playFile(const char* path) {
if (!driver_installed_ || path == nullptr || path[0] == '\0') {
return false;
}
if (!ensureSdMounted()) {
if (!ensureAudioStorageMounted() || audio_fs_ == nullptr) {
return false;
}
stopDialTone();
stopPlaybackFile();
playback_file_ = SD_MMC.open(path, FILE_READ);
playback_file_ = audio_fs_->open(path, FILE_READ);
if (!playback_file_) {
Serial.printf("[AudioEngine] sd file not found: %s\n", path);
Serial.printf("[AudioEngine] playback file not found on %s: %s\n",
audio_fs_is_fat_ ? "FFAT" : "SD_MMC",
path);
return false;
}
@@ -610,7 +634,7 @@ bool AudioEngine::playFile(const char* path) {
wav_copy_.begin(wav_stream_, playback_file_);
playback_path_ = path;
playing_ = true;
Serial.printf("[AudioEngine] play sd wav (audio-tools): %s\n", path);
Serial.printf("[AudioEngine] play wav/mp3 from %s: %s\n", audio_fs_is_fat_ ? "FFAT" : "SD_MMC", path);
return true;
}
@@ -820,7 +844,7 @@ bool AudioEngine::isPlaying() const {
}
bool AudioEngine::isSdReady() const {
return sd_ready_;
return audio_fs_ready_;
}
AudioRuntimeMetrics AudioEngine::metrics() const {
+5 -3
View File
@@ -95,7 +95,7 @@ private:
void stopTask();
bool lockI2s() const;
void unlockI2s() const;
bool ensureSdMounted();
bool ensureAudioStorageMounted();
void stopPlaybackFile();
bool prepareWavPlayback(File& file, const char* path);
bool streamPlaybackChunk();
@@ -110,8 +110,10 @@ private:
float dial_tone_gain_ = 0.0f;
float dial_tone_phase_ = 0.0f;
uint32_t next_dial_tone_push_ms_ = 0;
bool sd_mount_attempted_ = false;
bool sd_ready_ = false;
bool audio_fs_mount_attempted_ = false;
bool audio_fs_ready_ = false;
bool audio_fs_is_fat_ = false;
fs::FS* audio_fs_ = nullptr;
File playback_file_;
String playback_path_;
uint32_t playback_data_remaining_ = 0;
+94
View File
@@ -11,6 +11,7 @@ constexpr const char* kPinsNs = "a252-pins";
constexpr const char* kAudioNs = "a252-audio";
constexpr const char* kMqttNs = "mqtt";
constexpr const char* kEspNowNs = "espnow";
constexpr const char* kEspNowCallMapNs = "espnow-call";
bool saveString(Preferences& prefs, const char* key, const String& value) {
return prefs.putString(key, value) >= 0;
@@ -25,6 +26,40 @@ bool loadJsonArray(const String& raw, JsonDocument& doc) {
return err == DeserializationError::Ok && doc.is<JsonArray>();
}
bool loadJsonObject(const String& raw, JsonDocument& doc) {
if (raw.isEmpty()) {
doc.to<JsonObject>();
return true;
}
const auto err = deserializeJson(doc, raw);
return err == DeserializationError::Ok && doc.is<JsonObject>();
}
String normalizeEspNowCallKeyword(const String& keyword) {
String normalized = keyword;
normalized.trim();
normalized.toUpperCase();
return normalized;
}
void mergeCallMapEntry(EspNowCallMap& map, const String& keyword, const String& path) {
const String normalized_keyword = normalizeEspNowCallKeyword(keyword);
if (normalized_keyword.isEmpty()) {
return;
}
if (path.isEmpty()) {
return;
}
for (EspNowCallMapEntry& entry : map) {
if (entry.keyword == normalized_keyword) {
entry.path = path;
return;
}
}
map.push_back({normalized_keyword, path});
}
} // namespace
A252PinsConfig A252ConfigStore::defaultPins() {
@@ -286,6 +321,65 @@ bool A252ConfigStore::loadEspNowPeers(EspNowPeerStore& out) {
return true;
}
bool A252ConfigStore::loadEspNowCallMap(EspNowCallMap& out) {
out.clear();
Preferences prefs;
if (!prefs.begin(kEspNowCallMapNs, false)) {
return false;
}
const String raw = prefs.isKey("mappings") ? prefs.getString("mappings", "{}") : String("{}");
prefs.end();
JsonDocument doc;
if (!loadJsonObject(raw, doc)) {
return false;
}
JsonObject obj = doc.as<JsonObject>();
for (JsonPair item : obj) {
if (!item.value().is<const char*>()) {
continue;
}
const String key = item.key().c_str();
mergeCallMapEntry(out, key, item.value().as<const char*>());
}
return true;
}
bool A252ConfigStore::saveEspNowCallMap(const EspNowCallMap& map, String* error) {
JsonDocument doc;
JsonObject obj = doc.to<JsonObject>();
for (const EspNowCallMapEntry& entry : map) {
if (entry.keyword.isEmpty() || entry.path.isEmpty()) {
continue;
}
obj[entry.keyword] = entry.path;
}
String raw;
serializeJson(obj, raw);
Preferences prefs;
if (!prefs.begin(kEspNowCallMapNs, false)) {
if (error) {
*error = "nvs_open_failed";
}
return false;
}
const bool ok = prefs.putString("mappings", raw) >= 0;
prefs.end();
return ok;
}
void A252ConfigStore::espNowCallMapToJson(const EspNowCallMap& map, JsonObject obj) {
for (const EspNowCallMapEntry& entry : map) {
if (entry.keyword.isEmpty() || entry.path.isEmpty()) {
continue;
}
obj[entry.keyword] = entry.path;
}
}
bool A252ConfigStore::saveEspNowPeers(const EspNowPeerStore& store, String* error) {
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
+10
View File
@@ -44,6 +44,13 @@ struct A252AudioConfig {
String route = "rtc";
};
struct EspNowCallMapEntry {
String keyword;
String path;
};
using EspNowCallMap = std::vector<EspNowCallMapEntry>;
struct MqttConfig {
bool enabled = false;
String host;
@@ -74,6 +81,8 @@ public:
static bool loadEspNowPeers(EspNowPeerStore& out);
static bool saveEspNowPeers(const EspNowPeerStore& store, String* error = nullptr);
static bool loadEspNowCallMap(EspNowCallMap& out);
static bool saveEspNowCallMap(const EspNowCallMap& map, String* error = nullptr);
static bool validatePins(const A252PinsConfig& cfg, String& error);
static bool validateAudio(const A252AudioConfig& cfg, String& error);
@@ -83,6 +92,7 @@ public:
static void audioToJson(const A252AudioConfig& cfg, JsonObject obj);
static void mqttToJson(const MqttConfig& cfg, JsonObject obj, bool include_secret = false);
static void peersToJson(const EspNowPeerStore& store, JsonArray arr);
static void espNowCallMapToJson(const EspNowCallMap& map, JsonObject obj);
static String normalizeMac(const String& value);
static bool parseMac(const String& value, uint8_t out[6]);
+289 -8
View File
@@ -30,7 +30,10 @@ FeatureMatrix g_features = getFeatureMatrix(g_profile);
A252PinsConfig g_pins_cfg = A252ConfigStore::defaultPins();
A252AudioConfig g_audio_cfg = A252ConfigStore::defaultAudio();
EspNowPeerStore g_peer_store;
EspNowCallMap g_espnow_call_map;
String g_active_scene_id;
String g_pending_espnow_call_audio_path;
bool g_pending_espnow_call = false;
Ks0835SlicController g_slic;
AudioEngine g_audio;
@@ -152,6 +155,116 @@ bool extractBridgeCommand(JsonVariantConst payload, String& out_cmd, uint8_t dep
return false;
}
String sanitizeAudioPath(const String& raw_path) {
String path = raw_path;
path.trim();
if (path.isEmpty()) {
return "";
}
if (path.length() >= 2U && path[0] == '\"' && path[path.length() - 1U] == '\"') {
path = path.substring(1U, path.length() - 1U);
}
path.trim();
if (path.isEmpty()) {
return "";
}
if (path.startsWith("{") || path.startsWith("[") || path == "null") {
return "";
}
if (!path.startsWith("/")) {
path = "/" + path;
}
path.toLowerCase();
if (!path.endsWith(".wav") && !path.endsWith(".mp3")) {
path += ".wav";
}
return path;
}
void initDefaultEspNowCallMap(EspNowCallMap& out_map) {
out_map.clear();
out_map.push_back({"LA_OK", "/la_ok.wav"});
out_map.push_back({"LA_BUSY", "/la_busy.wav"});
}
String resolveEspNowCallAudioPath(const String& message, const String& args) {
String normalized_message = message;
normalized_message.trim();
normalized_message.toUpperCase();
if (!args.isEmpty()) {
return sanitizeAudioPath(args);
}
for (const EspNowCallMapEntry& entry : g_espnow_call_map) {
if (!entry.keyword.equalsIgnoreCase(normalized_message)) {
continue;
}
const String mapped = sanitizeAudioPath(entry.path);
if (!mapped.isEmpty()) {
return mapped;
}
}
if (normalized_message.isEmpty()) {
return "";
}
normalized_message.toLowerCase();
return "/" + normalized_message + ".wav";
}
DispatchResponse makeEspNowCallResponse(bool ok, const String& message, const String& path, bool pending) {
DispatchResponse res = makeResponse(ok, ok ? (pending ? "ESPNOW_CALL_RINGING" : "ESPNOW_CALL_PLAY") : "ESPNOW_CALL_FAILED");
JsonDocument payload;
payload["call"] = message;
payload["audio"] = path;
payload["pending"] = pending;
res.json = "";
res.raw = "";
res.ok = ok;
String json;
serializeJson(payload, json);
res.json = json;
return res;
}
bool handleIncomingEspNowCallCommand(const String& command_line, DispatchResponse& out) {
String keyword;
String args;
if (!splitFirstToken(command_line, keyword, args)) {
return false;
}
keyword.trim();
keyword.toUpperCase();
if (!keyword.startsWith("LA_")) {
return false;
}
if (g_telephony.state() == TelephonyState::OFF_HOOK || g_telephony.state() == TelephonyState::PLAYING_MESSAGE) {
out = makeResponse(false, "ESPNOW_CALL_BUSY");
return true;
}
const String audio_path = resolveEspNowCallAudioPath(keyword, args);
if (audio_path.isEmpty()) {
out = makeResponse(false, "ESPNOW_CALL_NO_AUDIO");
return true;
}
g_pending_espnow_call_audio_path = audio_path;
g_pending_espnow_call = true;
g_telephony.triggerIncomingRing();
out = makeEspNowCallResponse(true, keyword, audio_path, true);
return true;
}
bool buildEspNowEnvelopeCommand(JsonVariantConst payload,
String& out_cmd,
String& out_msg_id,
@@ -372,8 +485,20 @@ bool applyHardwareConfig() {
return false;
});
g_telephony.setAnswerCallback([]() {
Serial.println("[Telephony] answer callback disabled");
return false;
if (!g_pending_espnow_call || g_pending_espnow_call_audio_path.isEmpty()) {
Serial.println("[Telephony] answer callback disabled");
return false;
}
const String audio_path = g_pending_espnow_call_audio_path;
g_pending_espnow_call_audio_path = "";
g_pending_espnow_call = false;
const bool ok = g_audio.playFile(audio_path.c_str());
Serial.printf("[Telephony] answer callback -> play '%s' ok=%s\n",
audio_path.c_str(),
ok ? "true" : "false");
return ok;
});
Serial.printf("[RTC_BL_PHONE] HW init slic=%s codec=%s audio=%s\n",
@@ -415,6 +540,8 @@ void fillStatusSnapshot(JsonObject root) {
JsonObject telephony = root["telephony"].to<JsonObject>();
telephony["state"] = telephonyStateToString(g_telephony.state());
telephony["hook"] = g_slic.isHookOff() ? "OFF_HOOK" : "ON_HOOK";
telephony["pending_espnow_call"] = g_pending_espnow_call;
telephony["pending_espnow_call_audio"] = g_pending_espnow_call_audio_path;
appendAudioMetrics(root);
@@ -430,6 +557,7 @@ void fillStatusSnapshot(JsonObject root) {
JsonObject config = root["config"].to<JsonObject>();
A252ConfigStore::pinsToJson(g_pins_cfg, config["pins"].to<JsonObject>());
A252ConfigStore::audioToJson(g_audio_cfg, config["audio"].to<JsonObject>());
A252ConfigStore::espNowCallMapToJson(g_espnow_call_map, config["espnow_call_map"].to<JsonObject>());
JsonArray peers = config["espnow_peers"].to<JsonArray>();
A252ConfigStore::peersToJson(g_peer_store, peers);
@@ -574,15 +702,28 @@ bool applyAudioPatch(JsonVariantConst patch, A252AudioConfig& target, String& er
if (patch["adc_fft_enabled"].is<bool>()) {
next.adc_fft_enabled = patch["adc_fft_enabled"].as<bool>();
}
if (patch["adc_dsp_fft_downsample"].is<uint8_t>()) {
next.adc_dsp_fft_downsample = patch["adc_dsp_fft_downsample"].as<uint8_t>();
} else if (patch["adc_dsp_fft_downsample"].is<uint16_t>() && patch["adc_dsp_fft_downsample"].as<uint16_t>() <= 255U) {
if (patch["adc_dsp_fft_downsample"].is<int>()) {
const int ds = patch["adc_dsp_fft_downsample"].as<int>();
if (ds >= 0 && ds <= 255) {
next.adc_dsp_fft_downsample = static_cast<uint8_t>(ds);
}
} else if (patch["adc_dsp_fft_downsample"].is<uint16_t>()) {
next.adc_dsp_fft_downsample = static_cast<uint8_t>(patch["adc_dsp_fft_downsample"].as<uint16_t>());
}
if (patch["adc_fft_ignore_low_bin"].is<uint16_t>()) {
if (patch["adc_fft_ignore_low_bin"].is<int>()) {
const int low_bin = patch["adc_fft_ignore_low_bin"].as<int>();
if (low_bin >= 0 && low_bin <= static_cast<int>(UINT16_MAX)) {
next.adc_fft_ignore_low_bin = static_cast<uint16_t>(low_bin);
}
} else if (patch["adc_fft_ignore_low_bin"].is<uint16_t>()) {
next.adc_fft_ignore_low_bin = patch["adc_fft_ignore_low_bin"].as<uint16_t>();
}
if (patch["adc_fft_ignore_high_bin"].is<uint16_t>()) {
if (patch["adc_fft_ignore_high_bin"].is<int>()) {
const int high_bin = patch["adc_fft_ignore_high_bin"].as<int>();
if (high_bin >= 0 && high_bin <= static_cast<int>(UINT16_MAX)) {
next.adc_fft_ignore_high_bin = static_cast<uint16_t>(high_bin);
}
} else if (patch["adc_fft_ignore_high_bin"].is<uint16_t>()) {
next.adc_fft_ignore_high_bin = patch["adc_fft_ignore_high_bin"].as<uint16_t>();
}
if (patch["route"].is<const char*>()) {
@@ -597,6 +738,63 @@ bool applyAudioPatch(JsonVariantConst patch, A252AudioConfig& target, String& er
return true;
}
DispatchResponse applyEspNowCallMapSet(const String& args) {
if (args.isEmpty()) {
return makeResponse(false, "ESPNOW_CALL_MAP_SET invalid_json");
}
JsonDocument doc;
if (deserializeJson(doc, args) != DeserializationError::Ok || !doc.is<JsonObject>()) {
return makeResponse(false, "ESPNOW_CALL_MAP_SET invalid_json");
}
JsonObject obj = doc.as<JsonObject>();
EspNowCallMap next;
for (JsonPair pair : obj) {
if (!pair.value().is<const char*>()) {
continue;
}
String keyword = pair.key().c_str();
keyword.trim();
keyword.toUpperCase();
if (keyword.isEmpty()) {
continue;
}
if (!keyword.startsWith("LA_")) {
continue;
}
const String path = sanitizeAudioPath(pair.value().as<const char*>());
if (path.isEmpty()) {
continue;
}
bool updated = false;
for (EspNowCallMapEntry& entry : next) {
if (entry.keyword.equalsIgnoreCase(keyword)) {
entry.path = path;
updated = true;
break;
}
}
if (!updated) {
next.push_back({keyword, path});
}
}
if (next.empty()) {
return makeResponse(false, "ESPNOW_CALL_MAP_SET no_valid_entries");
}
String save_error;
if (!A252ConfigStore::saveEspNowCallMap(next, &save_error)) {
return makeResponse(false, "ESPNOW_CALL_MAP_SET save_failed" + (save_error.isEmpty() ? "" : String(" ") + save_error));
}
g_espnow_call_map = next;
return makeResponse(true, "ESPNOW_CALL_MAP_SET");
}
DispatchResponse executeCommandLine(const String& line) {
return g_dispatcher.dispatch(line);
}
@@ -847,6 +1045,25 @@ void registerCommands() {
return makeResponse(g_espnow.sendJson(target, payload), "ESPNOW_SEND");
});
g_dispatcher.registerCommand("ESPNOW_CALL_MAP_GET", [](const String&) {
JsonDocument doc;
JsonObject map = doc.to<JsonObject>();
A252ConfigStore::espNowCallMapToJson(g_espnow_call_map, map);
return jsonResponse(doc);
});
g_dispatcher.registerCommand("ESPNOW_CALL_MAP_SET", [](const String& args) {
return applyEspNowCallMapSet(args);
});
g_dispatcher.registerCommand("ESPNOW_CALL_MAP_RESET", [](const String&) {
initDefaultEspNowCallMap(g_espnow_call_map);
if (!A252ConfigStore::saveEspNowCallMap(g_espnow_call_map)) {
return makeResponse(false, "ESPNOW_CALL_MAP_RESET save_failed");
}
return makeResponse(true, "ESPNOW_CALL_MAP_RESET");
});
g_dispatcher.registerCommand("SLIC_CONFIG_GET", [](const String&) {
JsonDocument doc;
A252ConfigStore::pinsToJson(g_pins_cfg, doc.to<JsonObject>());
@@ -936,7 +1153,66 @@ void processInboundBridgeCommand(const String& source, const JsonVariantConst& p
return;
}
const DispatchResponse result = executeCommandLine(cmd);
DispatchResponse result;
if (handleIncomingEspNowCallCommand(cmd, result)) {
if (is_envelope_v2 && request_ack && isMacAddressString(source)) {
JsonDocument response;
response["msg_id"] = request_id.isEmpty() ? String(millis()) : request_id;
response["seq"] = request_seq;
response["type"] = "ack";
response["ack"] = true;
JsonObject ack_payload = response["payload"].to<JsonObject>();
ack_payload["ok"] = result.ok;
ack_payload["code"] = result.code;
ack_payload["error"] = result.ok ? "" : (result.code.isEmpty() ? result.raw : result.code);
if (!result.json.isEmpty()) {
JsonDocument parsed;
if (deserializeJson(parsed, result.json) == DeserializationError::Ok) {
ack_payload["data"].set(parsed.as<JsonVariantConst>());
} else {
ack_payload["data_raw"] = result.json;
}
} else if (!result.raw.isEmpty()) {
ack_payload["data_raw"] = result.raw;
}
String response_payload;
serializeJson(response, response_payload);
g_espnow.sendJson(source, response_payload);
return;
}
if (!is_rtcbl_v1 || !isMacAddressString(source)) {
return;
}
JsonDocument response;
response["proto"] = "rtcbl/1";
response["id"] = request_id;
response["ok"] = result.ok;
response["code"] = result.code;
response["error"] = result.ok ? "" : (result.code.isEmpty() ? result.raw : result.code);
if (!result.json.isEmpty()) {
JsonDocument parsed;
if (deserializeJson(parsed, result.json) == DeserializationError::Ok) {
JsonVariant data = response["data"];
data.set(parsed.as<JsonVariantConst>());
} else {
response["data_raw"] = result.json;
}
} else if (!result.raw.isEmpty()) {
response["data_raw"] = result.raw;
}
String response_payload;
serializeJson(response, response_payload);
g_espnow.sendJson(source, response_payload);
return;
}
result = executeCommandLine(cmd);
if (is_envelope_v2 && request_ack && isMacAddressString(source)) {
JsonDocument response;
@@ -1061,6 +1337,11 @@ void setup() {
g_pins_cfg.slic_line = -1;
A252ConfigStore::loadAudio(g_audio_cfg);
A252ConfigStore::loadEspNowPeers(g_peer_store);
initDefaultEspNowCallMap(g_espnow_call_map);
if (!A252ConfigStore::loadEspNowCallMap(g_espnow_call_map)) {
initDefaultEspNowCallMap(g_espnow_call_map);
A252ConfigStore::saveEspNowCallMap(g_espnow_call_map);
}
pinMode(kAudioAmpEnablePin, OUTPUT);
digitalWrite(kAudioAmpEnablePin, LOW);
+3 -9
View File
@@ -98,16 +98,10 @@ bool EspNowBridge::sendJson(const String& target, const String& json_payload) {
return false;
}
if (target == "broadcast" || target == "BROADCAST") {
const uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
return sendToMac(broadcast_mac, json_payload);
}
(void)target;
uint8_t mac[6] = {0};
if (!A252ConfigStore::parseMac(target, mac)) {
return false;
}
return sendToMac(mac, json_payload);
const uint8_t broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
return sendToMac(broadcast_mac, json_payload);
}
bool EspNowBridge::isReady() const {
+451
View File
@@ -0,0 +1,451 @@
#include "web/WebServerManager.h"
#ifdef USB_MSC_BOOT_ENABLE
#include <FFat.h>
#else
#include <SPIFFS.h>
#endif
namespace {
constexpr bool kForceAuthDisabled = true;
constexpr bool kEnableRealtimeEvents = true;
String quoteArg(const String& value) {
String escaped = value;
escaped.replace("\\", "\\\\");
escaped.replace("\"", "\\\"");
return String("\"") + escaped + "\"";
}
}
WebServerManager::WebServerManager(uint16_t port)
: server_(port),
events_("/api/events"),
rate_limit_ms_(250),
last_status_push_ms_(0),
status_cache_json_(""),
status_cache_ready_(false),
status_cache_mux_(portMUX_INITIALIZER_UNLOCKED),
auth_enabled_(false),
auth_user_("admin"),
auth_pass_("admin") {}
void WebServerManager::begin() {
#ifdef USB_MSC_BOOT_ENABLE
if (FFat.begin(true, "/usbmsc", 10, "usbmsc")) {
server_.serveStatic("/", FFat, "/webui/").setDefaultFile("index.html");
} else {
Serial.println("[WebServerManager] FFat mount failed (label usbmsc)");
}
#else
if (!SPIFFS.begin(true)) {
Serial.println("[WebServerManager] SPIFFS mount failed");
} else {
server_.serveStatic("/", SPIFFS, "/webui/").setDefaultFile("index.html");
}
#endif
registerRoutes();
server_.begin();
Serial.println("[WebServerManager] HTTP server started");
}
void WebServerManager::handle() {
const uint32_t now = millis();
if (now - last_status_push_ms_ >= 1000U) {
last_status_push_ms_ = now;
refreshStatusCache();
publishRealtimeStatus();
}
}
void WebServerManager::setAuthCredentials(const String& user, const String& pass, bool persist_to_nvs) {
(void)persist_to_nvs;
if (!isValidInput(user, 32) || !isValidInput(pass, 64)) {
return;
}
auth_user_ = user;
auth_pass_ = pass;
}
void WebServerManager::setAuthEnabled(bool enabled) {
if (kForceAuthDisabled) {
auth_enabled_ = false;
return;
}
auth_enabled_ = enabled;
}
bool WebServerManager::isAuthEnabled() const {
return auth_enabled_;
}
void WebServerManager::setRateLimitMs(uint32_t rate_limit_ms) {
rate_limit_ms_ = rate_limit_ms;
}
void WebServerManager::setStatusCallback(std::function<void(JsonObject)> callback) {
status_callback_ = std::move(callback);
}
void WebServerManager::setCommandExecutor(std::function<DispatchResponse(const String&)> callback) {
command_executor_ = std::move(callback);
}
void WebServerManager::registerRoutes() {
if (kEnableRealtimeEvents) {
events_.onConnect([this](AsyncEventSourceClient* client) {
JsonDocument hello;
hello["transport"] = "sse";
hello["connected"] = true;
hello["ts"] = millis();
const String payload = toJsonString(hello);
client->send(payload.c_str(), "hello", millis());
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (ready) {
client->send(cached.c_str(), "status", millis());
}
});
server_.addHandler(&events_);
}
server_.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (ready) {
request->send(200, "application/json", cached);
return;
}
JsonDocument warmup;
warmup["auth_enabled"] = isAuthEnabled();
warmup["state"] = "status_warmup";
request->send(200, "application/json", toJsonString(warmup));
});
server_.on("/api/control", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String action = doc["action"] | "";
if (!isValidInput(action, 128)) {
request->send(400, "application/json", "{\"error\":\"invalid action\"}");
return;
}
handleDispatch(request, action);
});
// A252 config endpoints.
server_.on("/api/config/pins", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "SLIC_CONFIG_GET"); });
server_.on("/api/config/pins", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
String payload;
serializeJson(doc, payload);
handleDispatch(request, "SLIC_CONFIG_SET " + payload);
});
server_.on("/api/config/audio", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "AUDIO_CONFIG_GET"); });
server_.on("/api/config/audio", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
String payload;
serializeJson(doc, payload);
handleDispatch(request, "AUDIO_CONFIG_SET " + payload);
});
server_.on("/api/config/mqtt", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "MQTT_STATUS"); });
server_.on("/api/config/mqtt", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
String payload;
serializeJson(doc, payload);
handleDispatch(request, "MQTT_CONFIG_SET " + payload);
});
// WiFi.
server_.on("/api/network/wifi", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_STATUS"); });
server_.on("/api/network/wifi/connect", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String ssid = doc["ssid"] | "";
const String pass = doc["pass"] | "";
if (!isValidInput(ssid, 64)) {
request->send(400, "application/json", "{\"error\":\"invalid ssid\"}");
return;
}
handleDispatch(request, "WIFI_CONNECT " + quoteArg(ssid) + " " + quoteArg(pass));
});
server_.on("/api/network/wifi/disconnect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_DISCONNECT"); });
server_.on("/api/network/wifi/reconnect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_RECONNECT"); });
server_.on("/api/network/wifi/scan", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "WIFI_SCAN"); });
// MQTT.
server_.on("/api/network/mqtt", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "MQTT_STATUS"); });
server_.on("/api/network/mqtt/connect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "MQTT_CONNECT"); });
server_.on("/api/network/mqtt/disconnect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "MQTT_DISCONNECT"); });
server_.on("/api/network/mqtt/publish", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String topic = doc["topic"] | "";
const String payload = doc["payload"] | "";
if (!isValidInput(topic, 128)) {
request->send(400, "application/json", "{\"error\":\"invalid topic\"}");
return;
}
handleDispatch(request, "MQTT_PUB " + topic + " " + payload);
});
// ESP-NOW.
server_.on("/api/network/espnow", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_STATUS"); });
server_.on("/api/network/espnow/on", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_ON"); });
server_.on("/api/network/espnow/off", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_OFF"); });
server_.on("/api/network/espnow/peer", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "ESPNOW_PEER_LIST"); });
server_.on("/api/network/espnow/peer", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String mac = doc["mac"] | "";
if (!isValidInput(mac, 32)) {
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
return;
}
handleDispatch(request, "ESPNOW_PEER_ADD " + mac);
});
server_.on("/api/network/espnow/peer", HTTP_DELETE, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String mac = doc["mac"] | "";
if (!isValidInput(mac, 32)) {
request->send(400, "application/json", "{\"error\":\"invalid mac\"}");
return;
}
handleDispatch(request, "ESPNOW_PEER_DEL " + mac);
});
server_.on("/api/network/espnow/send", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String input_mac = doc["mac"] | "broadcast";
String mac = "broadcast";
if (!input_mac.isEmpty() && !input_mac.equalsIgnoreCase("broadcast")) {
Serial.println(F("[WebServer] ESPNOW send mac field is ignored and normalized to broadcast"));
}
String payload;
JsonVariantConst payload_variant = doc["payload"].as<JsonVariantConst>();
bool already_enveloped = false;
if (payload_variant.is<JsonObjectConst>()) {
JsonObjectConst payload_obj = payload_variant.as<JsonObjectConst>();
already_enveloped = payload_obj["msg_id"].is<const char*>() && payload_obj["type"].is<const char*>();
}
if (already_enveloped) {
serializeJson(payload_variant, payload);
} else {
JsonDocument envelope;
envelope["msg_id"] = String("web-") + String(millis());
envelope["seq"] = millis();
envelope["type"] = "command";
envelope["ack"] = true;
if (!payload_variant.isNull()) {
envelope["payload"].set(payload_variant);
} else {
envelope["payload"].to<JsonObject>();
}
serializeJson(envelope, payload);
}
handleDispatch(request, "ESPNOW_SEND " + mac + " " + payload);
});
bool WebServerManager::authenticateRequest(AsyncWebServerRequest* request) const {
if (kForceAuthDisabled || !auth_enabled_) {
return true;
}
if (!request->authenticate(auth_user_.c_str(), auth_pass_.c_str())) {
request->requestAuthentication();
return false;
}
return true;
}
bool WebServerManager::extractJsonBody(AsyncWebServerRequest* request, JsonDocument& doc) {
if (request->hasParam("plain", true)) {
const String body = request->getParam("plain", true)->value();
return deserializeJson(doc, body) == DeserializationError::Ok;
}
return false;
}
String WebServerManager::toJsonString(const JsonDocument& doc) {
String out;
serializeJson(doc, out);
return out;
}
bool WebServerManager::isValidInput(const String& value, size_t max_len) {
if (value.isEmpty() || value.length() > max_len) {
return false;
}
for (size_t i = 0; i < value.length(); ++i) {
const char c = value[i];
if (c < 32 || c > 126) {
return false;
}
}
return true;
}
bool WebServerManager::isEffectCommand(const String& command_line) {
String token = command_line;
const int sep = token.indexOf(' ');
if (sep > 0) {
token = token.substring(0, sep);
}
token.trim();
token.toUpperCase();
return token == "CALL" || token == "PLAY" || token == "CAPTURE_START" || token == "CAPTURE_STOP";
}
void WebServerManager::refreshStatusCache() {
if (!status_callback_) {
portENTER_CRITICAL(&status_cache_mux_);
status_cache_ready_ = false;
status_cache_json_ = "";
portEXIT_CRITICAL(&status_cache_mux_);
return;
}
JsonDocument doc;
doc["auth_enabled"] = isAuthEnabled();
status_callback_(doc.to<JsonObject>());
const String payload = toJsonString(doc);
portENTER_CRITICAL(&status_cache_mux_);
status_cache_json_ = payload;
status_cache_ready_ = true;
portEXIT_CRITICAL(&status_cache_mux_);
}
String WebServerManager::snapshotStatusCache(bool* ready) {
portENTER_CRITICAL(&status_cache_mux_);
const bool has_data = status_cache_ready_;
const String payload = status_cache_json_;
portEXIT_CRITICAL(&status_cache_mux_);
if (ready != nullptr) {
*ready = has_data;
}
return payload;
}
void WebServerManager::publishRealtimeEvent(const char* event_name, const String& payload_json) {
if (!kEnableRealtimeEvents) {
return;
}
events_.send(payload_json.c_str(), event_name, millis());
}
void WebServerManager::publishRealtimeStatus() {
bool ready = false;
const String cached = snapshotStatusCache(&ready);
if (!ready) {
return;
}
publishRealtimeEvent("status", cached);
}
void WebServerManager::publishDispatchEvent(const String& command_line, const DispatchResponse& res) {
JsonDocument doc;
doc["command"] = command_line;
doc["ok"] = res.ok;
if (!res.code.isEmpty()) {
doc["code"] = res.code;
}
if (!res.raw.isEmpty()) {
doc["raw"] = res.raw;
}
if (!res.json.isEmpty()) {
JsonDocument parsed;
if (deserializeJson(parsed, res.json) == DeserializationError::Ok) {
doc["json"].set(parsed.as<JsonVariantConst>());
} else {
doc["json_raw"] = res.json;
}
}
const String payload = toJsonString(doc);
publishRealtimeEvent("dispatch", payload);
if (isEffectCommand(command_line)) {
publishRealtimeEvent("effect", payload);
}
}
void WebServerManager::handleDispatch(AsyncWebServerRequest* request,
const String& command_line,
uint16_t success_code,
uint16_t error_code) {
if (!authenticateRequest(request)) {
return;
}
if (!command_executor_) {
request->send(500, "application/json", "{\"error\":\"command executor not configured\"}");
return;
}
const DispatchResponse res = command_executor_(command_line);
if (!res.json.isEmpty()) {
request->send(res.ok ? success_code : error_code, "application/json", res.json);
} else {
JsonDocument doc;
doc["ok"] = res.ok;
if (!res.code.isEmpty()) {
doc["code"] = res.code;
}
if (!res.raw.isEmpty()) {
doc["raw"] = res.raw;
}
request->send(res.ok ? success_code : error_code, "application/json", toJsonString(doc));
}
publishDispatchEvent(command_line, res);
}
+58
View File
@@ -0,0 +1,58 @@
#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
+65 -13
View File
@@ -1,10 +1,6 @@
#include "web/WebServerManager.h"
#ifdef USB_MSC_BOOT_ENABLE
#include <FFat.h>
#else
#include <SPIFFS.h>
#endif
namespace {
constexpr bool kForceAuthDisabled = true;
@@ -31,19 +27,11 @@ WebServerManager::WebServerManager(uint16_t port)
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();
@@ -292,6 +280,68 @@ void WebServerManager::registerRoutes() {
handleDispatch(request, "ESPNOW_SEND " + mac + " " + payload);
});
// Bluetooth.
server_.on("/api/bluetooth", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_STATUS"); });
server_.on("/api/bluetooth/hfp/connect", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String addr = doc["addr"] | "";
if (!isValidInput(addr, 32)) {
request->send(400, "application/json", "{\"error\":\"invalid addr\"}");
return;
}
handleDispatch(request, "BT_HFP_CONNECT " + addr);
});
server_.on("/api/bluetooth/hfp/disconnect", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_HFP_DISCONNECT"); });
server_.on("/api/bluetooth/hfp/auto", HTTP_GET,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_STATUS"); });
server_.on("/api/bluetooth/hfp/auto", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const bool enabled = doc["enabled"] | true;
handleDispatch(request, enabled ? "BT_AUTO_RECONNECT_ON" : "BT_AUTO_RECONNECT_OFF");
});
server_.on("/api/bluetooth/discoverable/on", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_DISCOVERABLE_ON"); });
server_.on("/api/bluetooth/discoverable/off", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_DISCOVERABLE_OFF"); });
server_.on("/api/bluetooth/hfp/dial", HTTP_POST, [this](AsyncWebServerRequest* request) {
JsonDocument doc;
if (!extractJsonBody(request, doc)) {
request->send(400, "application/json", "{\"error\":\"invalid json body\"}");
return;
}
const String number = doc["number"] | "";
if (!isValidInput(number, 32)) {
request->send(400, "application/json", "{\"error\":\"invalid number\"}");
return;
}
handleDispatch(request, "BT_DIAL " + number);
});
server_.on("/api/bluetooth/hfp/redial", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_REDIAL"); });
server_.on("/api/bluetooth/hfp/answer", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_ANSWER"); });
server_.on("/api/bluetooth/hfp/hangup", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_HANGUP"); });
server_.on("/api/bluetooth/hfp/calls", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_CALLS"); });
server_.on("/api/bluetooth/pbap/sync", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_PBAP_SYNC"); });
server_.on("/api/bluetooth/ble/start", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_BLE_START"); });
server_.on("/api/bluetooth/ble/stop", HTTP_POST,
[this](AsyncWebServerRequest* request) { handleDispatch(request, "BT_BLE_STOP"); });
}
bool WebServerManager::authenticateRequest(AsyncWebServerRequest* request) const {
if (kForceAuthDisabled || !auth_enabled_) {
return true;
@@ -339,7 +389,9 @@ bool WebServerManager::isEffectCommand(const String& command_line) {
token.trim();
token.toUpperCase();
return token == "CALL" || token == "PLAY" || token == "CAPTURE_START" || token == "CAPTURE_STOP";
return token == "CALL" || token == "PLAY" || token == "CAPTURE_START" || token == "CAPTURE_STOP" ||
token == "BT_DIAL" || token == "DIAL" || token == "BT_REDIAL" || token == "BT_ANSWER" ||
token == "BT_HANGUP";
}
void WebServerManager::refreshStatusCache() {
+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RTC_BL_PHONE WebUI</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>RTC_BL_PHONE Dashboard</h1>
<p id="status">Chargement...</p>
</header>
<nav>
<button data-section="contacts">Annuaire</button>
<button data-section="config">Configuration</button>
<button data-section="logs">Logs</button>
<button data-section="control">Contrôle</button>
<button id="refreshStatusBtn">Rafraîchir statut</button>
</nav>
<main>
<section id="contactsSection" class="active">
<h2>Annuaire Téléphone</h2>
<input id="searchContact" type="text" placeholder="Recherche..." />
<div id="contactsList"></div>
<h3>Ajouter / Modifier un contact</h3>
<form id="contactForm">
<input type="text" name="nom" placeholder="Nom" required>
<input type="text" name="numero" placeholder="Numéro" required>
<select name="type">
<option value="mobile">Mobile</option>
<option value="fixe">Fixe</option>
</select>
<button type="submit">Enregistrer</button>
</form>
<div id="contactFeedback"></div>
</section>
<section id="configSection">
<h2>Configuration</h2>
<pre id="config"></pre>
<form id="configForm">
<input type="text" name="param1" placeholder="Paramètre 1">
<input type="text" name="param2" placeholder="Paramètre 2">
<button type="submit">Enregistrer</button>
</form>
</section>
<section id="logsSection">
<h2>Logs</h2>
<pre id="logs"></pre>
<button id="refreshLogsBtn">Rafraîchir logs</button>
</section>
<section id="controlSection">
<h2>Contrôle</h2>
<button data-action="call">Déclencher sonnerie</button>
<button data-action="capture_start">Démarrer capture</button>
<button data-action="capture_stop">Arrêter capture</button>
<pre id="controlResult"></pre>
</section>
</main>
<script src="script.js"></script>
</body>
</html>
+230
View File
@@ -0,0 +1,230 @@
let contactsData = [];
function showSection(section) {
const map = {
contacts: "contactsSection",
config: "configSection",
logs: "logsSection",
control: "controlSection",
};
Object.values(map).forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.classList.remove("active");
}
});
const sectionEl = document.getElementById(map[section]);
if (sectionEl) {
sectionEl.classList.add("active");
}
}
async function safeFetchJson(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
async function refreshStatus() {
const status = document.getElementById("status");
try {
const data = await safeFetchJson("/api/status");
status.textContent =
`state=${data.state} board=${data.board_profile || "n/a"} ` +
`telephony=${data.telephony || "n/a"} hook=${data.hook || "n/a"} ` +
`full_duplex=${data.full_duplex} underrun=${data.audio_underrun_count || 0} ` +
`drop=${data.audio_drop_frames || 0}`;
} catch (error) {
status.textContent = `Erreur statut: ${error.message}`;
}
}
async function loadContacts() {
try {
contactsData = await safeFetchJson("/api/contacts");
renderContacts();
} catch (error) {
document.getElementById("contactFeedback").textContent = `Erreur contacts: ${error.message}`;
}
}
function renderContacts() {
const list = document.getElementById("contactsList");
const searchInput = document.getElementById("searchContact");
const search = (searchInput?.value || "").toLowerCase();
list.innerHTML = "";
contactsData
.filter((c) => c.nom.toLowerCase().includes(search) || c.numero.includes(search))
.forEach((c, idx) => {
const card = document.createElement("div");
card.className = "contact-card";
card.innerHTML = `<b>${c.nom}</b><br><span>${c.numero}</span><br><span>${c.type}</span>`;
const actions = document.createElement("div");
actions.className = "contact-actions";
actions.innerHTML =
`<button data-call="${c.numero}">Appeler</button>` +
`<button data-edit="${idx}">Modifier</button>` +
`<button data-delete="${idx}">Supprimer</button>`;
card.appendChild(actions);
list.appendChild(card);
});
}
function editContact(idx) {
const c = contactsData[idx];
if (!c) {
return;
}
const form = document.getElementById("contactForm");
form.nom.value = c.nom;
form.numero.value = c.numero;
form.type.value = c.type;
form.dataset.editIdx = String(idx);
}
async function deleteContact(idx) {
const response = await fetch("/api/contacts", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idx }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await loadContacts();
document.getElementById("contactFeedback").textContent = "Contact supprimé";
}
async function callContact(numero) {
await sendControl("call", { numero });
document.getElementById("contactFeedback").textContent = `Appel lancé vers ${numero}`;
}
async function loadConfig() {
try {
const data = await safeFetchJson("/api/config");
document.getElementById("config").textContent = JSON.stringify(data, null, 2);
} catch (error) {
document.getElementById("config").textContent = `Erreur config: ${error.message}`;
}
}
async function saveConfig(event) {
event.preventDefault();
const form = event.target;
const payload = {
param1: form.param1.value || "valeur1",
param2: form.param2.value || "valeur2",
};
const response = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
document.getElementById("config").textContent = `Erreur config: HTTP ${response.status}`;
return;
}
await loadConfig();
}
async function refreshLogs() {
const response = await fetch("/api/logs");
const logs = response.ok ? await response.text() : `Erreur logs: HTTP ${response.status}`;
document.getElementById("logs").textContent = logs;
}
async function sendControl(action, extraPayload = {}) {
const response = await fetch("/api/control", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...extraPayload }),
});
const body = await response.text();
document.getElementById("controlResult").textContent = body;
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return true;
}
function bindEvents() {
document.querySelectorAll("nav button[data-section]").forEach((button) => {
button.addEventListener("click", () => showSection(button.dataset.section));
});
document.getElementById("refreshStatusBtn").addEventListener("click", refreshStatus);
document.getElementById("refreshLogsBtn").addEventListener("click", refreshLogs);
document.getElementById("searchContact").addEventListener("input", renderContacts);
document.getElementById("configForm").addEventListener("submit", saveConfig);
document.getElementById("contactForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.target;
const editIdxRaw = form.dataset.editIdx;
const hasEditIdx = typeof editIdxRaw !== "undefined";
const payload = {
nom: form.nom.value,
numero: form.numero.value,
type: form.type.value,
};
const method = hasEditIdx ? "PUT" : "POST";
const body = hasEditIdx ? { ...payload, idx: Number(editIdxRaw) } : payload;
const response = await fetch("/api/contacts", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
document.getElementById("contactFeedback").textContent = `Erreur contact: HTTP ${response.status}`;
return;
}
delete form.dataset.editIdx;
form.reset();
document.getElementById("contactFeedback").textContent = hasEditIdx
? "Contact modifié"
: "Contact ajouté";
await loadContacts();
});
document.getElementById("contactsList").addEventListener("click", async (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
try {
if (target.dataset.call) {
await callContact(target.dataset.call);
}
if (target.dataset.edit) {
editContact(Number(target.dataset.edit));
}
if (target.dataset.delete) {
await deleteContact(Number(target.dataset.delete));
}
} catch (error) {
document.getElementById("contactFeedback").textContent = error.message;
}
});
document.querySelectorAll("#controlSection button[data-action]").forEach((button) => {
button.addEventListener("click", async () => {
try {
await sendControl(button.dataset.action);
} catch (error) {
document.getElementById("controlResult").textContent = error.message;
}
});
});
}
document.addEventListener("DOMContentLoaded", async () => {
bindEvents();
await Promise.all([refreshStatus(), loadContacts(), loadConfig(), refreshLogs()]);
showSection("contacts");
});
+109
View File
@@ -0,0 +1,109 @@
: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);
}