add esp32-S3

This commit is contained in:
Clément SAILLANT
2026-02-24 19:20:17 +01:00
parent efeacb79f8
commit a50f3c656b
25 changed files with 1409 additions and 2168 deletions
-29
View File
@@ -110,35 +110,6 @@
<pre id="espnowPeersJson"></pre>
</div>
<div>
<h3>Bluetooth</h3>
<form id="btHfpConnectForm">
<input type="text" id="btHfpAddr" placeholder="MAC téléphone HFP (AA:BB:CC:DD:EE:FF)">
<button type="submit">HFP Connect</button>
</form>
<form id="btDialForm">
<input type="text" id="btDialNumber" placeholder="Numéro (ex: 0612345678)">
<button type="submit">Dial</button>
</form>
<div class="inline-actions">
<button id="btHfpDisconnectBtn">HFP Disconnect</button>
<button id="btRedialBtn">Redial</button>
<button id="btAnswerBtn">Answer</button>
<button id="btHangupBtn">Hangup</button>
<button id="btCallsBtn">Calls Query</button>
</div>
<div class="inline-actions">
<button id="btDiscoverableOnBtn">Discoverable ON</button>
<button id="btDiscoverableOffBtn">Discoverable OFF</button>
<button id="btAutoReconnectOnBtn">Auto Reconnect ON</button>
<button id="btAutoReconnectOffBtn">Auto Reconnect OFF</button>
<button id="btPbapSyncBtn">PBAP Sync</button>
<button id="btBleStartBtn">BLE Start</button>
<button id="btBleStopBtn">BLE Stop</button>
<button id="btRefreshBtn">Rafraîchir BT</button>
</div>
<pre id="btJson"></pre>
</div>
</div>
</section>
+2 -219
View File
@@ -89,14 +89,10 @@ function applyStatusSnapshot(status) {
const wifiState = status.wifi?.state || "n/a";
const mqttConnected = status.mqtt?.connected ? "on" : "off";
const peers = status.espnow?.peer_count ?? 0;
const btCallState = status.bluetooth?.call_state || "n/a";
const btConnected = status.bluetooth?.connected ? "on" : "off";
const btAutoReconnect = status.bluetooth?.auto_reconnect_enabled ? "on" : "off";
const pbapSupported = status.bluetooth?.pbap_supported ? "yes" : "no";
const liveState = realtimeConnected ? "live=on" : "live=off";
line.textContent =
`board=${status.board_profile || "n/a"} telephony=${telephonyState} hook=${hook} ` +
`wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers} bt=${btConnected} bt_auto=${btAutoReconnect} bt_call=${btCallState} pbap=${pbapSupported} ${liveState}`;
`wifi=${wifiState} mqtt=${mqttConnected} espnow_peers=${peers} ${liveState}`;
setJson("statusJson", status);
if (status.wifi) {
@@ -109,9 +105,6 @@ function applyStatusSnapshot(status) {
setJson("espnowJson", status.espnow);
setJson("espnowPeersJson", { peers: status.espnow.peers || [] });
}
if (status.bluetooth) {
setJson("btJson", status.bluetooth);
}
}
function connectRealtime() {
@@ -235,15 +228,6 @@ async function refreshNetwork() {
}
}
async function refreshBluetooth() {
try {
const bt = await requestJson("/api/bluetooth");
setJson("btJson", bt);
} catch (error) {
setJson("btJson", { error: error.message });
}
}
async function sendControl(action) {
const result = await requestJson("/api/control", {
method: "POST",
@@ -265,7 +249,6 @@ function bindEvents() {
});
document.getElementById("refreshConfigBtn").addEventListener("click", refreshConfig);
document.getElementById("espnowRefreshBtn").addEventListener("click", refreshNetwork);
document.getElementById("btRefreshBtn").addEventListener("click", refreshBluetooth);
document.getElementById("applyAudioConfigBtn").addEventListener("click", async () => {
try {
@@ -493,206 +476,6 @@ function bindEvents() {
}
});
document.getElementById("btHfpConnectForm").addEventListener("submit", async (event) => {
event.preventDefault();
const addr = document.getElementById("btHfpAddr").value.trim();
try {
const result = await requestJson("/api/bluetooth/hfp/connect", {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ addr }),
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btHfpDisconnectBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/disconnect", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btDialForm").addEventListener("submit", async (event) => {
event.preventDefault();
const number = document.getElementById("btDialNumber").value.trim();
try {
const result = await requestJson("/api/bluetooth/hfp/dial", {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ number }),
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btRedialBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/redial", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btAnswerBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/answer", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btHangupBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/hangup", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btCallsBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/calls", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btDiscoverableOnBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/discoverable/on", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btDiscoverableOffBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/discoverable/off", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btAutoReconnectOnBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/auto", {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ enabled: true }),
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btAutoReconnectOffBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/hfp/auto", {
method: "POST",
headers: jsonHeaders(),
body: JSON.stringify({ enabled: false }),
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btPbapSyncBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/pbap/sync", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btBleStartBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/ble/start", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.getElementById("btBleStopBtn").addEventListener("click", async () => {
try {
const result = await requestJson("/api/bluetooth/ble/stop", {
method: "POST",
headers: jsonHeaders(),
body: "{}",
});
setJson("actionResult", result);
await Promise.all([refreshBluetooth(), refreshStatus()]);
} catch (error) {
setJson("actionResult", { error: error.message });
}
});
document.querySelectorAll("#controlSection button[data-action]").forEach((button) => {
button.addEventListener("click", async () => {
try {
@@ -718,6 +501,6 @@ document.addEventListener("DOMContentLoaded", async () => {
bindEvents();
connectRealtime();
ensureFallbackPolling();
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork(), refreshBluetooth()]);
await Promise.all([refreshStatus(), refreshConfig(), refreshNetwork()]);
showSection("dashboard");
});
@@ -0,0 +1,5 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
app0, app, ota_0, 0x10000, 0x640000,
spiffs, data, spiffs, 0x650000, 0x9A0000,
coredump, data, coredump,0xFF0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 app0 app ota_0 0x10000 0x640000
4 spiffs data spiffs 0x650000 0x9A0000
5 coredump data coredump 0xFF0000 0x10000
@@ -0,0 +1,5 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
app0, app, ota_0, 0x10000, 0x640000,
usbmsc, data, fat, 0x650000, 0x9A0000,
coredump, data, coredump, 0xFF0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 app0 app ota_0 0x10000 0x640000
4 usbmsc data fat 0x650000 0x9A0000
5 coredump data coredump 0xFF0000 0x10000
+75 -22
View File
@@ -1,7 +1,7 @@
; PlatformIO Project Configuration File
[platformio]
default_envs = esp32dev
default_envs = esp32-s3-devkitc-1
build_dir = .pio/build_live
[env]
@@ -15,6 +15,7 @@ lib_deps =
bblanchon/ArduinoJson@^7.0.4
throwtheswitch/Unity@^2.6.1
https://github.com/pschatzmann/arduino-audio-tools.git
https://github.com/bitluni/OsciDisplay.git
lib_ignore =
ESPAsyncTCP
RPAsyncTCP
@@ -34,29 +35,11 @@ build_src_filter =
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/AgentSupervisor.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
[env:esp32dev-bt-migrate]
board = esp32dev
board_build.partitions = huge_app.csv
upload_port = /dev/cu.usbserial
monitor_port = /dev/cu.usbserial
build_flags =
${env.build_flags}
-DBOARD_PROFILE_A252
-DBT_BACKEND_EXPERIMENTAL=1
build_src_filter =
+<main.cpp>
+<audio/AudioEngine.cpp>
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<wifi/WifiManager.cpp>
+<visual/ScopeDisplay.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
@@ -77,6 +60,76 @@ lib_deps =
[env:esp32-s3-devkitc-1]
board = esp32-s3-devkitc-1
board_build.partitions = partitions/esp32s3_no_ota_spiffs_16MB.csv
upload_port = /dev/cu.usbserial
monitor_port = /dev/cu.usbserial
build_flags =
${env.build_flags}
-DBOARD_PROFILE_ESP32_S3
build_src_filter =
+<main.cpp>
+<audio/AudioEngine.cpp>
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/AgentSupervisor.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<wifi/WifiManager.cpp>
+<visual/ScopeDisplay.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
[env:esp32-s3-usb-host]
board = esp32s3usbotg
board_build.partitions = partitions/esp32s3_no_ota_spiffs_16MB.csv
upload_port = /dev/cu.usbserial
monitor_port = /dev/cu.usbserial
build_flags =
${env.build_flags}
-DBOARD_PROFILE_ESP32_S3
-DUSB_HOST_BOOT_ENABLE
build_src_filter =
+<main.cpp>
+<audio/AudioEngine.cpp>
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/AgentSupervisor.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<wifi/WifiManager.cpp>
+<visual/ScopeDisplay.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
[env:esp32-s3-usb-msc]
board = esp32-s3-devkitc-1
board_build.partitions = partitions/esp32s3_no_ota_spiffs_usb_msc_16MB.csv
extra_scripts = scripts/upload_usbmsc.py
upload_port = /dev/cu.usbserial
monitor_port = /dev/cu.usbserial
build_unflags =
-DARDUINO_USB_MODE=1
build_flags =
${env.build_flags}
-DBOARD_PROFILE_ESP32_S3
-DARDUINO_USB_MODE=0
-DUSB_MSC_BOOT_ENABLE
build_src_filter =
+<main.cpp>
+<usb/UsbMassStorageRuntime.cpp>
+<audio/AudioEngine.cpp>
+<audio/Es8388Driver.cpp>
+<config/A252ConfigStore.cpp>
+<core/CommandDispatcher.cpp>
+<core/AgentSupervisor.cpp>
+<core/PlatformProfile.cpp>
+<props/EspNowBridge.cpp>
+<wifi/WifiManager.cpp>
+<visual/ScopeDisplay.cpp>
+<slic/Ks0835SlicController.cpp>
+<telephony/DtmfDecoder.cpp>
+<telephony/TelephonyService.cpp>
+221
View File
@@ -0,0 +1,221 @@
"""PlatformIO custom target: create and flash FFat USB-MSC image."""
from __future__ import annotations
import os
import re
import shutil
import subprocess
from pathlib import Path
def _parse_int(value: str) -> int:
value = value.strip()
if value.lower().startswith("0x"):
return int(value, 16)
return int(value)
def _normalize_flash_freq(value: str) -> str:
if not value:
return "80m"
normalized = value.strip().lower()
if normalized.endswith("l"):
normalized = normalized[:-1]
if normalized.isdigit():
freq_hz = int(normalized)
if freq_hz % 1_000_000 == 0:
return f"{freq_hz // 1_000_000}m"
return str(freq_hz)
return normalized
def _run(command):
print("[upload_usbmsc] $ {}".format(" ".join(command)))
result = subprocess.run(command, check=False)
if result.returncode:
raise RuntimeError(f"Command failed ({command[0]}): {result.returncode}")
def _find_tool(tool: str, project_dir: Path, env) -> str:
path = shutil.which(tool)
if path:
return path
platformio_home = Path.home() / ".platformio" / "packages"
project_package = Path(os.environ.get("PIO_PACKAGES_DIR", str(project_dir / ".pio" / "packages")))
candidates = []
if tool == "mkfatfs":
candidates.extend(
[
project_package / "tool-mkfatfs" / "mkfatfs",
platformio_home / "tool-mkfatfs" / "mkfatfs",
]
)
elif tool == "esptool.py":
candidates.extend(
[
project_package / "tool-esptoolpy" / "esptool.py",
platformio_home / "tool-esptoolpy" / "esptool.py",
]
)
else:
candidates.append(Path(tool))
for candidate in candidates:
if candidate.exists():
return str(candidate)
raise RuntimeError(f"Unable to locate '{tool}'. Install PlatformIO package dependencies first.")
def _find_usbmsc_partition(partitions_path: Path) -> tuple[int, int]:
if not partitions_path.exists():
raise FileNotFoundError(f"Partition table not found: {partitions_path}")
for line in partitions_path.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
parts = [segment.strip() for segment in re.split(r",\s*", line) if segment.strip()]
if len(parts) < 5:
continue
name, _part_type, _sub_type, offset, size = parts[:5]
if name == "usbmsc":
return _parse_int(offset), _parse_int(size)
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 _upload_usbmsc_image(
esptool: str,
python_exec: str,
port: str,
flash_args: dict,
offset: int,
image_path: Path,
) -> None:
command = [
python_exec,
esptool,
"--port",
port,
"--baud",
flash_args["baud"],
"--before",
flash_args["before"],
"--after",
flash_args["after"],
"write_flash",
"-z",
"--flash_mode",
flash_args["mode"],
"--flash_freq",
flash_args["freq"],
"--flash_size",
flash_args["size"],
hex(offset),
str(image_path),
]
_run(command)
def _target_upload_usbmsc(source, target, env):
project_dir = Path(env.subst("${PROJECT_DIR}"))
build_dir = Path(env.subst("${BUILD_DIR}"))
build_dir.mkdir(parents=True, exist_ok=True)
board = env.BoardConfig()
partition_path_raw = env.GetProjectConfig().get("env:%s" % env["PIOENV"], "board_build.partitions")
if not partition_path_raw:
partition_path_raw = board.get("build", {}).get("partitions")
if not partition_path_raw:
raise RuntimeError("No partition table configured for this environment.")
partitions_path = Path(partition_path_raw)
if not partitions_path.is_absolute():
partitions_path = project_dir / partitions_path
partition_offset, partition_size = _find_usbmsc_partition(partitions_path)
if partition_size <= 0:
raise RuntimeError("USB-MSC partition has invalid size.")
webui_dir = project_dir / "data" / "webui"
image_path = build_dir / "usbmsc.bin"
mkfatfs = _find_tool("mkfatfs", project_dir, env)
_build_usbmsc_image(mkfatfs, webui_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})"
)
dry_run = os.environ.get("USB_MSC_DRY_RUN", "").lower() in {"1", "true", "yes", "on"}
if dry_run:
print("[upload_usbmsc] dry run enabled, skipping flash step")
return
upload_port = (
env.GetProjectOption("upload_port", None)
or env.get("UPLOAD_PORT")
or os.environ.get("PIO_UPLOAD_PORT")
or os.environ.get("UPLOAD_PORT")
)
if not upload_port:
print("[upload_usbmsc] skipped flash step (no upload port), image ready")
return
esptool = _find_tool("esptool.py", project_dir, env)
python_exec = env.get("PYTHONEXE", shutil.which("python3") or "python3")
board_upload = board.get("upload", {})
board_build = board.get("build", {})
flash_args = {
"baud": str(board_upload.get("speed", env.get("UPLOAD_SPEED", "460800"))),
"before": env.get("UPLOAD_BEFORE", "default_reset"),
"after": env.get("UPLOAD_AFTER", "hard_reset"),
"mode": env.get("FLASH_MODE", board_build.get("flash_mode", "dio")),
"freq": _normalize_flash_freq(board_build.get("f_flash", env.get("F_FLASH", "80m"))),
"size": board_upload.get("flash_size", env.get("FLASH_SIZE", "8MB")),
}
_upload_usbmsc_image(esptool, python_exec, upload_port, flash_args, partition_offset, image_path)
print(f"[upload_usbmsc] flashed {image_path.name} at 0x{partition_offset:06x}")
def _target_upload_usbmsc_dryrun(source, target, env):
previous = os.environ.get("USB_MSC_DRY_RUN")
os.environ["USB_MSC_DRY_RUN"] = "1"
try:
_target_upload_usbmsc(source, target, env)
finally:
if previous is None:
os.environ.pop("USB_MSC_DRY_RUN", None)
else:
os.environ["USB_MSC_DRY_RUN"] = previous
Import("env")
if hasattr(env, "AddCustomTarget"):
env.AddCustomTarget(
name="upload_usbmsc",
dependencies=[],
actions=[_target_upload_usbmsc],
title="Upload USB-MSC",
description="Build and flash FFat USB-MSC webui image",
)
env.AddCustomTarget(
name="upload_usbmsc_dryrun",
dependencies=[],
actions=[_target_upload_usbmsc_dryrun],
title="Upload USB-MSC (dry run)",
description="Build FFat USB-MSC webui image without flashing",
)
+374
View File
@@ -1,6 +1,7 @@
#include "audio/AudioEngine.h"
#include <SD_MMC.h>
#include <esp_dsp.h>
#include <algorithm>
#include <cmath>
@@ -15,11 +16,21 @@ constexpr int16_t kDialToneAmplitude = 32000;
constexpr float kDialToneLinearGain = 1.14f;
constexpr size_t kDialToneChunkFrames = 160;
constexpr size_t kMaxChannels = 2;
constexpr size_t kAdcDspFirTaps = 5U;
constexpr float kDspDcBlockR = 0.995f;
constexpr float kDspHighPassHz = 250.0f;
constexpr float kDspLowPassHz = 3400.0f;
constexpr float kDspAdcScale = 1.0f / 2048.0f;
constexpr float kDspPostGain = 1.0f;
constexpr uint8_t kAdcDspMinFftDownsample = 1U;
constexpr uint8_t kAdcDspMaxFftDownsample = 64U;
constexpr float kDialToneAttackMs = 25.0f;
constexpr float kDialToneReleaseMs = 40.0f;
constexpr TickType_t kI2sWriteTimeoutMs = 2;
constexpr TickType_t kI2sReadTimeoutMs = 2;
constexpr size_t kPlaybackCopyBytes = 1024;
constexpr int16_t kAdcRawMax = 4095;
constexpr int16_t kAdcMidScale = kAdcRawMax / 2;
int16_t clampInt16(float value) {
if (value > static_cast<float>(std::numeric_limits<int16_t>::max())) {
@@ -31,6 +42,77 @@ int16_t clampInt16(float value) {
return static_cast<int16_t>(value);
}
void biquadLowPassCoeff(float sample_rate_hz, float frequency_hz, float q, float& b0, float& b1, float& b2, float& a1, float& a2) {
if (frequency_hz <= 0.0f || sample_rate_hz <= 0.0f || q <= 0.0f) {
b0 = 1.0f;
b1 = 0.0f;
b2 = 0.0f;
a1 = 0.0f;
a2 = 0.0f;
return;
}
const float omega = kTwoPi * frequency_hz / sample_rate_hz;
const float sn = std::sin(omega);
const float cs = std::cos(omega);
const float alpha = sn / (2.0f * q);
const float b0o = (1.0f - cs) / 2.0f;
const float b1o = 1.0f - cs;
const float b2o = (1.0f - cs) / 2.0f;
const float a0 = 1.0f + alpha;
const float a1o = -2.0f * cs;
const float a2o = 1.0f - alpha;
b0 = b0o / a0;
b1 = b1o / a0;
b2 = b2o / a0;
a1 = a1o / a0;
a2 = a2o / a0;
}
void biquadHighPassCoeff(float sample_rate_hz, float frequency_hz, float q, float& b0, float& b1, float& b2, float& a1, float& a2) {
if (frequency_hz <= 0.0f || sample_rate_hz <= 0.0f || q <= 0.0f) {
b0 = 1.0f;
b1 = 0.0f;
b2 = 0.0f;
a1 = 0.0f;
a2 = 0.0f;
return;
}
const float omega = kTwoPi * frequency_hz / sample_rate_hz;
const float sn = std::sin(omega);
const float cs = std::cos(omega);
const float alpha = sn / (2.0f * q);
const float b0o = (1.0f + cs) / 2.0f;
const float b1o = -(1.0f + cs);
const float b2o = (1.0f + cs) / 2.0f;
const float a0 = 1.0f + alpha;
const float a1o = -2.0f * cs;
const float a2o = 1.0f - alpha;
b0 = b0o / a0;
b1 = b1o / a0;
b2 = b2o / a0;
a1 = a1o / a0;
a2 = a2o / a0;
}
float processBiquad(float input, float& b0, float& b1, float& b2, float& a1, float& a2, float& z1, float& z2) {
const float y = b0 * input + z1;
z1 = b1 * input - a1 * y + z2;
z2 = b2 * input - a2 * y;
return y;
}
float clampFloat(float value, float lo, float hi) {
if (value < lo) {
return lo;
}
if (value > hi) {
return hi;
}
return value;
}
int bitsPerSampleToInt(i2s_bits_per_sample_t bits) {
switch (bits) {
case I2S_BITS_PER_SAMPLE_24BIT:
@@ -141,9 +223,244 @@ bool AudioEngine::prepareWavPlayback(File& file, const char* path) {
return true;
}
void AudioEngine::initAdcDspChain(uint32_t sample_rate_hz) {
const float sr = static_cast<float>(sample_rate_hz == 0U ? kAdcDspDefaultSampleRateHz : sample_rate_hz);
const float high_cut = std::min(sr * 0.45f - 20.0f, kDspLowPassHz);
const float low_cut = std::min(std::max(kDspHighPassHz, 10.0f), sr * 0.45f - 100.0f);
biquadHighPassCoeff(sr, low_cut, 0.707f, adc_dsp_biquad_hp_b0_, adc_dsp_biquad_hp_b1_, adc_dsp_biquad_hp_b2_,
adc_dsp_biquad_hp_a1_, adc_dsp_biquad_hp_a2_);
biquadLowPassCoeff(sr, high_cut > 0.0f ? high_cut : 1.0f, 0.707f, adc_dsp_biquad_lp_b0_, adc_dsp_biquad_lp_b1_,
adc_dsp_biquad_lp_b2_, adc_dsp_biquad_lp_a1_, adc_dsp_biquad_lp_a2_);
resetAdcDspState();
initAdcFftDspBackend();
adc_dsp_chain_enabled_ = true;
Serial.printf("[AudioEngine] ADC DSP chain enabled (sr=%u, hp=%.1fHz, lp=%.1fHz)\n",
static_cast<unsigned>(sample_rate_hz),
low_cut,
high_cut > 0.0f ? high_cut : 1.0f);
}
void AudioEngine::initAdcFftDspBackend() {
adc_dsp_fft_probe_backend_ready_ = false;
if (!adc_dsp_fft_probe_enabled_ || !adc_fft_enabled_ || kAdcDspFftWindowSamples == 0U) {
return;
}
const esp_err_t ret = dsps_fft2r_init_fc32(nullptr, CONFIG_DSP_MAX_FFT_SIZE);
if (ret != ESP_OK) {
Serial.printf("[AudioEngine] FFT backend init failed: %d\n", ret);
return;
}
adc_dsp_fft_probe_backend_ready_ = true;
}
void AudioEngine::deinitAdcFftDspBackend() {
if (!adc_dsp_fft_probe_backend_ready_) {
return;
}
dsps_fft2r_deinit_fc32();
adc_dsp_fft_probe_backend_ready_ = false;
}
void AudioEngine::updateAdcDspConfig(const AudioConfig& cfg) {
adc_dsp_chain_enabled_ = (use_adc_capture_ && cfg.adc_dsp_enabled);
adc_fft_enabled_ = (adc_dsp_chain_enabled_ && cfg.adc_fft_enabled);
adc_dsp_fft_probe_enabled_ = adc_fft_enabled_;
adc_dsp_fft_downsample_ = cfg.adc_dsp_fft_downsample;
if (adc_dsp_fft_downsample_ < kAdcDspMinFftDownsample) {
adc_dsp_fft_downsample_ = kAdcDspMinFftDownsample;
} else if (adc_dsp_fft_downsample_ > kAdcDspMaxFftDownsample) {
adc_dsp_fft_downsample_ = kAdcDspMaxFftDownsample;
}
const uint16_t max_ignore_bin = static_cast<uint16_t>((kAdcDspFftWindowSamples / 2U > 0U) ? (kAdcDspFftWindowSamples / 2U - 1U) : 0U);
adc_fft_ignore_low_bin_ = std::min<uint16_t>(cfg.adc_fft_ignore_low_bin, max_ignore_bin);
adc_fft_ignore_high_bin_ = std::min<uint16_t>(cfg.adc_fft_ignore_high_bin, max_ignore_bin);
if (!adc_dsp_chain_enabled_) {
deinitAdcFftDspBackend();
return;
}
initAdcDspChain(cfg.sample_rate);
}
void AudioEngine::resetAdcDspState() {
std::memset(adc_dsp_fir_state_, 0, sizeof(adc_dsp_fir_state_));
adc_dsp_fir_pos_ = 0U;
adc_dsp_prev_input_ = 0.0f;
adc_dsp_prev_output_ = 0.0f;
adc_dsp_biquad_hp_z1_ = 0.0f;
adc_dsp_biquad_hp_z2_ = 0.0f;
adc_dsp_biquad_lp_z1_ = 0.0f;
adc_dsp_biquad_lp_z2_ = 0.0f;
std::memset(adc_dsp_fft_buffer_, 0, sizeof(adc_dsp_fft_buffer_));
adc_dsp_fft_head_ = 0U;
adc_dsp_fft_fill_ = 0U;
adc_dsp_fft_decimator_ = 0U;
metrics_.adc_fft_peak_bin = 0U;
const uint32_t effective_downsample = std::max<uint32_t>(1U, static_cast<uint32_t>(adc_dsp_fft_downsample_));
metrics_.adc_fft_probe_rate_hz =
static_cast<uint16_t>(std::max(1U, _config.sample_rate / std::max(1U, effective_downsample)));
metrics_.adc_fft_peak_freq_hz = 0.0f;
metrics_.adc_fft_peak_magnitude = 0.0f;
}
float AudioEngine::applyDcBlocker(float sample) {
const float filtered = sample - adc_dsp_prev_input_ + (kDspDcBlockR * adc_dsp_prev_output_);
adc_dsp_prev_input_ = sample;
adc_dsp_prev_output_ = filtered;
return filtered;
}
float AudioEngine::applyFirNoiseReduction(float sample) {
adc_dsp_fir_state_[adc_dsp_fir_pos_] = sample;
// FIR taps: 1/16 * [1, 4, 6, 4, 1] (soft anti-alias + anti-click).
constexpr float kFirCoeff[kAdcDspFirTaps] = {0.0625f, 0.25f, 0.375f, 0.25f, 0.0625f};
float result = 0.0f;
uint8_t idx = adc_dsp_fir_pos_;
for (size_t tap = 0U; tap < kAdcDspFirTaps; ++tap) {
const size_t fir_idx = (idx + kAdcDspFirTaps - tap) % kAdcDspFirTaps;
result += kFirCoeff[tap] * adc_dsp_fir_state_[fir_idx];
}
adc_dsp_fir_pos_ = static_cast<uint8_t>((adc_dsp_fir_pos_ + 1U) % kAdcDspFirTaps);
return result;
}
int16_t AudioEngine::applyBiquadChain(float sample) {
const float hp = processBiquad(sample, adc_dsp_biquad_hp_b0_, adc_dsp_biquad_hp_b1_, adc_dsp_biquad_hp_b2_,
adc_dsp_biquad_hp_a1_, adc_dsp_biquad_hp_a2_, adc_dsp_biquad_hp_z1_,
adc_dsp_biquad_hp_z2_);
const float lp = processBiquad(hp, adc_dsp_biquad_lp_b0_, adc_dsp_biquad_lp_b1_, adc_dsp_biquad_lp_b2_,
adc_dsp_biquad_lp_a1_, adc_dsp_biquad_lp_a2_, adc_dsp_biquad_lp_z1_,
adc_dsp_biquad_lp_z2_);
return clampInt16(clampFloat(lp * kDspPostGain * 32768.0f, -32768.0f, 32767.0f));
}
void AudioEngine::appendAdcFftSample(float sample) {
if (!adc_dsp_fft_probe_enabled_ || adc_dsp_fft_downsample_ == 0U || kAdcDspFftWindowSamples == 0U) {
return;
}
if (++adc_dsp_fft_decimator_ < adc_dsp_fft_downsample_) {
return;
}
adc_dsp_fft_decimator_ = 0U;
adc_dsp_fft_buffer_[adc_dsp_fft_head_] = sample;
adc_dsp_fft_head_ = static_cast<uint8_t>((adc_dsp_fft_head_ + 1U) % kAdcDspFftWindowSamples);
if (adc_dsp_fft_fill_ < kAdcDspFftWindowSamples) {
++adc_dsp_fft_fill_;
return;
}
runAdcFftProbe();
}
void AudioEngine::runAdcFftProbe() {
if (!adc_dsp_fft_probe_enabled_ || adc_dsp_fft_fill_ < kAdcDspFftWindowSamples || kAdcDspFftWindowSamples == 0U) {
return;
}
const size_t kHalfBins = kAdcDspFftWindowSamples / 2U;
if (kHalfBins < 2U) {
return;
}
float best_power = 0.0f;
uint16_t best_bin = 0U;
const uint32_t effective_downsample = std::max<uint32_t>(1U, static_cast<uint32_t>(adc_dsp_fft_downsample_));
const float probe_sr = static_cast<float>(std::max(1U, _config.sample_rate)) / static_cast<float>(effective_downsample);
const uint16_t ignore_low = std::min<uint16_t>(adc_fft_ignore_low_bin_, static_cast<uint16_t>(kHalfBins - 1U));
const uint16_t ignore_high = std::min<uint16_t>(adc_fft_ignore_high_bin_, static_cast<uint16_t>(kHalfBins));
const uint16_t upper_limit = (ignore_high >= kHalfBins) ? 1U : static_cast<uint16_t>(kHalfBins - ignore_high);
for (size_t i = 0U; i < kAdcDspFftWindowSamples; ++i) {
const size_t src_idx = (adc_dsp_fft_head_ + i) % kAdcDspFftWindowSamples;
const float phase = static_cast<float>(i) / static_cast<float>(kAdcDspFftWindowSamples - 1U);
const float sample = adc_dsp_fft_buffer_[src_idx] * (0.5f - 0.5f * std::cos(kTwoPi * phase));
adc_dsp_fft_complex_buffer_[i * 2U] = sample;
adc_dsp_fft_complex_buffer_[i * 2U + 1U] = 0.0f;
}
if (adc_dsp_fft_probe_backend_ready_) {
esp_err_t ret = dsps_fft2r_fc32(adc_dsp_fft_complex_buffer_, static_cast<int>(kAdcDspFftWindowSamples));
if (ret != ESP_OK) {
Serial.printf("[AudioEngine] dsps_fft2r_fc32 failed: %d\n", ret);
return;
}
ret = dsps_bit_rev_fc32(adc_dsp_fft_complex_buffer_, static_cast<int>(kAdcDspFftWindowSamples));
if (ret != ESP_OK) {
Serial.printf("[AudioEngine] dsps_bit_rev_fc32 failed: %d\n", ret);
return;
}
ret = dsps_cplx2reC_fc32(adc_dsp_fft_complex_buffer_, static_cast<int>(kAdcDspFftWindowSamples));
if (ret != ESP_OK) {
Serial.printf("[AudioEngine] dsps_cplx2reC_fc32 failed: %d\n", ret);
return;
}
} else {
return;
}
for (size_t bin = 1U; bin < kHalfBins; ++bin) {
if (bin <= ignore_low || bin >= upper_limit) {
continue;
}
const float re = adc_dsp_fft_complex_buffer_[bin * 2U];
const float im = adc_dsp_fft_complex_buffer_[bin * 2U + 1U];
const float power = (re * re) + (im * im);
if (power > best_power) {
best_power = power;
best_bin = static_cast<uint16_t>(bin);
}
}
metrics_.adc_fft_peak_bin = best_bin;
metrics_.adc_fft_peak_magnitude = std::sqrt(best_power);
metrics_.adc_fft_peak_freq_hz = best_bin == 0U ? 0.0f
: (static_cast<float>(best_bin) *
(probe_sr / static_cast<float>(kAdcDspFftWindowSamples)));
}
int16_t AudioEngine::processAdcSample(int16_t raw_sample) {
float sample = static_cast<float>(raw_sample) * kDspAdcScale;
if (!adc_dsp_chain_enabled_) {
return clampInt16(sample * kDspPostGain * 32768.0f);
}
sample = applyDcBlocker(sample);
sample = applyFirNoiseReduction(sample);
appendAdcFftSample(sample);
return applyBiquadChain(sample);
}
bool AudioEngine::begin(const AudioConfig& config) {
end();
_config = config;
adc_capture_pin_ = config.capture_adc_pin;
use_adc_capture_ = (adc_capture_pin_ >= 0);
if (use_adc_capture_) {
if (adc_capture_pin_ < 0 || adc_capture_pin_ > 39) {
Serial.printf("[AudioEngine] invalid ADC pin for capture: %d\n", adc_capture_pin_);
return false;
}
pinMode(adc_capture_pin_, INPUT);
analogReadResolution(12);
analogSetPinAttenuation(adc_capture_pin_, ADC_11db);
adc_capture_sample_interval_us_ = std::max(1U, 1000000U / _config.sample_rate);
} else {
adc_capture_sample_interval_us_ = 0U;
}
updateAdcDspConfig(config);
next_adc_capture_us_ = 0U;
const bool full_duplex = (_config.enable_capture && features_.has_full_duplex_i2s);
const audio_tools::RxTxMode mode = full_duplex ? audio_tools::RXTX_MODE : audio_tools::TX_MODE;
@@ -201,6 +518,7 @@ void AudioEngine::end() {
if (!driver_installed_) {
return;
}
deinitAdcFftDspBackend();
stopTask();
stopDialTone();
stopPlaybackFile();
@@ -309,8 +627,13 @@ bool AudioEngine::requestCapture(CaptureClient client) {
}
portENTER_CRITICAL(&capture_lock_);
const bool was_active = capture_active_;
capture_clients_mask_ = static_cast<uint8_t>(capture_clients_mask_ | bit);
capture_active_ = (capture_clients_mask_ != 0U);
if (capture_active_ && !was_active && use_adc_capture_ && adc_dsp_chain_enabled_) {
resetAdcDspState();
next_adc_capture_us_ = 0U;
}
portEXIT_CRITICAL(&capture_lock_);
return true;
}
@@ -334,6 +657,9 @@ size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
if (!capture_active_ || !driver_installed_ || dst == nullptr || samples == 0) {
return 0;
}
if (use_adc_capture_) {
return captureFromAdc(dst, samples, true);
}
if (!lockI2s()) {
return 0;
}
@@ -366,6 +692,9 @@ size_t AudioEngine::readCaptureFrameNonBlocking(int16_t* dst, size_t samples) {
if (!capture_active_ || !driver_installed_ || dst == nullptr || samples == 0) {
return 0;
}
if (use_adc_capture_) {
return captureFromAdc(dst, samples, false);
}
if (!lockI2s()) {
return 0;
}
@@ -414,6 +743,51 @@ void AudioEngine::stopCapture() {
releaseCapture(CAPTURE_CLIENT_GENERIC);
}
size_t AudioEngine::captureFromAdc(int16_t* dst, size_t samples, bool blocking) {
if (dst == nullptr || samples == 0) {
return 0;
}
const uint32_t start_ms = millis();
metrics_.frames_requested += static_cast<uint32_t>(samples);
size_t captured = 0;
if (next_adc_capture_us_ == 0U) {
next_adc_capture_us_ = static_cast<uint64_t>(micros());
}
if (adc_capture_sample_interval_us_ == 0U) {
adc_capture_sample_interval_us_ = 1000000U / std::max(1U, _config.sample_rate);
}
while (captured < samples) {
const uint64_t target_us = next_adc_capture_us_;
const uint64_t now_us = micros();
if (!blocking && now_us < target_us) {
break;
}
if (blocking && now_us < target_us) {
delayMicroseconds(static_cast<unsigned long>(target_us - now_us));
}
const int raw = analogRead(adc_capture_pin_);
const int16_t centered = static_cast<int16_t>(raw - kAdcMidScale);
dst[captured] = processAdcSample(centered);
++captured;
next_adc_capture_us_ = target_us + adc_capture_sample_interval_us_;
}
metrics_.frames_read += static_cast<uint32_t>(captured);
if (captured < samples) {
metrics_.underrun_count++;
metrics_.drop_frames += static_cast<uint32_t>(samples - captured);
}
metrics_.last_latency_ms = millis() - start_ms;
metrics_.max_latency_ms = std::max(metrics_.max_latency_ms, metrics_.last_latency_ms);
return captured;
}
bool AudioEngine::startDialTone() {
if (!driver_installed_) {
return false;
+59
View File
@@ -20,7 +20,13 @@ struct AudioConfig {
int ws_pin = 25;
int data_out_pin = 26;
int data_in_pin = 35;
int capture_adc_pin = -1;
bool enable_capture = true;
bool adc_dsp_enabled = true;
bool adc_fft_enabled = true;
uint8_t adc_dsp_fft_downsample = 2U;
uint16_t adc_fft_ignore_low_bin = 1U;
uint16_t adc_fft_ignore_high_bin = 1U;
uint8_t dma_buf_count = 8;
uint16_t dma_buf_len = 256;
};
@@ -32,6 +38,10 @@ struct AudioRuntimeMetrics {
uint32_t underrun_count = 0;
uint32_t last_latency_ms = 0;
uint32_t max_latency_ms = 0;
uint16_t adc_fft_peak_bin = 0;
uint16_t adc_fft_probe_rate_hz = 0;
float adc_fft_peak_freq_hz = 0.0f;
float adc_fft_peak_magnitude = 0.0f;
};
AudioConfig defaultAudioConfigForProfile(BoardProfile profile);
@@ -70,6 +80,17 @@ public:
private:
static size_t activeChannelCount(i2s_channel_fmt_t channel_format);
static void audioTaskFn(void* arg);
size_t captureFromAdc(int16_t* dst, size_t samples, bool blocking);
void initAdcDspChain(uint32_t sample_rate_hz);
int16_t processAdcSample(int16_t raw_sample);
void resetAdcDspState();
float applyDcBlocker(float sample);
float applyFirNoiseReduction(float sample);
int16_t applyBiquadChain(float sample);
void appendAdcFftSample(float sample);
void runAdcFftProbe();
void initAdcFftDspBackend();
void deinitAdcFftDspBackend();
void startTask();
void stopTask();
bool lockI2s() const;
@@ -78,6 +99,7 @@ private:
void stopPlaybackFile();
bool prepareWavPlayback(File& file, const char* path);
bool streamPlaybackChunk();
void updateAdcDspConfig(const AudioConfig& cfg);
bool driver_installed_ = false;
bool capture_active_ = false;
@@ -97,6 +119,43 @@ private:
AudioConfig _config;
FeatureMatrix features_;
AudioRuntimeMetrics metrics_;
int adc_capture_pin_ = -1;
uint32_t adc_capture_sample_interval_us_ = 0;
uint64_t next_adc_capture_us_ = 0;
bool use_adc_capture_ = false;
bool adc_dsp_chain_enabled_ = false;
bool adc_fft_enabled_ = false;
uint8_t adc_dsp_fft_downsample_ = kAdcDspDefaultFftDownsample;
uint16_t adc_fft_ignore_low_bin_ = 1U;
uint16_t adc_fft_ignore_high_bin_ = 1U;
static constexpr uint32_t kAdcDspDefaultSampleRateHz = 16000U;
static constexpr uint8_t kAdcDspDefaultFftDownsample = 2U;
float adc_dsp_prev_input_ = 0.0f;
float adc_dsp_prev_output_ = 0.0f;
float adc_dsp_fir_state_[5U] = {0.0f};
uint8_t adc_dsp_fir_pos_ = 0U;
float adc_dsp_biquad_hp_b0_ = 1.0f;
float adc_dsp_biquad_hp_b1_ = 0.0f;
float adc_dsp_biquad_hp_b2_ = 0.0f;
float adc_dsp_biquad_hp_a1_ = 0.0f;
float adc_dsp_biquad_hp_a2_ = 0.0f;
float adc_dsp_biquad_hp_z1_ = 0.0f;
float adc_dsp_biquad_hp_z2_ = 0.0f;
float adc_dsp_biquad_lp_b0_ = 1.0f;
float adc_dsp_biquad_lp_b1_ = 0.0f;
float adc_dsp_biquad_lp_b2_ = 0.0f;
float adc_dsp_biquad_lp_a1_ = 0.0f;
float adc_dsp_biquad_lp_a2_ = 0.0f;
float adc_dsp_biquad_lp_z1_ = 0.0f;
float adc_dsp_biquad_lp_z2_ = 0.0f;
static constexpr size_t kAdcDspFftWindowSamples = 64U;
float adc_dsp_fft_buffer_[kAdcDspFftWindowSamples] = {0.0f};
uint8_t adc_dsp_fft_head_ = 0U;
uint8_t adc_dsp_fft_fill_ = 0U;
uint8_t adc_dsp_fft_decimator_ = 0U;
float adc_dsp_fft_complex_buffer_[kAdcDspFftWindowSamples * 2U] = {0.0f};
bool adc_dsp_fft_probe_enabled_ = false;
bool adc_dsp_fft_probe_backend_ready_ = false;
audio_tools::I2SStream i2s_stream_;
audio_tools::WAVDecoder wav_decoder_;
audio_tools::EncodedAudioStream wav_stream_;
+2 -3
View File
@@ -100,9 +100,8 @@ bool Es8388Driver::setRoute(const String& route) {
return false;
}
// Current hardware path uses DAC->mixer->line output for both RTC and BT.
// Keep route as metadata and ensure output path is enabled.
if (route_ == "bluetooth" || route_ == "rtc") {
// Keep route as metadata and ensure output path is enabled for RTC.
if (route_ == "rtc") {
return writeReg(0x26, 0x00) && writeReg(0x27, 0x90) && writeReg(0x2A, 0x90) &&
writeReg(0x04, 0x3C);
}
File diff suppressed because it is too large Load Diff
-116
View File
@@ -1,116 +0,0 @@
#ifndef BLUETOOTH_BLUETOOTH_MANAGER_H
#define BLUETOOTH_BLUETOOTH_MANAGER_H
#include <Arduino.h>
#include <ArduinoJson.h>
#include <functional>
#include "core/PlatformProfile.h"
class AudioEngine;
class BluetoothManager {
public:
BluetoothManager();
bool begin(BoardProfile profile);
void setAudioBridge(AudioEngine* audio);
bool connect(const char* mac);
bool disconnect();
bool isConnected() const;
bool startHFP();
bool stopHFP();
bool startBLE();
bool stopBLE();
void tick();
bool setDiscoverable(bool enabled);
bool setAutoReconnectEnabled(bool enabled);
bool autoReconnectEnabled() const;
void suspendReconnect(uint32_t duration_ms);
bool dial(const String& number);
bool redial();
bool answerCall();
bool hangupCall();
bool queryCurrentCalls();
bool syncPbapContacts();
void logStatus() const;
void statusToJson(JsonObject obj) const;
void setSecurity(bool enabled);
void setBleCommandHandler(std::function<String(const String&)> handler);
void publishBleStatus();
String executeBleCommand(const String& cmd);
void handleHfpEvent(int event, const void* param);
void onHfpIncomingAudio(const uint8_t* buf, uint32_t len);
uint32_t onHfpOutgoingAudio(uint8_t* buf, uint32_t len);
void onBleClientConnected(bool connected);
void onGapAuthComplete(const uint8_t remote_bda[6], bool success);
void onGapAclDisconnected(const uint8_t remote_bda[6], uint16_t reason);
bool isSecurityEnabled() const;
bool isHfpActive() const;
bool isBleActive() const;
bool isDiscoverable() const;
bool isPbapSupported() const;
String callState() const;
String peerMac() const;
private:
bool ensureBtStackReady();
bool ensureHfpClientReady();
bool requireCallControlReady(const char* operation);
bool requestHfpConnect(const char* reason);
bool issueDialRequest(const String& dialed, const char* event_name);
bool loadBondedPeerFromStack();
bool parseMac(const String& mac, uint8_t out[6]) const;
String formatMac(const uint8_t* mac) const;
void applyDiscoverablePolicy();
void onHfpAudioStateChanged(bool connected);
void requestAudioIfNeeded(const char* reason);
bool hasAnyBluetoothClientConnected() const;
FeatureMatrix features_;
bool stack_ready_;
bool hfp_initialized_;
bool hfp_requested_;
bool auto_reconnect_enabled_;
bool ble_stack_initialized_;
bool ble_service_ready_;
bool ble_client_connected_;
bool connected_;
bool hfp_active_;
bool slc_connected_;
bool call_setup_active_;
bool ble_active_;
bool discoverable_;
bool security_enabled_;
bool pbap_supported_;
bool pbap_synced_;
bool hfp_data_callback_registered_;
bool hfp_audio_link_up_;
String peer_mac_;
uint8_t peer_addr_[6];
bool peer_addr_valid_;
bool hfp_connect_inflight_;
uint8_t hfp_reconnect_attempts_;
uint32_t next_hfp_reconnect_ms_;
uint32_t reconnect_suspended_until_ms_;
uint32_t slc_wait_deadline_ms_;
uint32_t next_audio_retry_ms_;
uint32_t next_pending_dial_retry_ms_;
volatile bool pending_status_publish_;
volatile bool pending_discoverable_policy_;
String call_state_;
String pending_dial_number_;
String last_dialed_number_;
String pbap_last_error_;
String last_hfp_event_;
String last_ble_event_;
String last_error_;
String ble_last_command_;
String ble_last_response_;
uint32_t sco_rx_bytes_;
uint32_t sco_tx_bytes_;
uint32_t last_sco_activity_ms_;
AudioEngine* audio_bridge_;
std::function<String(const String&)> ble_command_handler_;
};
#endif // BLUETOOTH_BLUETOOTH_MANAGER_H
-32
View File
@@ -1,32 +0,0 @@
#ifndef BLUETOOTH_I_BLUETOOTH_BACKEND_H
#define BLUETOOTH_I_BLUETOOTH_BACKEND_H
#include <Arduino.h>
#include "core/PlatformProfile.h"
struct PbapContact {
String display_name;
String phone_number;
};
class IBluetoothBackend {
public:
virtual ~IBluetoothBackend() = default;
virtual bool begin(BoardProfile profile) = 0;
virtual bool connect(const String& mac) = 0;
virtual bool disconnect() = 0;
virtual bool setDiscoverable(bool enabled) = 0;
virtual bool dial(const String& number) = 0;
virtual bool redial() = 0;
virtual bool answer() = 0;
virtual bool hangup() = 0;
virtual bool queryCalls() = 0;
virtual bool syncPbap() = 0;
virtual bool isPbapSupported() const = 0;
};
#endif // BLUETOOTH_I_BLUETOOTH_BACKEND_H
+85 -4
View File
@@ -4,6 +4,8 @@
#include <algorithm>
#include "core/PlatformProfile.h"
namespace {
constexpr const char* kPinsNs = "a252-pins";
constexpr const char* kAudioNs = "a252-audio";
@@ -26,7 +28,26 @@ bool loadJsonArray(const String& raw, JsonDocument& doc) {
} // namespace
A252PinsConfig A252ConfigStore::defaultPins() {
return A252PinsConfig{};
A252PinsConfig cfg;
if (detectBoardProfile() == BoardProfile::ESP32_S3) {
cfg.i2s_bck = 17;
cfg.i2s_ws = 18;
cfg.i2s_dout = 21;
cfg.i2s_din = 39;
cfg.es8388_sda = -1;
cfg.es8388_scl = -1;
cfg.slic_rm = 32;
cfg.slic_fr = 5;
cfg.slic_shk = 23;
cfg.slic_pd = 14;
cfg.slic_adc_in = 34;
cfg.hook_active_high = true;
cfg.pcm_flt = 25;
cfg.pcm_demp = 26;
cfg.pcm_xsmt = 27;
cfg.pcm_fmt = 33;
}
return cfg;
}
A252AudioConfig A252ConfigStore::defaultAudio() {
@@ -57,7 +78,12 @@ bool A252ConfigStore::loadPins(A252PinsConfig& out) {
out.slic_shk = prefs.getInt("slic_shk", out.slic_shk);
out.slic_line = prefs.getInt("slic_line", out.slic_line);
out.slic_pd = prefs.getInt("slic_pd", out.slic_pd);
out.slic_adc_in = prefs.getInt("slic_adc_in", out.slic_adc_in);
out.hook_active_high = prefs.getBool("hook_hi", out.hook_active_high);
out.pcm_flt = prefs.getInt("pcm_flt", out.pcm_flt);
out.pcm_demp = prefs.getInt("pcm_demp", out.pcm_demp);
out.pcm_xsmt = prefs.getInt("pcm_xsmt", out.pcm_xsmt);
out.pcm_fmt = prefs.getInt("pcm_fmt", out.pcm_fmt);
prefs.end();
String error;
@@ -98,7 +124,12 @@ bool A252ConfigStore::savePins(const A252PinsConfig& cfg, String* error) {
prefs.putInt("slic_shk", cfg.slic_shk);
prefs.putInt("slic_line", cfg.slic_line);
prefs.putInt("slic_pd", cfg.slic_pd);
prefs.putInt("slic_adc_in", cfg.slic_adc_in);
prefs.putBool("hook_hi", cfg.hook_active_high);
prefs.putInt("pcm_flt", cfg.pcm_flt);
prefs.putInt("pcm_demp", cfg.pcm_demp);
prefs.putInt("pcm_xsmt", cfg.pcm_xsmt);
prefs.putInt("pcm_fmt", cfg.pcm_fmt);
prefs.end();
return true;
}
@@ -276,15 +307,21 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
cfg.i2s_ws,
cfg.i2s_dout,
cfg.i2s_din,
cfg.es8388_sda,
cfg.es8388_scl,
cfg.slic_rm,
cfg.slic_fr,
cfg.slic_shk,
cfg.slic_pd,
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;
@@ -296,6 +333,43 @@ bool A252ConfigStore::validatePins(const A252PinsConfig& cfg, String& error) {
used.push_back(pin);
}
if (detectBoardProfile() == BoardProfile::ESP32_A252) {
if (cfg.es8388_sda < 0 || cfg.es8388_scl < 0) {
error = "invalid_pin_range";
return false;
}
if (cfg.es8388_sda == cfg.es8388_scl) {
error = "pin_conflict";
return false;
}
if (cfg.es8388_sda < 0 || cfg.es8388_sda > 39 || cfg.es8388_scl < 0 || cfg.es8388_scl > 39) {
error = "invalid_pin_range";
return false;
}
if (std::find(used.begin(), used.end(), cfg.es8388_sda) != used.end() ||
std::find(used.begin(), used.end(), cfg.es8388_scl) != used.end()) {
error = "pin_conflict";
return false;
}
used.push_back(cfg.es8388_sda);
used.push_back(cfg.es8388_scl);
} else {
if (cfg.es8388_sda >= 0) {
if (cfg.es8388_sda > 39 || std::find(used.begin(), used.end(), cfg.es8388_sda) != used.end()) {
error = cfg.es8388_sda > 39 ? "invalid_pin_range" : "pin_conflict";
return false;
}
used.push_back(cfg.es8388_sda);
}
if (cfg.es8388_scl >= 0) {
if (cfg.es8388_scl > 39 || std::find(used.begin(), used.end(), cfg.es8388_scl) != used.end()) {
error = cfg.es8388_scl > 39 ? "invalid_pin_range" : "pin_conflict";
return false;
}
used.push_back(cfg.es8388_scl);
}
}
// Optional legacy line-enable pin, retired by default (-1).
if (cfg.slic_line != -1) {
if (cfg.slic_line < 0 || cfg.slic_line > 39) {
@@ -328,7 +402,7 @@ bool A252ConfigStore::validateAudio(const A252AudioConfig& cfg, String& error) {
}
const String route = cfg.route;
if (!(route == "rtc" || route == "bluetooth" || route == "none")) {
if (!(route == "rtc" || route == "none")) {
error = "invalid_route";
return false;
}
@@ -369,7 +443,14 @@ void A252ConfigStore::pinsToJson(const A252PinsConfig& cfg, JsonObject obj) {
slic["shk"] = cfg.slic_shk;
slic["line"] = cfg.slic_line;
slic["pd"] = cfg.slic_pd;
slic["adc_in"] = cfg.slic_adc_in;
slic["hook_active_high"] = cfg.hook_active_high;
JsonObject pcm = obj["pcm"].to<JsonObject>();
pcm["flt"] = cfg.pcm_flt;
pcm["demp"] = cfg.pcm_demp;
pcm["xsmt"] = cfg.pcm_xsmt;
pcm["fmt"] = cfg.pcm_fmt;
}
void A252ConfigStore::audioToJson(const A252AudioConfig& cfg, JsonObject obj) {
+11
View File
@@ -21,13 +21,24 @@ struct A252PinsConfig {
int slic_shk = 23;
int slic_line = -1;
int slic_pd = 19;
int slic_adc_in = -1;
bool hook_active_high = true;
int pcm_flt = -1;
int pcm_demp = -1;
int pcm_xsmt = -1;
int pcm_fmt = -1;
};
struct A252AudioConfig {
uint32_t sample_rate = 16000;
uint8_t bits_per_sample = 16;
bool enable_capture = true;
bool adc_dsp_enabled = true;
bool adc_fft_enabled = true;
uint8_t adc_dsp_fft_downsample = 2U;
uint16_t adc_fft_ignore_low_bin = 1U;
uint16_t adc_fft_ignore_high_bin = 1U;
uint8_t volume = 90;
bool mute = false;
String route = "rtc";
-9
View File
@@ -12,24 +12,15 @@ FeatureMatrix getFeatureMatrix(BoardProfile profile) {
switch (profile) {
case BoardProfile::ESP32_A252:
return FeatureMatrix{
.has_bt_classic = true,
.has_hfp = true,
.has_full_duplex_i2s = true,
.has_ble_control = true,
};
case BoardProfile::ESP32_S3:
return FeatureMatrix{
.has_bt_classic = false,
.has_hfp = false,
.has_full_duplex_i2s = false,
.has_ble_control = true,
};
default:
return FeatureMatrix{
.has_bt_classic = false,
.has_hfp = false,
.has_full_duplex_i2s = false,
.has_ble_control = false,
};
}
}
-3
View File
@@ -9,10 +9,7 @@ enum class BoardProfile : uint8_t {
};
struct FeatureMatrix {
bool has_bt_classic;
bool has_hfp;
bool has_full_duplex_i2s;
bool has_ble_control;
};
BoardProfile detectBoardProfile();
+135 -12
View File
@@ -11,7 +11,10 @@
#include "props/EspNowBridge.h"
#include "slic/Ks0835SlicController.h"
#include "telephony/TelephonyService.h"
#include "visual/ScopeDisplay.h"
#include "wifi/WifiManagerInstance.h"
#include "usb/UsbHostRuntime.h"
#include "usb/UsbMassStorageRuntime.h"
#ifndef UNIT_TEST
namespace {
@@ -21,8 +24,8 @@ constexpr int kAudioAmpEnablePin = 21;
constexpr char kBootLogTag[] = "RTC_BOOT";
constexpr bool kPrintHelpOnBoot = false;
BoardProfile g_profile = BoardProfile::ESP32_A252;
FeatureMatrix g_features = getFeatureMatrix(BoardProfile::ESP32_A252);
BoardProfile g_profile = detectBoardProfile();
FeatureMatrix g_features = getFeatureMatrix(g_profile);
A252PinsConfig g_pins_cfg = A252ConfigStore::defaultPins();
A252AudioConfig g_audio_cfg = A252ConfigStore::defaultAudio();
@@ -35,6 +38,7 @@ Es8388Driver g_codec;
TelephonyService g_telephony;
EspNowBridge g_espnow;
CommandDispatcher g_dispatcher;
ScopeDisplay g_scope_display;
String g_serial_line;
DispatchResponse makeResponse(bool ok, const String& code) {
@@ -308,12 +312,28 @@ AudioConfig buildI2sConfig(const A252PinsConfig& pins_cfg, const A252AudioConfig
cfg.ws_pin = pins_cfg.i2s_ws;
cfg.data_out_pin = pins_cfg.i2s_dout;
cfg.data_in_pin = pins_cfg.i2s_din;
cfg.capture_adc_pin = pins_cfg.slic_adc_in;
cfg.enable_capture = audio_cfg.enable_capture;
cfg.dma_buf_count = 8;
cfg.dma_buf_len = 256;
return cfg;
}
void applyPcm5102ControlPins(const A252PinsConfig& pins_cfg) {
auto apply = [](int pin, int value) {
if (pin < 0) {
return;
}
pinMode(pin, OUTPUT);
digitalWrite(pin, value);
};
apply(pins_cfg.pcm_flt, LOW);
apply(pins_cfg.pcm_demp, LOW);
apply(pins_cfg.pcm_xsmt, HIGH);
apply(pins_cfg.pcm_fmt, LOW);
}
bool applyHardwareConfig() {
const SlicPins slic_pins = {
.pin_rm = static_cast<uint8_t>(g_pins_cfg.slic_rm),
@@ -328,22 +348,26 @@ bool applyHardwareConfig() {
g_slic.setPowerDown(false);
g_slic.setRing(false);
const bool codec_ok = g_codec.begin(g_pins_cfg.es8388_sda, g_pins_cfg.es8388_scl);
g_codec.setVolume(g_audio_cfg.volume);
g_codec.setMute(g_audio_cfg.mute);
g_codec.setRoute(g_audio_cfg.route);
bool codec_ok = true;
if (g_profile == BoardProfile::ESP32_A252) {
codec_ok = g_codec.begin(g_pins_cfg.es8388_sda, g_pins_cfg.es8388_scl);
g_codec.setVolume(g_audio_cfg.volume);
g_codec.setMute(g_audio_cfg.mute);
g_codec.setRoute(g_audio_cfg.route);
}
applyPcm5102ControlPins(g_pins_cfg);
const AudioConfig audio = buildI2sConfig(g_pins_cfg, g_audio_cfg);
const bool audio_ok = g_audio.begin(audio);
g_audio.resetMetrics();
g_telephony.begin(g_profile, g_slic, g_audio);
g_telephony.setDialCallback([](const String& number) {
Serial.printf("[Telephony] dial request ignored (BT disabled): %s\n", number.c_str());
Serial.printf("[Telephony] dial callback disabled for routing: %s\n", number.c_str());
return false;
});
g_telephony.setAnswerCallback([]() {
Serial.println("[Telephony] answer request ignored (BT disabled)");
Serial.println("[Telephony] answer callback disabled");
return false;
});
@@ -374,6 +398,9 @@ void appendAudioMetrics(JsonObject root) {
audio["underrun"] = metrics.underrun_count;
audio["drop"] = metrics.drop_frames;
audio["latence_ms"] = metrics.last_latency_ms;
audio["adc_fft_peak_bin"] = metrics.adc_fft_peak_bin;
audio["adc_fft_peak_freq_hz"] = metrics.adc_fft_peak_freq_hz;
audio["adc_fft_peak_mag"] = metrics.adc_fft_peak_magnitude;
}
void fillStatusSnapshot(JsonObject root) {
@@ -386,6 +413,12 @@ void fillStatusSnapshot(JsonObject root) {
appendAudioMetrics(root);
JsonObject scope = root["scope_display"].to<JsonObject>();
scope["supported"] = g_scope_display.supported();
scope["enabled"] = g_scope_display.enabled();
scope["frequency"] = g_scope_display.frequency();
scope["amplitude"] = g_scope_display.amplitude();
JsonObject espnow = root["espnow"].to<JsonObject>();
g_espnow.statusToJson(espnow);
@@ -432,9 +465,24 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
if (patch["slic"]["pd"].is<int>()) {
next.slic_pd = patch["slic"]["pd"].as<int>();
}
if (patch["slic"]["adc_in"].is<int>()) {
next.slic_adc_in = patch["slic"]["adc_in"].as<int>();
}
if (patch["slic"]["hook_active_high"].is<bool>()) {
next.hook_active_high = patch["slic"]["hook_active_high"].as<bool>();
}
if (patch["pcm"]["flt"].is<int>()) {
next.pcm_flt = patch["pcm"]["flt"].as<int>();
}
if (patch["pcm"]["demp"].is<int>()) {
next.pcm_demp = patch["pcm"]["demp"].as<int>();
}
if (patch["pcm"]["xsmt"].is<int>()) {
next.pcm_xsmt = patch["pcm"]["xsmt"].as<int>();
}
if (patch["pcm"]["fmt"].is<int>()) {
next.pcm_fmt = patch["pcm"]["fmt"].as<int>();
}
if (patch["i2s_bck"].is<int>()) {
next.i2s_bck = patch["i2s_bck"].as<int>();
@@ -468,9 +516,24 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
if (patch["slic_pd"].is<int>()) {
next.slic_pd = patch["slic_pd"].as<int>();
}
if (patch["slic_adc_in"].is<int>()) {
next.slic_adc_in = patch["slic_adc_in"].as<int>();
}
if (patch["hook_active_high"].is<bool>()) {
next.hook_active_high = patch["hook_active_high"].as<bool>();
}
if (patch["pcm_flt"].is<int>()) {
next.pcm_flt = patch["pcm_flt"].as<int>();
}
if (patch["pcm_demp"].is<int>()) {
next.pcm_demp = patch["pcm_demp"].as<int>();
}
if (patch["pcm_xsmt"].is<int>()) {
next.pcm_xsmt = patch["pcm_xsmt"].as<int>();
}
if (patch["pcm_fmt"].is<int>()) {
next.pcm_fmt = patch["pcm_fmt"].as<int>();
}
next.slic_line = -1;
@@ -629,6 +692,53 @@ void registerCommands() {
return makeResponse(true, "CAPTURE_STOP");
});
g_dispatcher.registerCommand("OSC_START", [](const String& args) {
String first;
String rest;
uint16_t freq = 1200U;
uint8_t amp = 48U;
if (!args.isEmpty()) {
if (!splitFirstToken(args, first, rest)) {
return makeResponse(false, "OSC_START invalid_args");
}
const int parsed_freq = first.toInt();
if (parsed_freq > 0) {
freq = static_cast<uint16_t>(parsed_freq);
}
if (!rest.isEmpty()) {
const int parsed_amp = rest.toInt();
if (parsed_amp > 0) {
amp = static_cast<uint8_t>(parsed_amp);
}
}
if (!g_scope_display.configure(freq, amp)) {
return makeResponse(false, "OSC_START invalid_config");
}
}
if (!g_scope_display.begin()) {
return makeResponse(false, "OSC_START not_supported");
}
g_scope_display.enable(true);
return makeResponse(true, "OSC_START");
});
g_dispatcher.registerCommand("OSC_STOP", [](const String&) {
g_scope_display.enable(false);
return makeResponse(true, "OSC_STOP");
});
g_dispatcher.registerCommand("OSC_STATUS", [](const String&) {
JsonDocument out;
JsonObject scope = out.to<JsonObject>();
scope["supported"] = g_scope_display.supported();
scope["enabled"] = g_scope_display.enabled();
scope["frequency"] = g_scope_display.frequency();
scope["amplitude"] = g_scope_display.amplitude();
return jsonResponse(out);
});
g_dispatcher.registerCommand("PLAY", [](const String& args) {
const String path = args.isEmpty() ? "/welcome.wav" : args;
return makeResponse(g_audio.playFile(path.c_str()), "PLAY");
@@ -779,9 +889,11 @@ void registerCommands() {
}
g_audio_cfg = next;
g_codec.setVolume(g_audio_cfg.volume);
g_codec.setMute(g_audio_cfg.mute);
g_codec.setRoute(g_audio_cfg.route);
if (g_profile == BoardProfile::ESP32_A252) {
g_codec.setVolume(g_audio_cfg.volume);
g_codec.setMute(g_audio_cfg.mute);
g_codec.setRoute(g_audio_cfg.route);
}
const bool audio_ok = g_audio.begin(buildI2sConfig(g_pins_cfg, g_audio_cfg));
return makeResponse(audio_ok, "AUDIO_CONFIG_SET");
});
@@ -910,9 +1022,19 @@ void setup() {
printf("[RTC_BL_PHONE] stdio lock warmup\n");
fflush(stdout);
g_profile = BoardProfile::ESP32_A252;
g_profile = detectBoardProfile();
g_features = getFeatureMatrix(g_profile);
#ifdef USB_HOST_BOOT_ENABLE
const bool usb_host = usb_host_runtime::enableHostPortPower();
Serial.printf("[RTC_BL_PHONE] USB host bootstrap: %s\n", usb_host ? "ok" : "not available");
#endif
#ifdef USB_MSC_BOOT_ENABLE
const bool usb_msc = usb_msc_runtime::beginUsbMassStorage();
Serial.printf("[RTC_BL_PHONE] USB MSC bootstrap: %s\n", usb_msc ? "ok" : "failed");
#endif
A252ConfigStore::loadPins(g_pins_cfg);
g_pins_cfg.slic_line = -1;
A252ConfigStore::loadAudio(g_audio_cfg);
@@ -939,6 +1061,7 @@ void setup() {
void loop() {
g_telephony.tick();
g_scope_display.tick();
g_espnow.tick();
pollSerial();
delay(1);
+3 -4
View File
@@ -219,7 +219,7 @@ void TelephonyService::commitDialBuffer(const char* reason) {
const String number = dial_buffer_;
const bool ok = dial_callback_ ? dial_callback_(number) : false;
last_dial_error_ = ok ? "" : "bt_dial_failed";
last_dial_error_ = ok ? "" : "dial_failed";
Serial.printf("[Telephony] dial_trigger reason=%s number=%s ok=%s\n",
reason != nullptr ? reason : "unknown",
number.c_str(),
@@ -282,10 +282,9 @@ void TelephonyService::tick() {
ring_phase_on_ = false;
slic_->setRing(false);
const bool answered = answer_callback_ ? answer_callback_() : false;
// While transitioning from incoming ring to call answer, keep dial tone muted
// even if BT answer fails transiently.
// Keep dial tone muted while transitioning from incoming ring to call answer.
suppress_dial_tone_ = true;
last_dial_error_ = answered ? "" : "bt_answer_failed";
last_dial_error_ = answered ? "" : "answer_failed";
state_ = TelephonyState::OFF_HOOK;
break;
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <Arduino.h>
namespace usb_host_runtime {
inline bool enableHostPortPower() {
#if defined(ARDUINO_ESP32_S3_USB_OTG) && defined(USB_HOST_EN) && \
defined(USB_HOST_POWER_VBUS) && defined(USB_HOST_POWER_OFF)
usbHostEnable(true);
usbHostPower(USB_HOST_POWER_VBUS);
return true;
#else
return false;
#endif
}
} // namespace usb_host_runtime
+228
View File
@@ -0,0 +1,228 @@
#include <Arduino.h>
#include <USB.h>
#include <USBMSC.h>
#include <esp_log.h>
#include <esp_partition.h>
#include "usb/UsbMassStorageRuntime.h"
namespace {
constexpr uint32_t kUsbMscBlockSize = 512;
constexpr uint32_t kSectorBytes = 4096;
constexpr char kUsbMscPartitionLabel[] = "usbmsc";
constexpr char kUsbMscVendorId[] = "ESP32";
constexpr char kUsbMscProductId[] = "USB_MSC";
constexpr char kUsbMscProductRevision[] = "1.0";
constexpr char kUsbMscLogTag[] = "USB_MSC";
USBMSC g_usb_msc;
const esp_partition_t* g_msc_partition = nullptr;
uint32_t g_msc_blocks = 0;
bool g_msc_ready = false;
uint32_t alignDown(uint32_t value, uint32_t align) {
return value & ~(align - 1U);
}
uint32_t alignUp(uint32_t value, uint32_t align) {
return (value + align - 1U) & ~(align - 1U);
}
bool isInRange(uint32_t offset, uint32_t size) {
if (g_msc_partition == nullptr) {
return false;
}
if (offset > g_msc_partition->size) {
return false;
}
return (static_cast<uint64_t>(offset) + size) <= g_msc_partition->size;
}
bool eraseRange(uint32_t offset, uint32_t size) {
if (!isInRange(offset, size)) {
return false;
}
const uint32_t aligned_offset = alignDown(offset, kSectorBytes);
const uint32_t aligned_size = alignUp(size, kSectorBytes);
const uint64_t partition_end = g_msc_partition->size;
if (aligned_offset >= partition_end) {
return false;
}
if (aligned_offset + aligned_size > partition_end) {
return false;
}
const esp_err_t err = esp_partition_erase_range(g_msc_partition, aligned_offset, aligned_size);
if (err != ESP_OK) {
ESP_LOGE(kUsbMscLogTag, "erase_range failed off=%lu size=%lu err=%s", aligned_offset, aligned_size, esp_err_to_name(err));
return false;
}
return true;
}
bool onStartStop(uint8_t power_condition, bool start, bool load_eject) {
(void)power_condition;
g_msc_ready = start || !load_eject;
ESP_LOGI(kUsbMscLogTag, "start_stop power_condition=%u start=%d eject=%d", power_condition, static_cast<int>(start),
static_cast<int>(load_eject));
return true;
}
int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t* buffer, uint32_t bufsize) {
if (!g_msc_ready || g_msc_partition == nullptr) {
return 0;
}
if (offset >= kUsbMscBlockSize) {
return 0;
}
const uint64_t block_offset = static_cast<uint64_t>(lba) * kUsbMscBlockSize;
const uint64_t bytes_offset_64 = block_offset + offset;
const uint64_t max_writable = g_msc_partition->size;
if (bytes_offset_64 >= max_writable) {
return 0;
}
const uint32_t bytes_offset = static_cast<uint32_t>(bytes_offset_64);
const uint32_t max_chunk = static_cast<uint32_t>(max_writable - bytes_offset);
const uint32_t write_size = (bufsize > max_chunk) ? max_chunk : bufsize;
if (!isInRange(bytes_offset, write_size)) {
return 0;
}
uint32_t written = 0;
while (written < write_size) {
const uint32_t dst_offset = bytes_offset + written;
const uint32_t sector_start = alignDown(dst_offset, kSectorBytes);
const uint32_t sector_end = sector_start + kSectorBytes;
const uint32_t chunk_end = (bytes_offset + write_size < sector_end) ? (bytes_offset + write_size) : sector_end;
const uint32_t copy_len = chunk_end - dst_offset;
const uint32_t sector_pos = dst_offset - sector_start;
uint8_t sector[kSectorBytes];
const esp_err_t read_err = esp_partition_read(g_msc_partition, sector_start, sector, kSectorBytes);
if (read_err != ESP_OK) {
ESP_LOGE(kUsbMscLogTag, "sector read failed addr=%lu err=%s", sector_start, esp_err_to_name(read_err));
return 0;
}
memcpy(sector + sector_pos, buffer + written, copy_len);
if (!eraseRange(sector_start, kSectorBytes)) {
return 0;
}
const esp_err_t write_err = esp_partition_write(g_msc_partition, sector_start, sector, kSectorBytes);
if (write_err != ESP_OK) {
ESP_LOGE(kUsbMscLogTag,
"write failed lba=%lu offset=%lu size=%lu err=%s",
lba,
offset,
write_size,
esp_err_to_name(write_err));
return static_cast<int32_t>(written);
}
written += copy_len;
}
return static_cast<int32_t>(written);
}
int32_t onRead(uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize) {
if (!g_msc_ready || g_msc_partition == nullptr) {
return 0;
}
if (offset >= kUsbMscBlockSize) {
return 0;
}
const uint64_t block_offset = static_cast<uint64_t>(lba) * kUsbMscBlockSize;
const uint64_t bytes_offset_64 = block_offset + offset;
if (bytes_offset_64 >= g_msc_partition->size) {
return 0;
}
const uint32_t bytes_offset = static_cast<uint32_t>(bytes_offset_64);
const uint32_t max_chunk = static_cast<uint32_t>(g_msc_partition->size - bytes_offset);
const uint32_t read_size = (bufsize > max_chunk) ? max_chunk : bufsize;
if (!isInRange(bytes_offset, read_size)) {
return 0;
}
const esp_err_t err = esp_partition_read(g_msc_partition, bytes_offset, buffer, read_size);
if (err != ESP_OK) {
ESP_LOGE(kUsbMscLogTag, "read failed lba=%lu offset=%lu size=%lu err=%s", lba, offset, read_size, esp_err_to_name(err));
return 0;
}
return static_cast<int32_t>(read_size);
}
void onUsbEvent(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) {
(void)arg;
(void)event_data;
if (event_base != ARDUINO_USB_EVENTS) {
return;
}
switch (event_id) {
case ARDUINO_USB_STARTED_EVENT:
ESP_LOGI(kUsbMscLogTag, "USB plugged");
break;
case ARDUINO_USB_STOPPED_EVENT:
ESP_LOGI(kUsbMscLogTag, "USB unplugged");
break;
default:
break;
}
}
} // namespace
namespace usb_msc_runtime {
bool beginUsbMassStorage() {
g_msc_partition = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, kUsbMscPartitionLabel);
if (g_msc_partition == nullptr) {
ESP_LOGE(kUsbMscLogTag, "partition '%s' not found", kUsbMscPartitionLabel);
return false;
}
g_msc_blocks = static_cast<uint32_t>(g_msc_partition->size / kUsbMscBlockSize);
g_usb_msc.onStartStop(onStartStop);
g_usb_msc.onRead(onRead);
g_usb_msc.onWrite(onWrite);
g_usb_msc.vendorID(kUsbMscVendorId);
g_usb_msc.productID(kUsbMscProductId);
g_usb_msc.productRevision(kUsbMscProductRevision);
g_usb_msc.mediaPresent(true);
if (!g_usb_msc.begin(g_msc_blocks, kUsbMscBlockSize)) {
ESP_LOGE(kUsbMscLogTag, "USBMSC begin failed");
g_msc_partition = nullptr;
return false;
}
USB.onEvent(onUsbEvent);
USB.begin();
g_msc_ready = true;
ESP_LOGI(kUsbMscLogTag,
"started: blocks=%lu size=%luKB label=%s",
g_msc_blocks,
g_msc_partition->size / 1024U,
g_msc_partition->label);
return true;
}
} // namespace usb_msc_runtime
+7
View File
@@ -0,0 +1,7 @@
#pragma once
namespace usb_msc_runtime {
bool beginUsbMassStorage();
} // namespace usb_msc_runtime
+126
View File
@@ -0,0 +1,126 @@
#include "ScopeDisplay.h"
#include <math.h>
#if defined(CONFIG_IDF_TARGET_ESP32)
#include <driver/dac.h>
#endif
namespace {
constexpr uint8_t kDefaultAmplitude = 48U;
constexpr uint16_t kDefaultFrequencyHz = 1200U;
constexpr uint16_t kMinFrequencyHz = 60U;
constexpr uint16_t kMaxFrequencyHz = 5000U;
} // namespace
ScopeDisplay::ScopeDisplay()
: initialized_(false),
configured_(false),
enabled_(false),
supported_(false),
frequency_hz_(kDefaultFrequencyHz),
amplitude_(kDefaultAmplitude),
last_tick_us_(0),
phase_(0.0f) {}
bool ScopeDisplay::supported() const {
return supported_;
}
bool ScopeDisplay::enabled() const {
return initialized_ && enabled_;
}
uint16_t ScopeDisplay::frequency() const {
return frequency_hz_;
}
uint8_t ScopeDisplay::amplitude() const {
return amplitude_;
}
bool ScopeDisplay::begin() {
#if defined(CONFIG_IDF_TARGET_ESP32)
dac_output_enable(DAC_CHANNEL_1);
dac_output_enable(DAC_CHANNEL_2);
initialized_ = true;
supported_ = true;
configured_ = true;
enabled_ = true;
last_tick_us_ = micros();
phase_ = 0.0f;
return true;
#else
initialized_ = false;
supported_ = false;
configured_ = false;
enabled_ = false;
return false;
#endif
}
void ScopeDisplay::end() {
if (!initialized_) {
return;
}
enabled_ = false;
#if defined(CONFIG_IDF_TARGET_ESP32)
dac_output_disable(DAC_CHANNEL_1);
dac_output_disable(DAC_CHANNEL_2);
#endif
initialized_ = false;
}
bool ScopeDisplay::configure(uint16_t frequency_hz, uint8_t amplitude) {
if (frequency_hz < kMinFrequencyHz || frequency_hz > kMaxFrequencyHz) {
return false;
}
if (amplitude == 0U) {
return false;
}
frequency_hz_ = frequency_hz;
amplitude_ = amplitude;
configured_ = true;
return true;
}
void ScopeDisplay::enable(bool value) {
if (!configured_ || !supported_) {
return;
}
enabled_ = value;
if (enabled_) {
if (!initialized_) {
begin();
}
}
}
void ScopeDisplay::tick() {
if (!initialized_ || !enabled_ || !configured_) {
return;
}
#if defined(CONFIG_IDF_TARGET_ESP32)
const uint32_t now = micros();
if ((now - last_tick_us_) < kTickIntervalUs) {
return;
}
last_tick_us_ = now;
const float step = kTau * static_cast<float>(frequency_hz_) * (kTickIntervalUs / 1000000.0f);
phase_ += step;
if (phase_ >= kTau) {
phase_ -= kTau;
}
const float x = sinf(phase_);
const float y = cosf(phase_);
const int v1 = 128 + static_cast<int>(x * static_cast<float>(amplitude_));
const int v2 = 128 + static_cast<int>(y * static_cast<float>(amplitude_));
const uint8_t sample1 = static_cast<uint8_t>(constrain(v1, 0, 255));
const uint8_t sample2 = static_cast<uint8_t>(constrain(v2, 0, 255));
dac_output_voltage(DAC_CHANNEL_1, sample1);
dac_output_voltage(DAC_CHANNEL_2, sample2);
#endif
}
+35
View File
@@ -0,0 +1,35 @@
#ifndef VISUAL_SCOPE_DISPLAY_H
#define VISUAL_SCOPE_DISPLAY_H
#include <Arduino.h>
class ScopeDisplay {
public:
ScopeDisplay();
bool begin();
void end();
bool supported() const;
bool enabled() const;
bool configure(uint16_t frequency_hz, uint8_t amplitude);
void enable(bool value);
void tick();
uint16_t frequency() const;
uint8_t amplitude() const;
private:
static constexpr uint32_t kTickIntervalUs = 300;
static constexpr float kTau = 6.283185307179586f;
bool initialized_;
bool configured_;
bool enabled_;
bool supported_;
uint16_t frequency_hz_;
uint8_t amplitude_;
uint32_t last_tick_us_;
float phase_;
};
#endif // VISUAL_SCOPE_DISPLAY_H
+13 -65
View File
@@ -1,6 +1,10 @@
#include "web/WebServerManager.h"
#ifdef USB_MSC_BOOT_ENABLE
#include <FFat.h>
#else
#include <SPIFFS.h>
#endif
namespace {
constexpr bool kForceAuthDisabled = true;
@@ -27,11 +31,19 @@ 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();
@@ -280,68 +292,6 @@ 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;
@@ -389,9 +339,7 @@ bool WebServerManager::isEffectCommand(const String& command_line) {
token.trim();
token.toUpperCase();
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";
return token == "CALL" || token == "PLAY" || token == "CAPTURE_START" || token == "CAPTURE_STOP";
}
void WebServerManager::refreshStatusCache() {
+5 -6
View File
@@ -61,7 +61,7 @@ String buildFallbackApSsid() {
return String(name);
}
void enforceBtCoexModemSleep() {
void enforceCoexModemSleep() {
WiFi.setSleep(true);
const esp_err_t err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT && err != ESP_ERR_WIFI_NOT_STARTED) {
@@ -84,7 +84,7 @@ WifiManager::WifiManager()
next_coex_reassert_ms_(0) {}
void WifiManager::enforceCoexPolicy() const {
enforceBtCoexModemSleep();
enforceCoexModemSleep();
}
bool WifiManager::begin(const char* ssid, const char* password, uint32_t timeout_ms) {
@@ -104,10 +104,9 @@ bool WifiManager::connect(const String& ssid, const String& password, uint32_t t
stopFallbackAp();
WiFi.mode(WIFI_STA);
// Required by ESP32 WiFi+BT coexistence.
// Keep reconnect policy manual to avoid repeated WiFi timer churn under BT load.
// Keep reconnect policy manual to avoid repeated WiFi timer churn from external clients.
WiFi.setAutoReconnect(false);
enforceCoexPolicy(); // Re-assert after mode switch to avoid BT coex abort.
enforceCoexPolicy();
WiFi.disconnect(false, true);
enforceCoexPolicy();
delay(100);
@@ -285,7 +284,7 @@ bool WifiManager::startFallbackAp() {
}
WiFi.mode(WIFI_AP_STA);
// Required by ESP32 WiFi+BT coexistence in AP+STA mode.
// Keep a stable WiFi mode under AP+STA conditions.
WiFi.setAutoReconnect(false);
enforceCoexPolicy();
const bool ok = WiFi.softAP(