Esp32_RTC_ZACUS #27
+56
-57
@@ -1,92 +1,91 @@
|
||||
# ESP-NOW API v1 (enveloppe `msg_id/seq/type/payload/ack`)
|
||||
# ESP-NOW API v1
|
||||
|
||||
Date: 2026-02-21
|
||||
Scope: contrat d'échange entre `RTC_BL_PHONE` (A252) et la seconde carte.
|
||||
Date: 2026-02-23
|
||||
|
||||
## 1. Objectif
|
||||
Ce document est synchronisé avec le contrat opérationnel: `docs/espnow_contract.md`.
|
||||
|
||||
Normaliser les trames ESP-NOW pour:
|
||||
- corréler requête/réponse,
|
||||
- conserver compatibilité legacy,
|
||||
- simplifier l'intégration du second repo.
|
||||
## Source de vérité
|
||||
|
||||
## 2. Requête v1 (nouveau format recommandé)
|
||||
- Canonique: `docs/espnow_contract.md`
|
||||
- Ce fichier doit rester cohérent avec ce contrat.
|
||||
|
||||
## Reprise du contrat (v1)
|
||||
|
||||
- Trame recommandée: `type: "command"`, `ack: true`.
|
||||
- Corrélation: `msg_id` + `seq`.
|
||||
- Corps: objet JSON dans `payload`, ou texte compatible selon parser legacy.
|
||||
|
||||
Exemple de demande:
|
||||
|
||||
```json
|
||||
{
|
||||
"proto": "rtcbl/1",
|
||||
"msg_id": "req-001",
|
||||
"seq": 1,
|
||||
"seq": 7,
|
||||
"type": "command",
|
||||
"ack": true,
|
||||
"payload": {
|
||||
"cmd": "STATUS",
|
||||
"args": {}
|
||||
"cmd": "WIFI_STATUS"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Règles:
|
||||
- `proto=rtcbl/1` recommandé (toléré absent pour compat).
|
||||
- `msg_id` sert à corréler la réponse.
|
||||
- `seq` est un compteur local de trame (recommandé monotone par source).
|
||||
- `type=command|request|cmd` déclenche l'exécution côté firmware.
|
||||
- `ack=true` demande une réponse corrélée.
|
||||
- `payload.cmd` obligatoire pour une commande dispatcher.
|
||||
- `payload.args` optionnel; sérialisé puis passé au dispatcher.
|
||||
|
||||
## 3. Réponse v1 (ack corrélée)
|
||||
Réponse attendue:
|
||||
|
||||
```json
|
||||
{
|
||||
"proto": "rtcbl/1",
|
||||
"msg_id": "req-001",
|
||||
"seq": 1,
|
||||
"seq": 7,
|
||||
"type": "ack",
|
||||
"ack": true,
|
||||
"payload": {
|
||||
"ok": true,
|
||||
"code": "STATUS",
|
||||
"data": {},
|
||||
"error": ""
|
||||
"code": "WIFI_STATUS",
|
||||
"error": "",
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Règles:
|
||||
- `msg_id` et `seq` reprennent la requête.
|
||||
- `payload.ok=false` => `payload.error` non vide.
|
||||
- `payload.data` contient le JSON de la commande si disponible.
|
||||
- fallback possible `payload.data_raw` si la réponse n'est pas JSON.
|
||||
## Compatibilité parser (legacy)
|
||||
|
||||
## 4. Compatibilité legacy
|
||||
- `cmd`, `command`, `action` (root ou `payload`).
|
||||
- format texte: `"CMD arg"`.
|
||||
- ancien champ `id` au lieu de `msg_id` côté trames historiques.
|
||||
|
||||
Le firmware continue d'accepter les formats existants:
|
||||
- `{"cmd":"..."}`
|
||||
- `{"raw":"..."}`
|
||||
- `{"command":"..."}`
|
||||
- `{"action":"..."}`
|
||||
- variantes imbriquées via `event`, `message`, `payload`
|
||||
- format historique `rtcbl/1`:
|
||||
- `{"proto":"rtcbl/1","id":"...","cmd":"...","args":{}}`
|
||||
|
||||
## 5. Commandes recommandées v1
|
||||
## Commandes supportées
|
||||
|
||||
- `STATUS`
|
||||
- `RING`
|
||||
- `CALL`
|
||||
- `HOOK`
|
||||
- `LIST_FILES`
|
||||
- `PLAY_FILE`
|
||||
- `WIFI_STATUS`
|
||||
- `MQTT_STATUS`
|
||||
- `ESPNOW_STATUS`
|
||||
- `BT_STATUS`
|
||||
- `UNLOCK`
|
||||
- `NEXT`
|
||||
- `WIFI_DISCONNECT`
|
||||
- `WIFI_RECONNECT`
|
||||
- `ESPNOW_ON`
|
||||
- `ESPNOW_OFF`
|
||||
- `STORY_REFRESH_SD`
|
||||
- `SC_EVENT`
|
||||
- `RING`
|
||||
- `SCENE <id>`
|
||||
- `SCENE` retourne une erreur `missing_scene_id` si `id` absent
|
||||
- `NEXT` retourne `scene_not_found` si aucune scène n’est active
|
||||
|
||||
## 6. Intégration second repo
|
||||
## Erreurs connues
|
||||
|
||||
Checklist minimum côté seconde carte:
|
||||
1. Émettre `msg_id` unique + `seq` monotone.
|
||||
2. Positionner `type=command` et `ack=true` pour obtenir une réponse.
|
||||
3. Implémenter timeout de réponse (2-5s) et corréler sur `msg_id`.
|
||||
4. Prévoir fallback legacy (`rtcbl/1` + formats `cmd/raw/command/action`) pour compat.
|
||||
- `unsupported_command`
|
||||
- `missing_scene_id`
|
||||
- `scene_not_found`
|
||||
- `WIFI_RECONNECT no_credentials`
|
||||
- erreurs réseau: `peer`, `payload` vide, trame > 240
|
||||
|
||||
## Limites runtime
|
||||
|
||||
- Trame brute max: `240`
|
||||
- Peers: `16`
|
||||
- RX queue: `6`
|
||||
|
||||
## Réception firmware
|
||||
|
||||
- `type=command` -> `executeEspNowCommandPayload`
|
||||
- `type` non-`command` ignoré pour dispatch de commande
|
||||
- `type=ack` ignoré côté dispatch commande
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# ESP-NOW Contract — Freenove (Quick)
|
||||
|
||||
Cible: intégration seconde carte (app/RTC).
|
||||
|
||||
## Recommandation trame
|
||||
- `type=command`
|
||||
- `msg_id` et `seq` pour corrélation
|
||||
- `ack=true` pour obtenir un retour
|
||||
- `payload` en JSON ou texte
|
||||
|
||||
Exemple recommandé:
|
||||
|
||||
```json
|
||||
{
|
||||
"msg_id": "req-001",
|
||||
"seq": 7,
|
||||
"type": "command",
|
||||
"ack": true,
|
||||
"payload": {
|
||||
"cmd": "WIFI_STATUS"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Réponse ACK attendue
|
||||
|
||||
```json
|
||||
{
|
||||
"msg_id": "req-001",
|
||||
"seq": 7,
|
||||
"type": "ack",
|
||||
"ack": true,
|
||||
"payload": { "ok": true, "code": "WIFI_STATUS", "error": "", "data": {} }
|
||||
}
|
||||
```
|
||||
|
||||
## Compatibilité entrées
|
||||
Le parseur accepte aussi:
|
||||
|
||||
- `cmd`, `command`, `action` (root ou `payload`)
|
||||
- format texte "`CMD arg`"
|
||||
|
||||
## Commandes supportées (ESP-NOW)
|
||||
|
||||
- `STATUS`
|
||||
- `WIFI_STATUS`
|
||||
- `ESPNOW_STATUS`
|
||||
- `UNLOCK`, `NEXT`
|
||||
- `WIFI_DISCONNECT`, `WIFI_RECONNECT`
|
||||
- `ESPNOW_ON`, `ESPNOW_OFF`
|
||||
- `STORY_REFRESH_SD`
|
||||
- `SC_EVENT`
|
||||
- `RING`
|
||||
- `SCENE <id>`
|
||||
- exemples: `SCENE SCENE_WIN_ETAPE`
|
||||
- JSON: `{"cmd":"SCENE","args":{"id":"SCENE_WIN_ETAPE"}}`
|
||||
- `SCENE` retourne les erreurs:
|
||||
- `missing_scene_id` si `id` absent
|
||||
- `scene_not_found` si aucune scène active pour un `NEXT`
|
||||
- Actions de contrôle (générique): `HW_*`, `AUDIO_*`, `MEDIA_*`, etc.
|
||||
|
||||
## Erreurs fréquentes
|
||||
- `unsupported_command`
|
||||
- `missing_scene_id`
|
||||
- `scene_not_found`
|
||||
- `WIFI_RECONNECT no_credentials` (pas de SSID/pw stocké)
|
||||
- erreurs réseau/connexion habituelles (`peer`, `payload` vide, trame > 240)
|
||||
|
||||
## Limites runtime
|
||||
- Trame brute max: `240`
|
||||
- Peers: `16`
|
||||
- RX queue: `6`
|
||||
|
||||
## Réception côté firmware
|
||||
- `type=command` -> `executeEspNowCommandPayload`
|
||||
- autre `type` -> ignoré en commande (bridge story seulement si activé)
|
||||
- les frames `type=ack` reçues sont ignorées côté dispatch
|
||||
+28
-7
@@ -11,16 +11,10 @@ monitor_speed = 115200
|
||||
test_build_src = yes
|
||||
build_flags =
|
||||
-DCORE_DEBUG_LEVEL=1
|
||||
-DCONFIG_ASYNC_TCP_STACK_SIZE=3072
|
||||
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=8
|
||||
-DCONFIG_ASYNC_TCP_PRIORITY=8
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson@^7.0.4
|
||||
ESP32Async/AsyncTCP@^3.3.2
|
||||
ESP32Async/ESPAsyncWebServer@^3.6.0
|
||||
throwtheswitch/Unity@^2.6.1
|
||||
knolleary/PubSubClient@^2.8
|
||||
https://github.com/luisllamasbinaburo/Arduino-List.git#master
|
||||
https://github.com/pschatzmann/arduino-audio-tools.git
|
||||
lib_ignore =
|
||||
ESPAsyncTCP
|
||||
RPAsyncTCP
|
||||
@@ -29,17 +23,43 @@ lib_ignore =
|
||||
[env:esp32dev]
|
||||
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
|
||||
build_src_filter =
|
||||
+<main.cpp>
|
||||
+<audio/AudioEngine.cpp>
|
||||
+<audio/Es8388Driver.cpp>
|
||||
+<config/A252ConfigStore.cpp>
|
||||
+<core/CommandDispatcher.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>
|
||||
+<slic/Ks0835SlicController.cpp>
|
||||
+<telephony/DtmfDecoder.cpp>
|
||||
+<telephony/TelephonyService.cpp>
|
||||
|
||||
[env:test]
|
||||
platform = espressif32
|
||||
@@ -53,6 +73,7 @@ lib_deps =
|
||||
throwtheswitch/Unity@^2.6.1
|
||||
knolleary/PubSubClient@^2.8
|
||||
https://github.com/luisllamasbinaburo/Arduino-List.git#master
|
||||
https://github.com/pschatzmann/arduino-audio-tools.git
|
||||
|
||||
[env:esp32-s3-devkitc-1]
|
||||
board = esp32-s3-devkitc-1
|
||||
|
||||
+179
-256
@@ -1,6 +1,6 @@
|
||||
#include "audio/AudioEngine.h"
|
||||
|
||||
#include <SPIFFS.h>
|
||||
#include <SD_MMC.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -14,16 +14,13 @@ constexpr float kTwoPi = 6.28318530718f;
|
||||
constexpr int16_t kDialToneAmplitude = 32000;
|
||||
constexpr float kDialToneLinearGain = 1.14f;
|
||||
constexpr size_t kDialToneChunkFrames = 160;
|
||||
constexpr size_t kStereoChannels = 2;
|
||||
constexpr size_t kMaxChannels = 2;
|
||||
constexpr float kDialToneAttackMs = 25.0f;
|
||||
constexpr float kDialToneReleaseMs = 40.0f;
|
||||
constexpr uint32_t kDialToneWavSeconds = 1;
|
||||
constexpr char kDialToneWavPrefix[] = "/dialtone_425_";
|
||||
constexpr bool kDialToneUseWav = false;
|
||||
constexpr TickType_t kI2sWriteTimeoutTicks = pdMS_TO_TICKS(2);
|
||||
}
|
||||
constexpr TickType_t kI2sWriteTimeoutMs = 2;
|
||||
constexpr TickType_t kI2sReadTimeoutMs = 2;
|
||||
constexpr size_t kPlaybackCopyBytes = 1024;
|
||||
|
||||
namespace {
|
||||
int16_t clampInt16(float value) {
|
||||
if (value > static_cast<float>(std::numeric_limits<int16_t>::max())) {
|
||||
return std::numeric_limits<int16_t>::max();
|
||||
@@ -33,6 +30,18 @@ int16_t clampInt16(float value) {
|
||||
}
|
||||
return static_cast<int16_t>(value);
|
||||
}
|
||||
|
||||
int bitsPerSampleToInt(i2s_bits_per_sample_t bits) {
|
||||
switch (bits) {
|
||||
case I2S_BITS_PER_SAMPLE_24BIT:
|
||||
return 24;
|
||||
case I2S_BITS_PER_SAMPLE_32BIT:
|
||||
return 32;
|
||||
case I2S_BITS_PER_SAMPLE_16BIT:
|
||||
default:
|
||||
return 16;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
AudioConfig defaultAudioConfigForProfile(BoardProfile profile) {
|
||||
@@ -61,13 +70,30 @@ AudioEngine::AudioEngine()
|
||||
capture_active_(false),
|
||||
capture_clients_mask_(0),
|
||||
playing_(false),
|
||||
play_until_ms_(0),
|
||||
features_(getFeatureMatrix(detectBoardProfile())) {}
|
||||
features_(getFeatureMatrix(detectBoardProfile())) {
|
||||
wav_stream_.setOutput(i2s_stream_);
|
||||
wav_stream_.setDecoder(&wav_decoder_);
|
||||
wav_copy_.setCheckAvailable(false);
|
||||
wav_copy_.setCheckAvailableForWrite(false);
|
||||
wav_copy_.setMinCopySize(sizeof(int16_t));
|
||||
wav_copy_.setRetry(2);
|
||||
wav_copy_.setRetryDelay(2);
|
||||
}
|
||||
|
||||
AudioEngine::~AudioEngine() {
|
||||
end();
|
||||
}
|
||||
|
||||
size_t AudioEngine::activeChannelCount(i2s_channel_fmt_t channel_format) {
|
||||
switch (channel_format) {
|
||||
case I2S_CHANNEL_FMT_ONLY_LEFT:
|
||||
case I2S_CHANNEL_FMT_ONLY_RIGHT:
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioEngine::lockI2s() const {
|
||||
if (i2s_io_mutex_ == nullptr) {
|
||||
return true;
|
||||
@@ -81,45 +107,69 @@ void AudioEngine::unlockI2s() const {
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioEngine::ensureSdMounted() {
|
||||
if (sd_mount_attempted_) {
|
||||
return sd_ready_;
|
||||
}
|
||||
|
||||
sd_mount_attempted_ = true;
|
||||
sd_ready_ = SD_MMC.begin();
|
||||
if (!sd_ready_) {
|
||||
Serial.println("[AudioEngine] SD_MMC mount failed");
|
||||
}
|
||||
return sd_ready_;
|
||||
}
|
||||
|
||||
void AudioEngine::stopPlaybackFile() {
|
||||
wav_copy_.end();
|
||||
wav_stream_.end();
|
||||
if (playback_file_) {
|
||||
playback_file_.close();
|
||||
}
|
||||
playback_path_ = "";
|
||||
playback_data_remaining_ = 0;
|
||||
playback_input_channels_ = 0;
|
||||
playing_ = false;
|
||||
}
|
||||
|
||||
bool AudioEngine::prepareWavPlayback(File& file, const char* path) {
|
||||
(void)path;
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
// WAV parsing/format conversion is delegated to audio-tools WAVDecoder.
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioEngine::begin(const AudioConfig& config) {
|
||||
end();
|
||||
_config = config;
|
||||
|
||||
const i2s_mode_t mode = static_cast<i2s_mode_t>(
|
||||
I2S_MODE_MASTER | I2S_MODE_TX |
|
||||
((_config.enable_capture && features_.has_full_duplex_i2s) ? I2S_MODE_RX : 0));
|
||||
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;
|
||||
auto i2s_cfg = i2s_stream_.defaultConfig(mode);
|
||||
i2s_cfg.port_no = static_cast<int>(_config.port);
|
||||
i2s_cfg.sample_rate = _config.sample_rate;
|
||||
i2s_cfg.bits_per_sample = bitsPerSampleToInt(_config.bits_per_sample);
|
||||
i2s_cfg.channels = static_cast<int>(activeChannelCount(_config.channel_format));
|
||||
i2s_cfg.channel_format = _config.channel_format;
|
||||
i2s_cfg.pin_bck = _config.bck_pin;
|
||||
i2s_cfg.pin_ws = _config.ws_pin;
|
||||
i2s_cfg.pin_data = _config.data_out_pin;
|
||||
i2s_cfg.pin_data_rx = _config.data_in_pin;
|
||||
i2s_cfg.buffer_count = _config.dma_buf_count;
|
||||
i2s_cfg.buffer_size = _config.dma_buf_len;
|
||||
i2s_cfg.auto_clear = true;
|
||||
|
||||
_i2s_config = {
|
||||
.mode = mode,
|
||||
.sample_rate = _config.sample_rate,
|
||||
.bits_per_sample = _config.bits_per_sample,
|
||||
.channel_format = _config.channel_format,
|
||||
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
|
||||
.intr_alloc_flags = 0,
|
||||
.dma_buf_count = _config.dma_buf_count,
|
||||
.dma_buf_len = _config.dma_buf_len,
|
||||
.use_apll = false,
|
||||
.tx_desc_auto_clear = true,
|
||||
.fixed_mclk = 0,
|
||||
};
|
||||
|
||||
_i2s_pins = {
|
||||
.bck_io_num = _config.bck_pin,
|
||||
.ws_io_num = _config.ws_pin,
|
||||
.data_out_num = _config.data_out_pin,
|
||||
.data_in_num = _config.data_in_pin,
|
||||
};
|
||||
|
||||
if (i2s_driver_install(_config.port, &_i2s_config, 0, nullptr) != ESP_OK) {
|
||||
Serial.println("[AudioEngine] i2s_driver_install failed");
|
||||
if (!i2s_stream_.begin(i2s_cfg)) {
|
||||
Serial.println("[AudioEngine] i2s begin failed");
|
||||
driver_installed_ = false;
|
||||
return false;
|
||||
}
|
||||
if (i2s_set_pin(_config.port, &_i2s_pins) != ESP_OK) {
|
||||
Serial.println("[AudioEngine] i2s_set_pin failed");
|
||||
i2s_driver_uninstall(_config.port);
|
||||
driver_installed_ = false;
|
||||
return false;
|
||||
|
||||
if (i2s_stream_.driver() != nullptr) {
|
||||
i2s_stream_.driver()->setWaitTimeReadMs(kI2sReadTimeoutMs);
|
||||
i2s_stream_.driver()->setWaitTimeWriteMs(kI2sWriteTimeoutMs);
|
||||
}
|
||||
|
||||
if (i2s_io_mutex_ == nullptr) {
|
||||
@@ -129,7 +179,6 @@ bool AudioEngine::begin(const AudioConfig& config) {
|
||||
}
|
||||
}
|
||||
|
||||
i2s_zero_dma_buffer(_config.port);
|
||||
driver_installed_ = true;
|
||||
portENTER_CRITICAL(&capture_lock_);
|
||||
capture_clients_mask_ = 0U;
|
||||
@@ -141,12 +190,7 @@ bool AudioEngine::begin(const AudioConfig& config) {
|
||||
dial_tone_gain_ = 0.0f;
|
||||
dial_tone_phase_ = 0.0f;
|
||||
next_dial_tone_push_ms_ = 0;
|
||||
closeDialToneWav();
|
||||
dial_tone_wav_ready_ = false;
|
||||
dial_tone_wav_path_ = String(kDialToneWavPrefix) + String(_config.sample_rate) + ".wav";
|
||||
if (kDialToneUseWav) {
|
||||
ensureDialToneWav();
|
||||
}
|
||||
stopPlaybackFile();
|
||||
startTask();
|
||||
|
|
||||
Serial.printf("[AudioEngine] ready (full_duplex=%s)\n",
|
||||
supportsFullDuplex() ? "true" : "false");
|
||||
@@ -159,16 +203,16 @@ void AudioEngine::end() {
|
||||
}
|
||||
stopTask();
|
||||
stopDialTone();
|
||||
stopPlaybackFile();
|
||||
portENTER_CRITICAL(&capture_lock_);
|
||||
capture_clients_mask_ = 0U;
|
||||
capture_active_ = false;
|
||||
portEXIT_CRITICAL(&capture_lock_);
|
||||
closeDialToneWav();
|
||||
if (i2s_io_mutex_ != nullptr) {
|
||||
vSemaphoreDelete(i2s_io_mutex_);
|
||||
i2s_io_mutex_ = nullptr;
|
||||
}
|
||||
i2s_driver_uninstall(_config.port);
|
||||
i2s_stream_.end();
|
||||
driver_installed_ = false;
|
||||
}
|
||||
|
||||
@@ -222,10 +266,33 @@ bool AudioEngine::playFile(const char* path) {
|
||||
if (!driver_installed_ || path == nullptr || path[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
// Firmware skeleton: this marks a playback window for telephony flow.
|
||||
if (!ensureSdMounted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
stopDialTone();
|
||||
stopPlaybackFile();
|
||||
|
||||
playback_file_ = SD_MMC.open(path, FILE_READ);
|
||||
if (!playback_file_) {
|
||||
Serial.printf("[AudioEngine] sd file not found: %s\n", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!prepareWavPlayback(playback_file_, path)) {
|
||||
stopPlaybackFile();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!wav_stream_.begin()) {
|
||||
stopPlaybackFile();
|
||||
Serial.printf("[AudioEngine] wav decoder begin failed: %s\n", path);
|
||||
return false;
|
||||
}
|
||||
wav_copy_.begin(wav_stream_, playback_file_);
|
||||
playback_path_ = path;
|
||||
playing_ = true;
|
||||
play_until_ms_ = millis() + 2500;
|
||||
Serial.printf("[AudioEngine] play request: %s\n", path);
|
||||
Serial.printf("[AudioEngine] play sd wav (audio-tools): %s\n", path);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -274,8 +341,8 @@ size_t AudioEngine::readCaptureFrame(int16_t* dst, size_t samples) {
|
||||
metrics_.frames_requested += static_cast<uint32_t>(samples);
|
||||
const uint32_t start_ms = millis();
|
||||
const size_t byte_count = samples * sizeof(int16_t);
|
||||
size_t bytes_read = 0;
|
||||
if (i2s_read(_config.port, dst, byte_count, &bytes_read, 2) != ESP_OK || bytes_read == 0) {
|
||||
size_t bytes_read = i2s_stream_.readBytes(reinterpret_cast<uint8_t*>(dst), byte_count);
|
||||
if (bytes_read == 0) {
|
||||
std::memset(dst, 0, byte_count);
|
||||
metrics_.underrun_count++;
|
||||
metrics_.drop_frames += static_cast<uint32_t>(samples);
|
||||
@@ -303,10 +370,19 @@ size_t AudioEngine::readCaptureFrameNonBlocking(int16_t* dst, size_t samples) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (i2s_stream_.driver() != nullptr) {
|
||||
i2s_stream_.driver()->setWaitTimeReadMs(0);
|
||||
}
|
||||
|
||||
metrics_.frames_requested += static_cast<uint32_t>(samples);
|
||||
const size_t byte_count = samples * sizeof(int16_t);
|
||||
size_t bytes_read = 0;
|
||||
if (i2s_read(_config.port, dst, byte_count, &bytes_read, 0) != ESP_OK || bytes_read == 0) {
|
||||
const size_t bytes_read = i2s_stream_.readBytes(reinterpret_cast<uint8_t*>(dst), byte_count);
|
||||
|
||||
if (i2s_stream_.driver() != nullptr) {
|
||||
i2s_stream_.driver()->setWaitTimeReadMs(kI2sReadTimeoutMs);
|
||||
}
|
||||
|
||||
if (bytes_read == 0) {
|
||||
unlockI2s();
|
||||
return 0;
|
||||
}
|
||||
@@ -329,11 +405,7 @@ size_t AudioEngine::writePlaybackFrame(const int16_t* src, size_t samples) {
|
||||
}
|
||||
|
||||
const size_t byte_count = samples * sizeof(int16_t);
|
||||
size_t bytes_written = 0;
|
||||
if (i2s_write(_config.port, src, byte_count, &bytes_written, kI2sWriteTimeoutTicks) != ESP_OK) {
|
||||
unlockI2s();
|
||||
return 0;
|
||||
}
|
||||
const size_t bytes_written = i2s_stream_.write(reinterpret_cast<const uint8_t*>(src), byte_count);
|
||||
unlockI2s();
|
||||
return bytes_written / sizeof(int16_t);
|
||||
}
|
||||
@@ -346,9 +418,6 @@ bool AudioEngine::startDialTone() {
|
||||
if (!driver_installed_) {
|
||||
return false;
|
||||
}
|
||||
if (kDialToneUseWav) {
|
||||
ensureDialToneWav();
|
||||
}
|
||||
const bool was_active = dial_tone_active_;
|
||||
dial_tone_active_ = true;
|
||||
if (!was_active && dial_tone_gain_ <= 0.0001f) {
|
||||
@@ -376,6 +445,10 @@ bool AudioEngine::isPlaying() const {
|
||||
return playing_;
|
||||
}
|
||||
|
||||
bool AudioEngine::isSdReady() const {
|
||||
return sd_ready_;
|
||||
}
|
||||
|
||||
AudioRuntimeMetrics AudioEngine::metrics() const {
|
||||
return metrics_;
|
||||
}
|
||||
@@ -384,15 +457,36 @@ void AudioEngine::resetMetrics() {
|
||||
metrics_ = AudioRuntimeMetrics{};
|
||||
}
|
||||
|
||||
void AudioEngine::tick() {
|
||||
if (playing_ && millis() >= play_until_ms_) {
|
||||
playing_ = false;
|
||||
bool AudioEngine::streamPlaybackChunk() {
|
||||
if (!playback_file_) {
|
||||
stopPlaybackFile();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!driver_installed_ || playing_) {
|
||||
const size_t copied = wav_copy_.copyBytes(kPlaybackCopyBytes);
|
||||
if (copied > 0U) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!playback_file_.available()) {
|
||||
stopPlaybackFile();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioEngine::tick() {
|
||||
if (!driver_installed_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playing_) {
|
||||
if (streamPlaybackChunk()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bool tone_requested = dial_tone_active_;
|
||||
const bool tone_tail_active = dial_tone_gain_ > 0.0005f;
|
||||
if (!tone_requested && !tone_tail_active) {
|
||||
@@ -404,28 +498,14 @@ void AudioEngine::tick() {
|
||||
return;
|
||||
}
|
||||
|
||||
int16_t frame[kDialToneChunkFrames * kStereoChannels] = {0};
|
||||
const float phase_step = (kTwoPi * kDialToneHz) / static_cast<float>(_config.sample_rate);
|
||||
const size_t chunk_samples = kDialToneChunkFrames * kStereoChannels;
|
||||
const size_t chunk_bytes = chunk_samples * sizeof(int16_t);
|
||||
uint8_t pcm_raw[kDialToneChunkFrames * kStereoChannels * sizeof(int16_t)] = {0};
|
||||
bool wav_ready = false;
|
||||
size_t wav_filled = 0;
|
||||
|
||||
if (kDialToneUseWav && ensureDialToneWav() && openDialToneWav()) {
|
||||
while (wav_filled < chunk_bytes) {
|
||||
const int got = dial_tone_file_.read(pcm_raw + wav_filled, chunk_bytes - wav_filled);
|
||||
if (got > 0) {
|
||||
wav_filled += static_cast<size_t>(got);
|
||||
continue;
|
||||
}
|
||||
if (!dial_tone_file_.seek(dial_tone_wav_data_offset_)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
wav_ready = (wav_filled == chunk_bytes);
|
||||
const size_t channels = activeChannelCount(_config.channel_format);
|
||||
if (channels == 0U || channels > kMaxChannels) {
|
||||
return;
|
||||
}
|
||||
|
||||
int16_t frame[kDialToneChunkFrames * kMaxChannels] = {0};
|
||||
const float phase_step = (kTwoPi * kDialToneHz) / static_cast<float>(_config.sample_rate);
|
||||
|
||||
const float attack_step =
|
||||
1.0f / std::max(1.0f, (static_cast<float>(_config.sample_rate) * (kDialToneAttackMs / 1000.0f)));
|
||||
const float release_step =
|
||||
@@ -437,43 +517,26 @@ void AudioEngine::tick() {
|
||||
dial_tone_gain_ = std::max(0.0f, dial_tone_gain_ - release_step);
|
||||
}
|
||||
|
||||
int16_t sample_l = 0;
|
||||
int16_t sample_r = 0;
|
||||
if (wav_ready) {
|
||||
const size_t l_idx = i * kStereoChannels;
|
||||
const size_t r_idx = l_idx + 1;
|
||||
const size_t l_byte = l_idx * sizeof(int16_t);
|
||||
const size_t r_byte = r_idx * sizeof(int16_t);
|
||||
sample_l = static_cast<int16_t>(
|
||||
static_cast<uint16_t>(pcm_raw[l_byte]) |
|
||||
static_cast<uint16_t>(static_cast<uint16_t>(pcm_raw[l_byte + 1]) << 8));
|
||||
sample_r = static_cast<int16_t>(
|
||||
static_cast<uint16_t>(pcm_raw[r_byte]) |
|
||||
static_cast<uint16_t>(static_cast<uint16_t>(pcm_raw[r_byte + 1]) << 8));
|
||||
} else {
|
||||
const int16_t sample = static_cast<int16_t>(std::sin(dial_tone_phase_) * static_cast<float>(kDialToneAmplitude));
|
||||
sample_l = sample;
|
||||
sample_r = sample;
|
||||
dial_tone_phase_ += phase_step;
|
||||
if (dial_tone_phase_ >= kTwoPi) {
|
||||
dial_tone_phase_ -= kTwoPi;
|
||||
}
|
||||
const int16_t sample = static_cast<int16_t>(std::sin(dial_tone_phase_) * static_cast<float>(kDialToneAmplitude));
|
||||
dial_tone_phase_ += phase_step;
|
||||
if (dial_tone_phase_ >= kTwoPi) {
|
||||
dial_tone_phase_ -= kTwoPi;
|
||||
}
|
||||
|
||||
const float gain = dial_tone_gain_ * kDialToneLinearGain;
|
||||
frame[(i * kStereoChannels)] = clampInt16(static_cast<float>(sample_l) * gain);
|
||||
frame[(i * kStereoChannels) + 1] = clampInt16(static_cast<float>(sample_r) * gain);
|
||||
const int16_t out = clampInt16(static_cast<float>(sample) * dial_tone_gain_ * kDialToneLinearGain);
|
||||
for (size_t ch = 0; ch < channels; ++ch) {
|
||||
frame[i * channels + ch] = out;
|
||||
}
|
||||
}
|
||||
|
||||
const size_t requested_samples = kDialToneChunkFrames * kStereoChannels;
|
||||
const size_t requested_samples = kDialToneChunkFrames * channels;
|
||||
const size_t written_samples = writePlaybackFrame(frame, requested_samples);
|
||||
if (written_samples == 0U) {
|
||||
// Retry fast when TX queue/mutex was temporarily unavailable.
|
||||
next_dial_tone_push_ms_ = now + 1U;
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t written_frames = written_samples / kStereoChannels;
|
||||
const size_t written_frames = written_samples / channels;
|
||||
const uint32_t chunk_ms = static_cast<uint32_t>((1000U * written_frames) / _config.sample_rate);
|
||||
next_dial_tone_push_ms_ = now + (chunk_ms == 0U ? 1U : chunk_ms);
|
||||
}
|
||||
@@ -481,143 +544,3 @@ void AudioEngine::tick() {
|
||||
const AudioConfig& AudioEngine::config() const {
|
||||
return _config;
|
||||
}
|
||||
|
||||
bool AudioEngine::ensureSpiffsMounted() {
|
||||
if (spiffs_mount_attempted_) {
|
||||
return spiffs_ready_;
|
||||
}
|
||||
spiffs_mount_attempted_ = true;
|
||||
spiffs_ready_ = SPIFFS.begin(false);
|
||||
if (!spiffs_ready_) {
|
||||
Serial.println("[AudioEngine] SPIFFS not available (dial tone WAV fallback)");
|
||||
}
|
||||
return spiffs_ready_;
|
||||
}
|
||||
|
||||
bool AudioEngine::generateDialToneWav(const char* path) {
|
||||
if (path == nullptr || path[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (!ensureSpiffsMounted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SPIFFS.remove(path);
|
||||
File file = SPIFFS.open(path, FILE_WRITE);
|
||||
if (!file) {
|
||||
Serial.printf("[AudioEngine] cannot create %s\n", path);
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t channels = static_cast<uint16_t>(kStereoChannels);
|
||||
const uint16_t bits_per_sample = 16;
|
||||
const uint32_t bytes_per_sample = bits_per_sample / 8U;
|
||||
const uint32_t frames = _config.sample_rate * kDialToneWavSeconds;
|
||||
const uint32_t data_bytes = frames * channels * bytes_per_sample;
|
||||
const uint32_t riff_size = 36U + data_bytes;
|
||||
const uint32_t byte_rate = _config.sample_rate * channels * bytes_per_sample;
|
||||
const uint16_t block_align = static_cast<uint16_t>(channels * bytes_per_sample);
|
||||
|
||||
auto write_u16 = [&](uint16_t v) {
|
||||
uint8_t b[2] = {static_cast<uint8_t>(v & 0xFFU), static_cast<uint8_t>((v >> 8) & 0xFFU)};
|
||||
return file.write(b, sizeof(b)) == sizeof(b);
|
||||
};
|
||||
auto write_u32 = [&](uint32_t v) {
|
||||
uint8_t b[4] = {static_cast<uint8_t>(v & 0xFFU), static_cast<uint8_t>((v >> 8) & 0xFFU),
|
||||
static_cast<uint8_t>((v >> 16) & 0xFFU), static_cast<uint8_t>((v >> 24) & 0xFFU)};
|
||||
return file.write(b, sizeof(b)) == sizeof(b);
|
||||
};
|
||||
|
||||
bool ok = true;
|
||||
ok &= file.write(reinterpret_cast<const uint8_t*>("RIFF"), 4) == 4;
|
||||
ok &= write_u32(riff_size);
|
||||
ok &= file.write(reinterpret_cast<const uint8_t*>("WAVE"), 4) == 4;
|
||||
ok &= file.write(reinterpret_cast<const uint8_t*>("fmt "), 4) == 4;
|
||||
ok &= write_u32(16U);
|
||||
ok &= write_u16(1U);
|
||||
ok &= write_u16(channels);
|
||||
ok &= write_u32(_config.sample_rate);
|
||||
ok &= write_u32(byte_rate);
|
||||
ok &= write_u16(block_align);
|
||||
ok &= write_u16(bits_per_sample);
|
||||
ok &= file.write(reinterpret_cast<const uint8_t*>("data"), 4) == 4;
|
||||
ok &= write_u32(data_bytes);
|
||||
if (!ok) {
|
||||
file.close();
|
||||
SPIFFS.remove(path);
|
||||
return false;
|
||||
}
|
||||
|
||||
int16_t pcm[kDialToneChunkFrames * kStereoChannels] = {0};
|
||||
const float phase_step = (kTwoPi * kDialToneHz) / static_cast<float>(_config.sample_rate);
|
||||
float phase = 0.0f;
|
||||
uint32_t written_frames = 0;
|
||||
while (written_frames < frames) {
|
||||
const uint32_t remaining = frames - written_frames;
|
||||
const uint32_t this_chunk = std::min<uint32_t>(static_cast<uint32_t>(kDialToneChunkFrames), remaining);
|
||||
for (uint32_t i = 0; i < this_chunk; ++i) {
|
||||
const int16_t s = static_cast<int16_t>(std::sin(phase) * static_cast<float>(kDialToneAmplitude));
|
||||
pcm[(i * kStereoChannels)] = s;
|
||||
pcm[(i * kStereoChannels) + 1] = s;
|
||||
phase += phase_step;
|
||||
if (phase >= kTwoPi) {
|
||||
phase -= kTwoPi;
|
||||
}
|
||||
}
|
||||
const size_t bytes = static_cast<size_t>(this_chunk * kStereoChannels * sizeof(int16_t));
|
||||
if (file.write(reinterpret_cast<const uint8_t*>(pcm), bytes) != bytes) {
|
||||
file.close();
|
||||
SPIFFS.remove(path);
|
||||
return false;
|
||||
}
|
||||
written_frames += this_chunk;
|
||||
}
|
||||
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioEngine::ensureDialToneWav() {
|
||||
if (!dial_tone_wav_path_.isEmpty() && dial_tone_wav_ready_) {
|
||||
return true;
|
||||
}
|
||||
if (dial_tone_wav_path_.isEmpty()) {
|
||||
dial_tone_wav_path_ = String(kDialToneWavPrefix) + String(_config.sample_rate) + ".wav";
|
||||
}
|
||||
if (!ensureSpiffsMounted()) {
|
||||
return false;
|
||||
}
|
||||
if (!SPIFFS.exists(dial_tone_wav_path_.c_str())) {
|
||||
if (!generateDialToneWav(dial_tone_wav_path_.c_str())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
dial_tone_wav_ready_ = SPIFFS.exists(dial_tone_wav_path_.c_str());
|
||||
return dial_tone_wav_ready_;
|
||||
}
|
||||
|
||||
bool AudioEngine::openDialToneWav() {
|
||||
if (!dial_tone_wav_ready_) {
|
||||
return false;
|
||||
}
|
||||
if (dial_tone_file_) {
|
||||
return true;
|
||||
}
|
||||
dial_tone_file_ = SPIFFS.open(dial_tone_wav_path_.c_str(), FILE_READ);
|
||||
if (!dial_tone_file_) {
|
||||
dial_tone_wav_ready_ = false;
|
||||
return false;
|
||||
}
|
||||
if (!dial_tone_file_.seek(dial_tone_wav_data_offset_)) {
|
||||
dial_tone_file_.close();
|
||||
dial_tone_wav_ready_ = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioEngine::closeDialToneWav() {
|
||||
if (dial_tone_file_) {
|
||||
dial_tone_file_.close();
|
||||
}
|
||||
}
|
||||
|
||||
+17
-14
@@ -2,6 +2,7 @@
|
||||
#define AUDIO_ENGINE_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <AudioTools.h>
|
||||
#include <FS.h>
|
||||
#include <driver/i2s.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
@@ -60,22 +61,23 @@ public:
|
||||
virtual bool isDialToneActive() const;
|
||||
virtual bool supportsFullDuplex() const;
|
||||
virtual bool isPlaying() const;
|
||||
virtual bool isSdReady() const;
|
||||
virtual AudioRuntimeMetrics metrics() const;
|
||||
virtual void resetMetrics();
|
||||
virtual void tick();
|
||||
const AudioConfig& config() const;
|
||||
|
||||
private:
|
||||
static size_t activeChannelCount(i2s_channel_fmt_t channel_format);
|
||||
static void audioTaskFn(void* arg);
|
||||
void startTask();
|
||||
void stopTask();
|
||||
bool lockI2s() const;
|
||||
void unlockI2s() const;
|
||||
bool ensureDialToneWav();
|
||||
bool ensureSpiffsMounted();
|
||||
bool generateDialToneWav(const char* path);
|
||||
bool openDialToneWav();
|
||||
void closeDialToneWav();
|
||||
bool ensureSdMounted();
|
||||
void stopPlaybackFile();
|
||||
bool prepareWavPlayback(File& file, const char* path);
|
||||
bool streamPlaybackChunk();
|
||||
|
||||
bool driver_installed_ = false;
|
||||
bool capture_active_ = false;
|
||||
@@ -86,22 +88,23 @@ private:
|
||||
float dial_tone_gain_ = 0.0f;
|
||||
float dial_tone_phase_ = 0.0f;
|
||||
uint32_t next_dial_tone_push_ms_ = 0;
|
||||
uint32_t play_until_ms_ = 0;
|
||||
bool sd_mount_attempted_ = false;
|
||||
bool sd_ready_ = false;
|
||||
File playback_file_;
|
||||
String playback_path_;
|
||||
uint32_t playback_data_remaining_ = 0;
|
||||
uint16_t playback_input_channels_ = 0;
|
||||
AudioConfig _config;
|
||||
FeatureMatrix features_;
|
||||
AudioRuntimeMetrics metrics_;
|
||||
i2s_config_t _i2s_config{};
|
||||
i2s_pin_config_t _i2s_pins{};
|
||||
audio_tools::I2SStream i2s_stream_;
|
||||
audio_tools::WAVDecoder wav_decoder_;
|
||||
audio_tools::EncodedAudioStream wav_stream_;
|
||||
audio_tools::StreamCopy wav_copy_;
|
||||
mutable SemaphoreHandle_t i2s_io_mutex_ = nullptr;
|
||||
TaskHandle_t task_handle_ = nullptr;
|
||||
static constexpr uint16_t kAudioTaskStackWords = 4096;
|
||||
static constexpr uint8_t kAudioTaskPriority = 5;
|
||||
bool spiffs_mount_attempted_ = false;
|
||||
bool spiffs_ready_ = false;
|
||||
bool dial_tone_wav_ready_ = false;
|
||||
String dial_tone_wav_path_;
|
||||
File dial_tone_file_;
|
||||
uint32_t dial_tone_wav_data_offset_ = 44;
|
||||
portMUX_TYPE capture_lock_ = portMUX_INITIALIZER_UNLOCKED;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,12 +15,13 @@ struct A252PinsConfig {
|
||||
int es8388_sda = 33;
|
||||
int es8388_scl = 32;
|
||||
|
||||
int slic_rm = 22;
|
||||
int slic_fr = 19;
|
||||
int slic_shk = 36;
|
||||
// A252 bench defaults.
|
||||
int slic_rm = 18;
|
||||
int slic_fr = 5;
|
||||
int slic_shk = 23;
|
||||
int slic_line = -1;
|
||||
int slic_pd = 18;
|
||||
bool hook_active_high = false;
|
||||
int slic_pd = 19;
|
||||
bool hook_active_high = true;
|
||||
};
|
||||
|
||||
struct A252AudioConfig {
|
||||
|
||||
@@ -32,7 +32,11 @@ DispatchResponse CommandDispatcher::dispatch(const String& line) const {
|
||||
if (it == handlers_.end()) {
|
||||
DispatchResponse resp;
|
||||
resp.ok = false;
|
||||
resp.code = "UNKNOWN_COMMAND " + cmd;
|
||||
resp.code = "unsupported_command";
|
||||
if (!cmd.isEmpty()) {
|
||||
resp.code += ' ';
|
||||
resp.code += cmd;
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
|
||||
+125
-322
@@ -1,20 +1,17 @@
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <esp_log.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "audio/AudioEngine.h"
|
||||
#include "audio/Es8388Driver.h"
|
||||
#include "bluetooth/BluetoothManager.h"
|
||||
#include "config/A252ConfigStore.h"
|
||||
#include "core/CommandDispatcher.h"
|
||||
#include "core/PlatformProfile.h"
|
||||
#include "props/EspNowBridge.h"
|
||||
#include "props/PropsBridge.h"
|
||||
#include "slic/Ks0835SlicController.h"
|
||||
#include "telephony/TelephonyService.h"
|
||||
#include "web/WebServerManager.h"
|
||||
#include "wifi/WifiCredentialsStorage.h"
|
||||
#include "wifi/WifiManager.h"
|
||||
#include "wifi/WifiManagerInstance.h"
|
||||
|
||||
#ifndef UNIT_TEST
|
||||
namespace {
|
||||
@@ -22,7 +19,6 @@ namespace {
|
||||
constexpr uint32_t kSerialBaud = 115200;
|
||||
constexpr int kAudioAmpEnablePin = 21;
|
||||
constexpr char kBootLogTag[] = "RTC_BOOT";
|
||||
constexpr uint32_t kBootWifiConnectTimeoutMs = 2500;
|
||||
constexpr bool kPrintHelpOnBoot = false;
|
||||
|
||||
BoardProfile g_profile = BoardProfile::ESP32_A252;
|
||||
@@ -30,18 +26,14 @@ FeatureMatrix g_features = getFeatureMatrix(BoardProfile::ESP32_A252);
|
||||
|
||||
A252PinsConfig g_pins_cfg = A252ConfigStore::defaultPins();
|
||||
A252AudioConfig g_audio_cfg = A252ConfigStore::defaultAudio();
|
||||
MqttConfig g_mqtt_cfg = A252ConfigStore::defaultMqtt();
|
||||
EspNowPeerStore g_peer_store;
|
||||
String g_active_scene_id;
|
||||
|
||||
Ks0835SlicController g_slic;
|
||||
AudioEngine g_audio;
|
||||
Es8388Driver g_codec;
|
||||
TelephonyService g_telephony;
|
||||
WifiManager g_wifi;
|
||||
PropsBridge g_mqtt;
|
||||
EspNowBridge g_espnow;
|
||||
BluetoothManager g_bt;
|
||||
WebServerManager g_web;
|
||||
CommandDispatcher g_dispatcher;
|
||||
String g_serial_line;
|
||||
|
||||
@@ -271,6 +263,38 @@ bool isMacAddressString(const String& value) {
|
||||
return A252ConfigStore::parseMac(value, mac);
|
||||
}
|
||||
|
||||
bool parseSceneIdFromArgs(const String& args, String& scene_id) {
|
||||
scene_id = "";
|
||||
String normalized = args;
|
||||
normalized.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized[0] == '{') {
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, normalized) == DeserializationError::Ok && doc.is<JsonObject>()) {
|
||||
scene_id = doc["id"] | "";
|
||||
scene_id.trim();
|
||||
return !scene_id.isEmpty();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized[0] == '"') {
|
||||
if (normalized.length() >= 2U) {
|
||||
scene_id = normalized.substring(1, normalized.length() - 1);
|
||||
scene_id.trim();
|
||||
}
|
||||
return !scene_id.isEmpty();
|
||||
}
|
||||
|
||||
String rest;
|
||||
splitFirstToken(normalized, scene_id, rest);
|
||||
scene_id.trim();
|
||||
return !scene_id.isEmpty();
|
||||
}
|
||||
|
||||
AudioConfig buildI2sConfig(const A252PinsConfig& pins_cfg, const A252AudioConfig& audio_cfg) {
|
||||
AudioConfig cfg;
|
||||
cfg.port = I2S_NUM_0;
|
||||
@@ -315,10 +339,12 @@ bool applyHardwareConfig() {
|
||||
|
||||
g_telephony.begin(g_profile, g_slic, g_audio);
|
||||
g_telephony.setDialCallback([](const String& number) {
|
||||
return g_bt.dial(number);
|
||||
Serial.printf("[Telephony] dial request ignored (BT disabled): %s\n", number.c_str());
|
||||
return false;
|
||||
});
|
||||
g_telephony.setAnswerCallback([]() {
|
||||
return g_bt.answerCall();
|
||||
Serial.println("[Telephony] answer request ignored (BT disabled)");
|
||||
return false;
|
||||
});
|
||||
|
||||
Serial.printf("[RTC_BL_PHONE] HW init slic=%s codec=%s audio=%s\n",
|
||||
@@ -326,7 +352,7 @@ bool applyHardwareConfig() {
|
||||
codec_ok ? "ok" : "fail",
|
||||
audio_ok ? "ok" : "fail");
|
||||
|
||||
return slic_ok && audio_ok;
|
||||
return slic_ok && codec_ok && audio_ok;
|
||||
}
|
||||
|
||||
void appendAudioMetrics(JsonObject root) {
|
||||
@@ -342,6 +368,8 @@ void appendAudioMetrics(JsonObject root) {
|
||||
JsonObject audio = root["audio"].to<JsonObject>();
|
||||
audio["full_duplex"] = g_audio.supportsFullDuplex();
|
||||
audio["dial_tone_active"] = g_audio.isDialToneActive();
|
||||
audio["playing"] = g_audio.isPlaying();
|
||||
audio["sd_ready"] = g_audio.isSdReady();
|
||||
audio["frames"] = metrics.frames_read;
|
||||
audio["underrun"] = metrics.underrun_count;
|
||||
audio["drop"] = metrics.drop_frames;
|
||||
@@ -350,6 +378,7 @@ void appendAudioMetrics(JsonObject root) {
|
||||
|
||||
void fillStatusSnapshot(JsonObject root) {
|
||||
root["board_profile"] = boardProfileToString(g_profile);
|
||||
root["active_scene"] = g_active_scene_id;
|
||||
|
||||
JsonObject telephony = root["telephony"].to<JsonObject>();
|
||||
telephony["state"] = telephonyStateToString(g_telephony.state());
|
||||
@@ -357,38 +386,17 @@ void fillStatusSnapshot(JsonObject root) {
|
||||
|
||||
appendAudioMetrics(root);
|
||||
|
||||
JsonObject wifi = root["wifi"].to<JsonObject>();
|
||||
g_wifi.statusToJson(wifi);
|
||||
|
||||
JsonObject mqtt = root["mqtt"].to<JsonObject>();
|
||||
g_mqtt.statusToJson(mqtt);
|
||||
|
||||
JsonObject espnow = root["espnow"].to<JsonObject>();
|
||||
g_espnow.statusToJson(espnow);
|
||||
|
||||
JsonObject bluetooth = root["bluetooth"].to<JsonObject>();
|
||||
g_bt.statusToJson(bluetooth);
|
||||
|
||||
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::mqttToJson(g_mqtt_cfg, config["mqtt"].to<JsonObject>(), false);
|
||||
|
||||
JsonArray peers = config["espnow_peers"].to<JsonArray>();
|
||||
A252ConfigStore::peersToJson(g_peer_store, peers);
|
||||
}
|
||||
|
||||
void publishStatusIfConnected() {
|
||||
if (!g_mqtt.isConnected()) {
|
||||
return;
|
||||
}
|
||||
JsonDocument doc;
|
||||
fillStatusSnapshot(doc.to<JsonObject>());
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
g_mqtt.publish("status", payload, false);
|
||||
}
|
||||
|
||||
bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& error) {
|
||||
A252PinsConfig next = target;
|
||||
|
||||
@@ -464,7 +472,6 @@ bool applyPinsPatch(JsonVariantConst patch, A252PinsConfig& target, String& erro
|
||||
next.hook_active_high = patch["hook_active_high"].as<bool>();
|
||||
}
|
||||
|
||||
// LINE enable logic is retired on A252 bench wiring.
|
||||
next.slic_line = -1;
|
||||
|
||||
if (!A252ConfigStore::validatePins(next, error)) {
|
||||
@@ -505,55 +512,10 @@ bool applyAudioPatch(JsonVariantConst patch, A252AudioConfig& target, String& er
|
||||
return true;
|
||||
}
|
||||
|
||||
bool applyMqttPatch(JsonVariantConst patch, MqttConfig& target, String& error) {
|
||||
MqttConfig next = target;
|
||||
|
||||
if (patch["enabled"].is<bool>()) {
|
||||
next.enabled = patch["enabled"].as<bool>();
|
||||
}
|
||||
if (patch["host"].is<const char*>()) {
|
||||
next.host = patch["host"].as<const char*>();
|
||||
}
|
||||
if (patch["port"].is<uint16_t>()) {
|
||||
next.port = patch["port"].as<uint16_t>();
|
||||
}
|
||||
if (patch["user"].is<const char*>()) {
|
||||
next.user = patch["user"].as<const char*>();
|
||||
}
|
||||
if (patch["pass"].is<const char*>()) {
|
||||
next.pass = patch["pass"].as<const char*>();
|
||||
}
|
||||
if (patch["base_topic"].is<const char*>()) {
|
||||
next.base_topic = patch["base_topic"].as<const char*>();
|
||||
}
|
||||
|
||||
if (!A252ConfigStore::validateMqtt(next, error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
target = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
DispatchResponse executeCommandLine(const String& line) {
|
||||
return g_dispatcher.dispatch(line);
|
||||
}
|
||||
|
||||
String responseToText(const DispatchResponse& res) {
|
||||
if (!res.raw.isEmpty()) {
|
||||
return res.raw;
|
||||
}
|
||||
if (!res.json.isEmpty()) {
|
||||
return res.json;
|
||||
}
|
||||
String out = res.ok ? "OK" : "ERR";
|
||||
if (!res.code.isEmpty()) {
|
||||
out += " ";
|
||||
out += res.code;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void registerCommands() {
|
||||
g_dispatcher.registerCommand("PING", [](const String&) {
|
||||
DispatchResponse res;
|
||||
@@ -580,6 +542,84 @@ void registerCommands() {
|
||||
return makeResponse(true, "CALL");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("RING", [](const String&) {
|
||||
g_telephony.triggerIncomingRing();
|
||||
return makeResponse(true, "RING");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("WIFI_STATUS", [](const String&) {
|
||||
JsonDocument doc;
|
||||
JsonObject root = doc.to<JsonObject>();
|
||||
const wl_status_t status = WiFi.status();
|
||||
const bool connected = status == WL_CONNECTED;
|
||||
|
||||
root["connected"] = connected;
|
||||
root["status"] = status;
|
||||
if (connected) {
|
||||
root["ip"] = WiFi.localIP().toString();
|
||||
root["rssi"] = WiFi.RSSI();
|
||||
root["channel"] = WiFi.channel();
|
||||
} else {
|
||||
root["ip"] = "";
|
||||
root["rssi"] = 0;
|
||||
root["channel"] = 0;
|
||||
}
|
||||
root["mode"] = WiFi.getMode() == WIFI_MODE_STA
|
||||
? "STA"
|
||||
: WiFi.getMode() == WIFI_MODE_AP
|
||||
? "AP"
|
||||
: WiFi.getMode() == WIFI_MODE_APSTA
|
||||
? "APSTA"
|
||||
: "NULL";
|
||||
return jsonResponse(doc);
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("WIFI_DISCONNECT", [](const String&) {
|
||||
g_wifi.disconnect(false);
|
||||
return makeResponse(true, "WIFI_DISCONNECT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("WIFI_RECONNECT", [](const String&) {
|
||||
const bool ok = g_wifi.reconnect();
|
||||
return makeResponse(ok, ok ? "WIFI_RECONNECT" : "WIFI_RECONNECT no_credentials");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("UNLOCK", [](const String&) {
|
||||
g_slic.setLineEnabled(true);
|
||||
return makeResponse(true, "UNLOCK");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("NEXT", [](const String&) {
|
||||
if (g_active_scene_id.isEmpty()) {
|
||||
return makeResponse(false, "scene_not_found");
|
||||
}
|
||||
g_active_scene_id = "";
|
||||
return makeResponse(true, "NEXT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("STORY_REFRESH_SD", [](const String&) {
|
||||
return makeResponse(g_audio.isSdReady(), "STORY_REFRESH_SD");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("SC_EVENT", [](const String&) {
|
||||
return makeResponse(true, "SC_EVENT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("SCENE", [](const String& args) {
|
||||
String scene_id;
|
||||
if (!parseSceneIdFromArgs(args, scene_id)) {
|
||||
return makeResponse(false, "missing_scene_id");
|
||||
}
|
||||
g_active_scene_id = scene_id;
|
||||
JsonDocument out;
|
||||
JsonObject root = out.to<JsonObject>();
|
||||
root["ok"] = true;
|
||||
root["code"] = "SCENE";
|
||||
root["scene"] = scene_id;
|
||||
root["active"] = true;
|
||||
return jsonResponse(out);
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("CAPTURE_START", [](const String&) {
|
||||
return makeResponse(g_audio.startCapture(), "CAPTURE_START");
|
||||
});
|
||||
@@ -609,7 +649,7 @@ void registerCommands() {
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("AMP_ON", [](const String&) {
|
||||
// Locked polarity: AMP_EN active LOW on GPIO21.
|
||||
// Locked polarity for A252 bench: AMP_EN active LOW on GPIO21.
|
||||
digitalWrite(kAudioAmpEnablePin, LOW);
|
||||
return makeResponse(true, "AMP_ON");
|
||||
});
|
||||
@@ -619,108 +659,6 @@ void registerCommands() {
|
||||
return makeResponse(true, "AMP_OFF");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("WIFI_CONNECT", [](const String& args) {
|
||||
String ssid;
|
||||
String password_raw;
|
||||
if (!splitFirstToken(args, ssid, password_raw) || ssid.isEmpty()) {
|
||||
return makeResponse(false, "WIFI_CONNECT invalid_args");
|
||||
}
|
||||
|
||||
String password = password_raw;
|
||||
if (!password_raw.isEmpty()) {
|
||||
String parsed;
|
||||
String remaining;
|
||||
if (splitFirstToken(password_raw, parsed, remaining) && remaining.isEmpty()) {
|
||||
password = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const bool ok = g_wifi.connect(ssid, password, 10000, true);
|
||||
return makeResponse(ok, "WIFI_CONNECT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("WIFI_DISCONNECT", [](const String&) {
|
||||
g_wifi.disconnect(false);
|
||||
return makeResponse(true, "WIFI_DISCONNECT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("WIFI_STATUS", [](const String&) {
|
||||
JsonDocument doc;
|
||||
g_wifi.statusToJson(doc.to<JsonObject>());
|
||||
return jsonResponse(doc);
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("WIFI_SCAN", [](const String&) {
|
||||
// Guard BT/HFP reconnect while WiFi allocates scan resources.
|
||||
g_bt.suspendReconnect(8000U);
|
||||
JsonDocument doc;
|
||||
JsonObject root = doc.to<JsonObject>();
|
||||
root["ok"] = true;
|
||||
JsonArray nets = root["networks"].to<JsonArray>();
|
||||
g_wifi.scanToJson(nets);
|
||||
return jsonResponse(doc);
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("WIFI_RECONNECT", [](const String&) {
|
||||
return makeResponse(g_wifi.reconnect(10000), "WIFI_RECONNECT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("MQTT_CONFIG_SET", [](const String& args) {
|
||||
if (args.isEmpty()) {
|
||||
return makeResponse(false, "MQTT_CONFIG_SET invalid_json");
|
||||
}
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, args) != DeserializationError::Ok) {
|
||||
return makeResponse(false, "MQTT_CONFIG_SET invalid_json");
|
||||
}
|
||||
|
||||
MqttConfig next = g_mqtt_cfg;
|
||||
String error;
|
||||
if (!applyMqttPatch(doc.as<JsonVariantConst>(), next, error)) {
|
||||
return makeResponse(false, "MQTT_CONFIG_SET " + error);
|
||||
}
|
||||
if (!A252ConfigStore::saveMqtt(next, &error)) {
|
||||
return makeResponse(false, "MQTT_CONFIG_SET " + error);
|
||||
}
|
||||
|
||||
g_mqtt_cfg = next;
|
||||
g_mqtt.setConfig(g_mqtt_cfg);
|
||||
|
||||
JsonDocument out;
|
||||
A252ConfigStore::mqttToJson(g_mqtt_cfg, out.to<JsonObject>(), false);
|
||||
return jsonResponse(out);
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("MQTT_CONNECT", [](const String&) {
|
||||
if (!g_mqtt_cfg.enabled) {
|
||||
return makeResponse(false, "MQTT_CONNECT disabled");
|
||||
}
|
||||
return makeResponse(g_mqtt.connectNow(), "MQTT_CONNECT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("MQTT_DISCONNECT", [](const String&) {
|
||||
g_mqtt.disconnect();
|
||||
return makeResponse(true, "MQTT_DISCONNECT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("MQTT_STATUS", [](const String&) {
|
||||
JsonDocument doc;
|
||||
JsonObject root = doc.to<JsonObject>();
|
||||
g_mqtt.statusToJson(root);
|
||||
A252ConfigStore::mqttToJson(g_mqtt_cfg, root["config"].to<JsonObject>(), false);
|
||||
return jsonResponse(doc);
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("MQTT_PUB", [](const String& args) {
|
||||
String topic;
|
||||
String payload;
|
||||
if (!splitFirstToken(args, topic, payload) || topic.isEmpty()) {
|
||||
return makeResponse(false, "MQTT_PUB invalid_args");
|
||||
}
|
||||
const bool ok = g_mqtt.publishRaw(topic, payload, false);
|
||||
return makeResponse(ok, "MQTT_PUB");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("ESPNOW_ON", [](const String&) {
|
||||
return makeResponse(g_espnow.begin(g_peer_store), "ESPNOW_ON");
|
||||
});
|
||||
@@ -777,88 +715,6 @@ void registerCommands() {
|
||||
return makeResponse(g_espnow.sendJson(target, payload), "ESPNOW_SEND");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_HFP_CONNECT", [](const String& args) {
|
||||
if (args.isEmpty()) {
|
||||
return makeResponse(false, "BT_HFP_CONNECT invalid_addr");
|
||||
}
|
||||
const bool ok = g_bt.connect(args.c_str()) && g_bt.startHFP();
|
||||
return makeResponse(ok, "BT_HFP_CONNECT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_HFP_DISCONNECT", [](const String&) {
|
||||
g_bt.stopHFP();
|
||||
const bool ok = g_bt.disconnect();
|
||||
return makeResponse(ok, "BT_HFP_DISCONNECT");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_DIAL", [](const String& args) {
|
||||
String number = args;
|
||||
number.trim();
|
||||
if (number.isEmpty()) {
|
||||
return makeResponse(false, "BT_DIAL invalid_number");
|
||||
}
|
||||
return makeResponse(g_bt.dial(number), "BT_DIAL");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("DIAL", [](const String& args) {
|
||||
String number = args;
|
||||
number.trim();
|
||||
if (number.isEmpty()) {
|
||||
return makeResponse(false, "DIAL invalid_number");
|
||||
}
|
||||
return makeResponse(g_bt.dial(number), "DIAL");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_REDIAL", [](const String&) {
|
||||
return makeResponse(g_bt.redial(), "BT_REDIAL");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_ANSWER", [](const String&) {
|
||||
return makeResponse(g_bt.answerCall(), "BT_ANSWER");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_HANGUP", [](const String&) {
|
||||
return makeResponse(g_bt.hangupCall(), "BT_HANGUP");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_CALLS", [](const String&) {
|
||||
return makeResponse(g_bt.queryCurrentCalls(), "BT_CALLS");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_PBAP_SYNC", [](const String&) {
|
||||
return makeResponse(g_bt.syncPbapContacts(), "BT_PBAP_SYNC unsupported");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_BLE_START", [](const String&) {
|
||||
return makeResponse(g_bt.startBLE(), "BT_BLE_START");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_BLE_STOP", [](const String&) {
|
||||
return makeResponse(g_bt.stopBLE(), "BT_BLE_STOP");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_DISCOVERABLE_ON", [](const String&) {
|
||||
return makeResponse(g_bt.setDiscoverable(true), "BT_DISCOVERABLE_ON");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_DISCOVERABLE_OFF", [](const String&) {
|
||||
return makeResponse(g_bt.setDiscoverable(false), "BT_DISCOVERABLE_OFF");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_AUTO_RECONNECT_ON", [](const String&) {
|
||||
return makeResponse(g_bt.setAutoReconnectEnabled(true), "BT_AUTO_RECONNECT_ON");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_AUTO_RECONNECT_OFF", [](const String&) {
|
||||
return makeResponse(g_bt.setAutoReconnectEnabled(false), "BT_AUTO_RECONNECT_OFF");
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("BT_STATUS", [](const String&) {
|
||||
JsonDocument doc;
|
||||
g_bt.statusToJson(doc.to<JsonObject>());
|
||||
return jsonResponse(doc);
|
||||
});
|
||||
|
||||
g_dispatcher.registerCommand("SLIC_CONFIG_GET", [](const String&) {
|
||||
JsonDocument doc;
|
||||
A252ConfigStore::pinsToJson(g_pins_cfg, doc.to<JsonObject>());
|
||||
@@ -948,15 +804,6 @@ void processInboundBridgeCommand(const String& source, const JsonVariantConst& p
|
||||
|
||||
const DispatchResponse result = executeCommandLine(cmd);
|
||||
|
||||
JsonDocument event;
|
||||
event["source"] = source;
|
||||
event["cmd"] = cmd;
|
||||
event["ok"] = result.ok;
|
||||
event["code"] = result.code;
|
||||
String payload_json;
|
||||
serializeJson(event, payload_json);
|
||||
g_mqtt.publish("event", payload_json, false);
|
||||
|
||||
if (is_envelope_v2 && request_ack && isMacAddressString(source)) {
|
||||
JsonDocument response;
|
||||
response["msg_id"] = request_id.isEmpty() ? String(millis()) : request_id;
|
||||
@@ -1067,76 +914,32 @@ void setup() {
|
||||
g_features = getFeatureMatrix(g_profile);
|
||||
|
||||
A252ConfigStore::loadPins(g_pins_cfg);
|
||||
// Hard-wired SLIC mapping for live bench tests.
|
||||
g_pins_cfg.slic_rm = 18;
|
||||
g_pins_cfg.slic_fr = 5;
|
||||
g_pins_cfg.slic_shk = 23;
|
||||
// LINE enable is not used on this wiring.
|
||||
g_pins_cfg.slic_line = -1;
|
||||
g_pins_cfg.slic_pd = 19;
|
||||
g_pins_cfg.hook_active_high = true;
|
||||
A252ConfigStore::loadAudio(g_audio_cfg);
|
||||
A252ConfigStore::loadEspNowPeers(g_peer_store);
|
||||
|
||||
pinMode(kAudioAmpEnablePin, OUTPUT);
|
||||
digitalWrite(kAudioAmpEnablePin, LOW);
|
||||
A252ConfigStore::loadAudio(g_audio_cfg);
|
||||
A252ConfigStore::loadMqtt(g_mqtt_cfg);
|
||||
A252ConfigStore::loadEspNowPeers(g_peer_store);
|
||||
|
||||
applyHardwareConfig();
|
||||
registerCommands();
|
||||
|
||||
g_bt.setBleCommandHandler([](const String& cmd) {
|
||||
return responseToText(executeCommandLine(cmd));
|
||||
});
|
||||
g_bt.setAudioBridge(&g_audio);
|
||||
|
||||
g_mqtt.begin(g_mqtt_cfg);
|
||||
g_mqtt.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
|
||||
processInboundBridgeCommand(source, payload);
|
||||
});
|
||||
|
||||
String ssid;
|
||||
String password;
|
||||
if (WifiCredentialsStorage::load(ssid, password)) {
|
||||
g_wifi.connect(ssid, password, kBootWifiConnectTimeoutMs, false);
|
||||
if (g_wifi.isConnected() && g_mqtt_cfg.enabled) {
|
||||
g_mqtt.connectNow();
|
||||
}
|
||||
} else {
|
||||
g_wifi.ensureFallbackAp();
|
||||
}
|
||||
|
||||
g_espnow.begin(g_peer_store);
|
||||
g_espnow.setCommandCallback([](const String& source, const JsonVariantConst& payload) {
|
||||
processInboundBridgeCommand(source, payload);
|
||||
});
|
||||
|
||||
g_bt.begin(g_profile);
|
||||
|
||||
g_web.setRateLimitMs(250);
|
||||
g_web.setAuthEnabled(false);
|
||||
g_web.setStatusCallback(fillStatusSnapshot);
|
||||
g_web.setCommandExecutor(executeCommandLine);
|
||||
g_web.begin();
|
||||
|
||||
Serial.printf("[RTC_BL_PHONE] Boot: profile=%s bt_classic=%s full_duplex=%s\n",
|
||||
Serial.printf("[RTC_BL_PHONE] Boot: profile=%s full_duplex=%s\n",
|
||||
boardProfileToString(g_profile),
|
||||
g_features.has_bt_classic ? "true" : "false",
|
||||
g_features.has_full_duplex_i2s ? "true" : "false");
|
||||
if (kPrintHelpOnBoot) {
|
||||
printHelp();
|
||||
}
|
||||
publishStatusIfConnected();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
g_telephony.setIncomingRing(g_bt.callState() == "ringing");
|
||||
g_telephony.tick();
|
||||
g_wifi.loop();
|
||||
g_bt.tick();
|
||||
g_mqtt.tick();
|
||||
g_espnow.tick();
|
||||
g_web.handle();
|
||||
pollSerial();
|
||||
delay(1);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
EspNowBridge* EspNowBridge::instance_ = nullptr;
|
||||
|
||||
namespace {
|
||||
constexpr size_t kEspNowMaxPayloadBytes = 240;
|
||||
constexpr size_t kEspNowMaxPeers = 16;
|
||||
|
||||
void enforceEspNowCoexPolicy() {
|
||||
WiFi.setSleep(true);
|
||||
const esp_err_t err = esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
|
||||
@@ -89,6 +92,12 @@ bool EspNowBridge::sendJson(const String& target, const String& json_payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (json_payload.length() > kEspNowMaxPayloadBytes) {
|
||||
Serial.printf("[EspNowBridge] send rejected: payload too large=%u bytes\n",
|
||||
static_cast<unsigned>(json_payload.length()));
|
||||
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);
|
||||
@@ -138,6 +147,11 @@ bool EspNowBridge::addPeerInternal(const String& mac, bool persist) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (store_.peers.size() >= kEspNowMaxPeers) {
|
||||
Serial.println("[EspNowBridge] peer rejected: max peers reached");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t peer_mac[6] = {0};
|
||||
if (!A252ConfigStore::parseMac(normalized, peer_mac)) {
|
||||
return false;
|
||||
@@ -190,6 +204,11 @@ bool EspNowBridge::sendToMac(const uint8_t mac[6], const String& payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.length() > kEspNowMaxPayloadBytes) {
|
||||
tx_fail_++;
|
||||
return false;
|
||||
}
|
||||
|
||||
const esp_err_t err = esp_now_send(mac, reinterpret_cast<const uint8_t*>(payload.c_str()), payload.length());
|
||||
if (err != ESP_OK) {
|
||||
tx_fail_++;
|
||||
@@ -214,6 +233,13 @@ void EspNowBridge::onDataRecv(const uint8_t* mac_addr, const uint8_t* data, int
|
||||
mac_addr[4],
|
||||
mac_addr[5]);
|
||||
|
||||
if (len <= 0 || len > static_cast<int>(kEspNowMaxPayloadBytes)) {
|
||||
Serial.printf("[EspNowBridge] rx dropped: invalid len=%d (max=%u)\n",
|
||||
len,
|
||||
static_cast<unsigned>(kEspNowMaxPayloadBytes));
|
||||
return;
|
||||
}
|
||||
|
||||
String payload;
|
||||
payload.reserve(len + 1);
|
||||
for (int i = 0; i < len; ++i) {
|
||||
|
||||
Reference in New Issue
Block a user
ensureSdMounted()latches the first attempt result and never retries oncesd_mount_attempted_is true. If the first mount occurs while no card is inserted (or mount transiently fails), laterPLAYattempts continue to fail permanently even after inserting a card, because the code always returns the cached false value until reboot.Useful? React with 👍 / 👎.