Esp32_RTC_ZACUS #27

Closed
electron-rare wants to merge 2 commits from esp32_RTC_ZACUS into main
9 changed files with 519 additions and 662 deletions
+56 -57
View File
@@ -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 nest 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
+77
View File
@@ -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
View File
@@ -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
View File
@@ -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();
chatgpt-codex-connector[bot] commented 2026-02-23 20:39:25 +00:00 (Migrated from github.com)
Review

P1 Badge Allow SD mount retry after initial failure

ensureSdMounted() latches the first attempt result and never retries once sd_mount_attempted_ is true. If the first mount occurs while no card is inserted (or mount transiently fails), later PLAY attempts continue to fail permanently even after inserting a card, because the code always returns the cached false value until reboot.

Useful? React with 👍 / 👎.

**<sub><sub>![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat)</sub></sub> Allow SD mount retry after initial failure** `ensureSdMounted()` latches the first attempt result and never retries once `sd_mount_attempted_` is true. If the first mount occurs while no card is inserted (or mount transiently fails), later `PLAY` attempts continue to fail permanently even after inserting a card, because the code always returns the cached false value until reboot. Useful? React with 👍 / 👎.
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();
}
}
1
+17 -14
View File
@@ -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;
};
+6 -5
View File
@@ -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 {
+5 -1
View File
@@ -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
View File
@@ -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;
1
@@ -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);
}
+26
View File
@@ -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) {