mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-10-23 14:22:43 +00:00
LXST voice call stability: fix hangup crash, signal queue, TX pump, mic tuning
- Fix use-after-free crash on hangup: set _call_state=IDLE before deleting _lxst_audio, preventing pump_call_tx() (runs without LVGL lock) from accessing freed memory - Replace single-slot _call_signal_pending with 8-element ring buffer queue to prevent signal loss when CONNECTING+ESTABLISHED arrive in rapid succession - Extract TX pump into pump_call_tx() called right after reticulum->loop() for low-latency audio TX without LVGL lock dependency (was buried at step 10) - Tune ES7210 mic gain to 21dB (was 15dB) to improve Codec2 input level without ADC clipping that occurred at 24dB - I2S capture: use APLL for accurate 8kHz clock, direct 8kHz sampling (no more 16→8kHz decimation), DMA 16x64 for encode burst headroom - Reduce Reticulum log verbosity to LOG_INFO (was LOG_TRACE) - BLE: add ble_hs_sched_reset() tiered recovery before reboot on desync, widen supervision timeout to 4.0s for WiFi coexistence - Add UDP multicast log broadcasting and OTA flash support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ddd19a04db
commit
6744eb136d
12 files changed
+343
-264
No files matched your search
@@ -30,6 +30,10 @@ extern "C" {
|
||||
int ble_gap_adv_active(void);
|
||||
int ble_gap_disc_active(void);
|
||||
int ble_gap_conn_active(void);
|
||||
|
||||
// Host reset — enqueues a reset event that clears GAP state and resyncs
|
||||
// with the BLE controller without touching heap-corrupting deinit paths.
|
||||
void ble_hs_sched_reset(int reason);
|
||||
}
|
||||
|
||||
namespace RNS { namespace BLE {
|
||||
@@ -373,6 +377,30 @@ bool NimBLEPlatform::recoverBLEStack() {
|
||||
return false; // Won't reach here
|
||||
}
|
||||
|
||||
bool NimBLEPlatform::attemptHostReset() {
|
||||
INFO("NimBLEPlatform: Attempting ble_hs_sched_reset (attempt " +
|
||||
std::to_string(_host_reset_attempts + 1) + ")");
|
||||
|
||||
ble_hs_sched_reset(BLE_HS_ETIMEOUT);
|
||||
|
||||
// Poll for resync up to 3s
|
||||
uint32_t start = millis();
|
||||
while (!ble_hs_synced() && (millis() - start) < 3000) {
|
||||
delay(50);
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
|
||||
if (ble_hs_synced()) {
|
||||
unsigned long elapsed = millis() - start;
|
||||
INFO("NimBLEPlatform: Host resync successful after " +
|
||||
std::to_string(elapsed) + "ms");
|
||||
return true;
|
||||
}
|
||||
|
||||
WARNING("NimBLEPlatform: Host resync failed after 3s");
|
||||
return false;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// State Machine Implementation
|
||||
//=============================================================================
|
||||
@@ -682,22 +710,39 @@ bool NimBLEPlatform::startScan(uint16_t duration_ms) {
|
||||
WARNING("NimBLEPlatform: Host not synced, desync " +
|
||||
std::to_string(desync_duration / 1000) + "s (fail " +
|
||||
std::to_string(_scan_fail_count) + "/" +
|
||||
std::to_string(SCAN_FAIL_RECOVERY_THRESHOLD) + ")");
|
||||
std::to_string(SCAN_FAIL_RECOVERY_THRESHOLD) +
|
||||
", resets=" + std::to_string(_host_reset_attempts) + ")");
|
||||
|
||||
// Only reboot after prolonged desync — brief desyncs self-recover.
|
||||
// With active connections, give a bit more time (90s vs 60s) but
|
||||
// don't wait too long — a desynced host can't actually communicate
|
||||
// over those connections, so they're effectively zombie connections.
|
||||
unsigned long reboot_threshold = HOST_DESYNC_REBOOT_MS; // 60s base
|
||||
if (getConnectionCount() > 0) {
|
||||
reboot_threshold = 90000; // 90s with connections (they're likely dead anyway)
|
||||
}
|
||||
if (desync_duration >= reboot_threshold) {
|
||||
// Tiered recovery:
|
||||
// 0-10s: Wait for natural self-recovery
|
||||
// 10s: Try ble_hs_sched_reset() (first attempt)
|
||||
// 30s: Try ble_hs_sched_reset() (second attempt)
|
||||
// 60s+: Reboot (last resort)
|
||||
if (desync_duration >= 10000 && _host_reset_attempts == 0) {
|
||||
_host_reset_attempts++;
|
||||
if (attemptHostReset()) {
|
||||
_host_desync_since = 0;
|
||||
_host_reset_attempts = 0;
|
||||
_scan_fail_count = 0;
|
||||
return false; // Synced — will succeed on next scan cycle
|
||||
}
|
||||
} else if (desync_duration >= 30000 && _host_reset_attempts == 1) {
|
||||
_host_reset_attempts++;
|
||||
if (attemptHostReset()) {
|
||||
_host_desync_since = 0;
|
||||
_host_reset_attempts = 0;
|
||||
_scan_fail_count = 0;
|
||||
return false;
|
||||
}
|
||||
} else if (desync_duration >= HOST_DESYNC_REBOOT_MS) {
|
||||
ERROR("NimBLEPlatform: Host desynced for " +
|
||||
std::to_string(desync_duration / 1000) + "s (conns=" +
|
||||
std::to_string(getConnectionCount()) + "), rebooting");
|
||||
std::to_string(getConnectionCount()) +
|
||||
", resets=" + std::to_string(_host_reset_attempts) +
|
||||
"), rebooting");
|
||||
_scan_fail_count = 0;
|
||||
_host_desync_since = 0;
|
||||
_host_reset_attempts = 0;
|
||||
recoverBLEStack();
|
||||
}
|
||||
return false;
|
||||
@@ -709,6 +754,7 @@ bool NimBLEPlatform::startScan(uint16_t duration_ms) {
|
||||
unsigned long recovery_time = millis() - _host_desync_since;
|
||||
INFO("NimBLEPlatform: Host re-synced after " + std::to_string(recovery_time) + "ms");
|
||||
_host_desync_since = 0;
|
||||
_host_reset_attempts = 0;
|
||||
}
|
||||
|
||||
// Log GAP hardware state before checking
|
||||
@@ -1144,7 +1190,7 @@ bool NimBLEPlatform::connectNative(const BLEAddress& address, uint16_t timeout_m
|
||||
}
|
||||
|
||||
client->setClientCallbacks(this, false);
|
||||
client->setConnectionParams(24, 40, 0, 256); // 30-50ms interval, 2.56s timeout
|
||||
client->setConnectionParams(24, 48, 0, 400); // 30-60ms interval, 4.0s supervision timeout
|
||||
client->setConnectTimeout(timeout_ms); // milliseconds
|
||||
|
||||
// Suppress _on_connected in onConnect callback — we'll fire it from here
|
||||
|
||||
@@ -260,12 +260,14 @@ private:
|
||||
uint8_t _conn_establish_fail_count = 0; // rc=574 connection establishment failures
|
||||
unsigned long _last_full_recovery_time = 0;
|
||||
unsigned long _host_desync_since = 0; // millis() when host first lost sync (0 = synced)
|
||||
uint8_t _host_reset_attempts = 0; // ble_hs_sched_reset attempts since last sync
|
||||
static constexpr uint8_t SCAN_FAIL_RECOVERY_THRESHOLD = 10;
|
||||
static constexpr uint8_t LIGHTWEIGHT_RESET_MAX_FAILS = 3;
|
||||
static constexpr uint8_t CONN_ESTABLISH_FAIL_THRESHOLD = 5;
|
||||
static constexpr unsigned long FULL_RECOVERY_COOLDOWN_MS = 60000; // 60 seconds
|
||||
static constexpr unsigned long HOST_DESYNC_REBOOT_MS = 60000; // Reboot after 60s desync (no connections)
|
||||
bool recoverBLEStack();
|
||||
bool attemptHostReset();
|
||||
|
||||
// NimBLE objects
|
||||
NimBLEServer* _server = nullptr;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#include "es7210.h"
|
||||
|
||||
#define I2S_DSP_MODE_A 0
|
||||
#define MCLK_DIV_FRE 256
|
||||
#define MCLK_DIV_FRE 512 // MCLK = sample_rate * 512 (4.096MHz at 8kHz)
|
||||
|
||||
#define ES7210_MCLK_SOURCE FROM_CLOCK_DOUBLE_PIN
|
||||
#define FROM_PAD_PIN 0
|
||||
|
||||
+44
-101
@@ -44,17 +44,19 @@ bool I2SCapture::init() {
|
||||
// Settings match official LilyGO T-Deck Plus Microphone example
|
||||
i2s_config_t i2s_config = {};
|
||||
i2s_config.mode = static_cast<i2s_mode_t>(I2S_MODE_MASTER | I2S_MODE_RX);
|
||||
i2s_config.sample_rate = I2S_SAMPLE_RATE; // 16kHz — downsample to 8kHz for Codec2
|
||||
i2s_config.sample_rate = I2S_SAMPLE_RATE; // 8kHz — matches Codec2 directly
|
||||
i2s_config.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT;
|
||||
i2s_config.channel_format = I2S_CHANNEL_FMT_ALL_LEFT;
|
||||
i2s_config.communication_format = I2S_COMM_FORMAT_STAND_I2S;
|
||||
i2s_config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1;
|
||||
i2s_config.dma_buf_count = 8;
|
||||
// At 8kHz × 2 TDM channels = 16ksps. Filter+encode burst for 1600 samples
|
||||
// takes ~20ms; 16 × 64 = 1024 samples = 64ms headroom prevents DMA overflow.
|
||||
i2s_config.dma_buf_count = 16;
|
||||
i2s_config.dma_buf_len = 64;
|
||||
i2s_config.use_apll = false;
|
||||
i2s_config.use_apll = true; // APLL gives accurate audio clocks (vs main PLL integer dividers)
|
||||
i2s_config.tx_desc_auto_clear = true;
|
||||
i2s_config.fixed_mclk = 0;
|
||||
i2s_config.mclk_multiple = I2S_MCLK_MULTIPLE_256; // MCLK = 16kHz * 256 = 4.096MHz
|
||||
i2s_config.fixed_mclk = 4096000; // Force 4.096MHz MCLK (matches ES7210 coeff table for 8kHz)
|
||||
i2s_config.mclk_multiple = I2S_MCLK_MULTIPLE_256; // Ignored when fixed_mclk is set
|
||||
i2s_config.bits_per_chan = I2S_BITS_PER_CHAN_16BIT;
|
||||
// TDM channel mask — required for ES7210 on T-Deck Plus
|
||||
i2s_config.chan_mask = static_cast<i2s_channel_t>(I2S_TDM_ACTIVE_CH0 | I2S_TDM_ACTIVE_CH1);
|
||||
@@ -83,7 +85,7 @@ bool I2SCapture::init() {
|
||||
|
||||
i2sInitialized_ = true;
|
||||
|
||||
ESP_LOGI(TAG, "I2S capture initialized: %dHz 16-bit mono, MCLK=4.096MHz", I2S_SAMPLE_RATE);
|
||||
ESP_LOGI(TAG, "I2S capture initialized: %dHz 16-bit TDM, MCLK=4.096MHz", I2S_SAMPLE_RATE);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -96,7 +98,11 @@ bool I2SCapture::configureEncoder(Codec2Wrapper* codec, bool enableFilters) {
|
||||
}
|
||||
codec_ = codec;
|
||||
|
||||
frameSamples_ = codec_->samplesPerFrame();
|
||||
// Accumulate FRAMES_PER_BATCH codec frames before filter+encode.
|
||||
// Columba uses 200ms (1600 samples for Codec2 3200) so the AGC operates
|
||||
// on meaningful block sizes. With only 160 samples (20ms) the AGC blocks
|
||||
// are 16 samples and gain-pump, producing buzzy audio.
|
||||
frameSamples_ = codec_->samplesPerFrame() * FRAMES_PER_BATCH;
|
||||
filtersEnabled_ = enableFilters;
|
||||
|
||||
// Allocate ring buffer in PSRAM
|
||||
@@ -111,13 +117,16 @@ bool I2SCapture::configureEncoder(Codec2Wrapper* codec, bool enableFilters) {
|
||||
silenceBuf_ = static_cast<int16_t*>(
|
||||
heap_caps_calloc(frameSamples_, sizeof(int16_t), MALLOC_CAP_SPIRAM));
|
||||
|
||||
// Filter chain: 1 channel (mono), voice band 300-3400Hz, AGC -12dB target, 12dB max
|
||||
// Filter chain: 1 channel (mono), voice band 300-3400Hz, AGC -12dB target, 12dB max gain
|
||||
// PGA gain is 21dB; loud speech peaks around -6dBFS, quiet around -20dBFS.
|
||||
// AGC boosts quiet sections; 12dB max prevents noise pumping during silence.
|
||||
if (enableFilters) {
|
||||
filterChain_ = new VoiceFilterChain(1, 300.0f, 3400.0f, -12.0f, 12.0f);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Encoder configured: Codec2 mode %d, %d samples/frame, %d bytes/frame, filters=%d",
|
||||
codec_->libraryMode(), frameSamples_, codec_->bytesPerFrame(), enableFilters);
|
||||
ESP_LOGI(TAG, "Encoder configured: Codec2 mode %d, %d samples/batch (%d x %d), %d bytes/frame, filters=%d",
|
||||
codec_->libraryMode(), frameSamples_, FRAMES_PER_BATCH,
|
||||
codec_->samplesPerFrame(), codec_->bytesPerFrame(), enableFilters);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -182,62 +191,35 @@ void I2SCapture::captureTask(void* param) {
|
||||
}
|
||||
|
||||
void I2SCapture::captureLoop() {
|
||||
// I2S read buffer: read in chunks (TDM interleaved, 2 channels at 16kHz each)
|
||||
static constexpr int READ_SAMPLES = 256; // Larger buffer → more 8kHz samples per read
|
||||
// I2S read buffer: TDM interleaved, 2 channels at 8kHz
|
||||
static constexpr int READ_SAMPLES = 256;
|
||||
int16_t readBuf[READ_SAMPLES];
|
||||
// CH0 at 16kHz after TDM deinterleave (÷2)
|
||||
// CH0 mono after TDM deinterleave (÷2)
|
||||
int16_t ch0Buf[READ_SAMPLES / 2];
|
||||
// After FIR decimation 16kHz→8kHz (÷2)
|
||||
int16_t dsBuf[READ_SAMPLES / 4];
|
||||
size_t bytesRead = 0;
|
||||
|
||||
// 15-tap half-band FIR filter for anti-aliased decimation by 2.
|
||||
// Designed for Fs=16kHz, cutoff=4kHz (Nyquist of 8kHz output).
|
||||
// Kaiser window beta=6, ~60dB stopband attenuation.
|
||||
// Half-band property: every other coefficient is zero, so only 5 unique
|
||||
// multiply-accumulate ops per output sample (symmetric coefficients).
|
||||
//
|
||||
// Float coefficients (symmetric, sum=1.0):
|
||||
// h[0,14] = -0.000676 h[2,12] = +0.012712 h[4,10] = -0.062710
|
||||
// h[6,8] = +0.300794 h[7] = +0.499759 (center)
|
||||
// h[1,3,5,9,11,13] = 0 (half-band zeros)
|
||||
//
|
||||
// Q15 fixed-point (scaled by 32768):
|
||||
static constexpr int FIR_TAPS = 15;
|
||||
static const int16_t FIR_Q15[FIR_TAPS] = {
|
||||
-22, 0, 417, 0, -2055, 0, 9856, 16376, 9856, 0, -2055, 0, 417, 0, -22
|
||||
};
|
||||
static constexpr int FIR_SHIFT = 15; // Q15 format: divide accumulator by 32768
|
||||
|
||||
// FIR delay line (persists across I2S reads)
|
||||
int16_t firDelay[FIR_TAPS] = {0};
|
||||
|
||||
static constexpr int16_t LIMITER_THRESHOLD = 16000;
|
||||
|
||||
{
|
||||
char logbuf[96];
|
||||
snprintf(logbuf, sizeof(logbuf), "[CAP] Capture task on core %d, I2S=%dHz, codec=%dHz, FIR=%d-tap, stack=%d",
|
||||
xPortGetCoreID(), I2S_SAMPLE_RATE, CODEC_SAMPLE_RATE, FIR_TAPS, CAPTURE_TASK_STACK);
|
||||
snprintf(logbuf, sizeof(logbuf), "[CAP] Capture task on core %d, I2S=%dHz, codec=%dHz, stack=%d",
|
||||
xPortGetCoreID(), I2S_SAMPLE_RATE, CODEC_SAMPLE_RATE, CAPTURE_TASK_STACK);
|
||||
pyxis_log(logbuf);
|
||||
}
|
||||
uint32_t framesEncoded = 0;
|
||||
uint32_t totalDsSamples = 0; // Total mono samples after decimation
|
||||
uint32_t totalSamples = 0; // Total mono samples after deinterleave
|
||||
uint32_t rateCheckMs = millis(); // For sample rate measurement
|
||||
int16_t runningPeakDs = 0; // Peak of decimated samples per interval
|
||||
int16_t runningPeakRaw = 0; // Peak of raw I2S samples per interval
|
||||
int16_t runningPeak = 0; // Peak of mono samples per interval
|
||||
uint32_t ringDrops = 0; // Ring buffer overflow counter
|
||||
|
||||
while (capturing_.load(std::memory_order_relaxed)) {
|
||||
// Read samples from I2S DMA (at 16kHz)
|
||||
// Read samples from I2S DMA (at 8kHz, TDM 2-ch)
|
||||
esp_err_t err = i2s_read(I2S_NUM_1, readBuf, sizeof(readBuf), &bytesRead,
|
||||
pdMS_TO_TICKS(100));
|
||||
if (err != ESP_OK || bytesRead == 0) continue;
|
||||
|
||||
int samplesRead = bytesRead / sizeof(int16_t);
|
||||
|
||||
// Dump first raw I2S samples on each capture start to see TDM channel layout
|
||||
// Resets per capture start (not per boot) since framesEncoded resets to 0
|
||||
if (framesEncoded == 0 && samplesRead >= 16 && totalDsSamples == 0) {
|
||||
// Dump first raw I2S samples on each capture start
|
||||
if (framesEncoded == 0 && samplesRead >= 16 && totalSamples == 0) {
|
||||
char rawdump[192];
|
||||
int pos = snprintf(rawdump, sizeof(rawdump),
|
||||
"[CAP] Raw I2S (%d read, %zu bytes): ", samplesRead, bytesRead);
|
||||
@@ -246,80 +228,41 @@ void I2SCapture::captureLoop() {
|
||||
pyxis_log(rawdump);
|
||||
}
|
||||
|
||||
// Track raw I2S peak (all channels)
|
||||
for (int i = 0; i < samplesRead; i++) {
|
||||
int16_t v = readBuf[i] < 0 ? -readBuf[i] : readBuf[i];
|
||||
if (v > runningPeakRaw) runningPeakRaw = v;
|
||||
}
|
||||
|
||||
// Step 1: TDM deinterleave — extract CH0 at 16kHz.
|
||||
// readBuf is [CH0,CH1,CH0,CH1,...], so CH0 samples at even indices.
|
||||
// TDM deinterleave — extract CH0 (mic) at 8kHz.
|
||||
// readBuf is [CH0,CH1,CH0,CH1,...], CH0 at even indices.
|
||||
int ch0Count = samplesRead / 2;
|
||||
for (int i = 0; i < ch0Count; i++) {
|
||||
ch0Buf[i] = readBuf[i * 2];
|
||||
int16_t v = ch0Buf[i] < 0 ? -ch0Buf[i] : ch0Buf[i];
|
||||
if (v > runningPeak) runningPeak = v;
|
||||
}
|
||||
|
||||
// Step 2: 15-tap half-band FIR decimation by 2 (16kHz → 8kHz).
|
||||
// For each output sample, push 2 input samples through the FIR delay
|
||||
// line and compute the convolution sum. Half-band zeros mean only
|
||||
// taps 0,2,4,6,7,8,10,12,14 are non-zero — exploited below.
|
||||
int dsCount = ch0Count / 2;
|
||||
for (int i = 0; i < dsCount; i++) {
|
||||
// Push 2 input samples into the delay line (decimation by 2)
|
||||
for (int k = 0; k < 2; k++) {
|
||||
// Shift delay line right by 1
|
||||
for (int d = FIR_TAPS - 1; d > 0; d--)
|
||||
firDelay[d] = firDelay[d - 1];
|
||||
firDelay[0] = ch0Buf[i * 2 + k];
|
||||
}
|
||||
|
||||
// Compute FIR output — exploit symmetry and half-band zeros.
|
||||
// Non-zero taps: 0,2,4,6,7,8,10,12,14
|
||||
// Symmetric pairs: (0,14), (2,12), (4,10), (6,8), center=7
|
||||
int32_t acc = 0;
|
||||
acc += (int32_t)FIR_Q15[0] * ((int32_t)firDelay[0] + firDelay[14]);
|
||||
acc += (int32_t)FIR_Q15[2] * ((int32_t)firDelay[2] + firDelay[12]);
|
||||
acc += (int32_t)FIR_Q15[4] * ((int32_t)firDelay[4] + firDelay[10]);
|
||||
acc += (int32_t)FIR_Q15[6] * ((int32_t)firDelay[6] + firDelay[8]);
|
||||
acc += (int32_t)FIR_Q15[7] * (int32_t)firDelay[7]; // center tap
|
||||
|
||||
int16_t s = (int16_t)(acc >> FIR_SHIFT);
|
||||
|
||||
// Hard limiter: clamp to ±LIMITER_THRESHOLD to prevent ADC clipping artifacts
|
||||
if (s > LIMITER_THRESHOLD) s = LIMITER_THRESHOLD;
|
||||
else if (s < -LIMITER_THRESHOLD) s = -LIMITER_THRESHOLD;
|
||||
dsBuf[i] = s;
|
||||
int16_t v = s < 0 ? -s : s;
|
||||
if (v > runningPeakDs) runningPeakDs = v;
|
||||
}
|
||||
|
||||
// Measure actual sample rate: count deinterleaved samples per second
|
||||
totalDsSamples += dsCount;
|
||||
// Measure actual sample rate
|
||||
totalSamples += ch0Count;
|
||||
uint32_t now = millis();
|
||||
uint32_t elapsed = now - rateCheckMs;
|
||||
if (elapsed >= 2000) {
|
||||
uint32_t rate = (totalDsSamples * 1000) / elapsed;
|
||||
uint32_t rate = (totalSamples * 1000) / elapsed;
|
||||
{
|
||||
char logbuf[128];
|
||||
snprintf(logbuf, sizeof(logbuf), "[CAP] rate=%luHz frames=%lu rawPeak=%d dsPeak=%d ringDrops=%lu",
|
||||
snprintf(logbuf, sizeof(logbuf), "[CAP] rate=%luHz frames=%lu peak=%d ringDrops=%lu",
|
||||
(unsigned long)rate, (unsigned long)framesEncoded,
|
||||
runningPeakRaw, runningPeakDs, (unsigned long)ringDrops);
|
||||
runningPeak, (unsigned long)ringDrops);
|
||||
pyxis_log(logbuf);
|
||||
}
|
||||
totalDsSamples = 0;
|
||||
totalSamples = 0;
|
||||
rateCheckMs = now;
|
||||
runningPeakRaw = 0;
|
||||
runningPeakDs = 0;
|
||||
runningPeak = 0;
|
||||
}
|
||||
|
||||
// Accumulate downsampled samples into frame-sized buffer
|
||||
// Accumulate mono samples into frame-sized buffer
|
||||
int offset = 0;
|
||||
while (offset < dsCount && capturing_.load(std::memory_order_relaxed)) {
|
||||
while (offset < ch0Count && capturing_.load(std::memory_order_relaxed)) {
|
||||
int needed = frameSamples_ - accumCount_;
|
||||
int available = dsCount - offset;
|
||||
int available = ch0Count - offset;
|
||||
int toCopy = (available < needed) ? available : needed;
|
||||
|
||||
memcpy(accumBuffer_ + accumCount_, dsBuf + offset, toCopy * sizeof(int16_t));
|
||||
memcpy(accumBuffer_ + accumCount_, ch0Buf + offset, toCopy * sizeof(int16_t));
|
||||
accumCount_ += toCopy;
|
||||
offset += toCopy;
|
||||
|
||||
|
||||
@@ -101,8 +101,12 @@ private:
|
||||
|
||||
bool filtersEnabled_ = true;
|
||||
|
||||
static constexpr int I2S_SAMPLE_RATE = 16000; // I2S runs at 16kHz (matches T-Deck Plus reference)
|
||||
static constexpr int CODEC_SAMPLE_RATE = 8000; // Codec2 expects 8kHz — we downsample 2:1
|
||||
static constexpr int I2S_SAMPLE_RATE = 8000; // I2S runs at 8kHz — matches Codec2 directly, no resampling needed
|
||||
static constexpr int CODEC_SAMPLE_RATE = 8000; // Codec2 expects 8kHz
|
||||
// Accumulate this many codec frames before filter+encode.
|
||||
// Matches Columba's 200ms batch (1600 samples for Codec2 3200).
|
||||
// The AGC needs large blocks for stable gain tracking.
|
||||
static constexpr int FRAMES_PER_BATCH = 10;
|
||||
static constexpr int ENCODED_RING_SLOTS = 128;
|
||||
static constexpr int ENCODED_RING_MAX_BYTES = 256;
|
||||
static constexpr int CAPTURE_TASK_STACK = 24576; // 24KB — pyxis_log→sendto uses ~4KB lwIP stack
|
||||
|
||||
@@ -44,7 +44,7 @@ bool LXSTAudio::init(int codec2Mode, uint8_t micGain) {
|
||||
cfg.codec_mode = AUDIO_HAL_CODEC_MODE_ENCODE;
|
||||
cfg.i2s_iface.mode = AUDIO_HAL_MODE_SLAVE;
|
||||
cfg.i2s_iface.fmt = AUDIO_HAL_I2S_NORMAL;
|
||||
cfg.i2s_iface.samples = AUDIO_HAL_16K_SAMPLES;
|
||||
cfg.i2s_iface.samples = AUDIO_HAL_08K_SAMPLES;
|
||||
cfg.i2s_iface.bits = AUDIO_HAL_BIT_LENGTH_16BITS;
|
||||
|
||||
uint32_t ret_val = ESP_OK;
|
||||
|
||||
@@ -67,10 +67,10 @@ public:
|
||||
* Does NOT start capture or playback.
|
||||
*
|
||||
* @param codec2Mode Codec2 library mode (default 1600)
|
||||
* @param micGain ES7210 mic gain (0-14, default 8 = 24dB)
|
||||
* @param micGain ES7210 mic gain (0-14, default 7 = 21dB)
|
||||
* @return true on success
|
||||
*/
|
||||
bool init(int codec2Mode = CODEC2_MODE_1600, uint8_t micGain = 5);
|
||||
bool init(int codec2Mode = CODEC2_MODE_1600, uint8_t micGain = 7);
|
||||
|
||||
/** Tear down everything and release all resources. */
|
||||
void deinit();
|
||||
|
||||
@@ -73,7 +73,7 @@ void ConversationListScreen::create_header() {
|
||||
|
||||
// Title
|
||||
lv_obj_t* title = lv_label_create(_header);
|
||||
lv_label_set_text(title, "LXMF");
|
||||
lv_label_set_text(title, "PYXIS");
|
||||
lv_obj_align(title, LV_ALIGN_LEFT_MID, 8, 0);
|
||||
lv_obj_set_style_text_color(title, Theme::textPrimary(), 0);
|
||||
lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
|
||||
|
||||
+101
-115
@@ -61,9 +61,11 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::Me
|
||||
_call_muted(false),
|
||||
_call_answer_pending(false),
|
||||
_call_link_closed_pending(false),
|
||||
_call_signal_pending(0xFF),
|
||||
_call_signal_write(0),
|
||||
_call_signal_read(0),
|
||||
_call_audio_rx_count(0),
|
||||
_call_audio_tx_count(0) {
|
||||
memset((void*)_call_signal_queue, 0, sizeof(_call_signal_queue));
|
||||
}
|
||||
|
||||
UIManager::~UIManager() {
|
||||
@@ -794,7 +796,8 @@ void UIManager::call_initiate(const Bytes& peer_hash) {
|
||||
_call_audio_rx_count = 0;
|
||||
_call_audio_tx_count = 0;
|
||||
_call_link_closed_pending = false;
|
||||
_call_signal_pending = 0xFF;
|
||||
_call_signal_write = 0;
|
||||
_call_signal_read = 0;
|
||||
|
||||
{
|
||||
std::string dh = peer_dest.hash().toHex().substr(0, 16);
|
||||
@@ -825,6 +828,11 @@ void UIManager::call_initiate(const Bytes& peer_hash) {
|
||||
void UIManager::call_hangup() {
|
||||
INFO("LXST: Hanging up");
|
||||
|
||||
// Set IDLE first — prevents pump_call_tx() (which runs without LVGL lock)
|
||||
// from accessing _lxst_audio after we delete it.
|
||||
_call_state = CallState::IDLE;
|
||||
s_call_instance = nullptr;
|
||||
|
||||
// Stop audio
|
||||
if (_lxst_audio) {
|
||||
_lxst_audio->stopCapture();
|
||||
@@ -840,9 +848,7 @@ void UIManager::call_hangup() {
|
||||
_call_link = Link(Type::NONE);
|
||||
}
|
||||
|
||||
_call_state = CallState::IDLE;
|
||||
_call_peer_hash = Bytes();
|
||||
s_call_instance = nullptr;
|
||||
|
||||
// Return to chat screen
|
||||
if (_call_screen) {
|
||||
@@ -890,13 +896,19 @@ void UIManager::call_send_signal(int signal) {
|
||||
len = 6;
|
||||
}
|
||||
|
||||
Bytes signal_data(msgpack_buf, len);
|
||||
Packet packet(_call_link, signal_data);
|
||||
packet.send();
|
||||
try {
|
||||
Bytes signal_data(msgpack_buf, len);
|
||||
Packet packet(_call_link, signal_data);
|
||||
packet.send();
|
||||
|
||||
char buf[48];
|
||||
snprintf(buf, sizeof(buf), "LXST: Sent signal 0x%03X", signal);
|
||||
DEBUG(buf);
|
||||
char buf[48];
|
||||
snprintf(buf, sizeof(buf), "LXST: Sent signal 0x%03X", signal);
|
||||
DEBUG(buf);
|
||||
} catch (const std::exception& e) {
|
||||
char dbg[128];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: Signal send exception: %s", e.what());
|
||||
WARNING(dbg);
|
||||
}
|
||||
}
|
||||
|
||||
void UIManager::call_send_audio_batch(const uint8_t* batch_data, int batch_len,
|
||||
@@ -956,12 +968,21 @@ void UIManager::call_send_audio_batch(const uint8_t* batch_data, int batch_len,
|
||||
INFO(dbg);
|
||||
}
|
||||
|
||||
Bytes audio_data(packet_buf, pos);
|
||||
Packet packet(_call_link, audio_data);
|
||||
packet.send();
|
||||
try {
|
||||
Bytes audio_data(packet_buf, pos);
|
||||
Packet packet(_call_link, audio_data);
|
||||
packet.send();
|
||||
} catch (const std::exception& e) {
|
||||
char dbg[128];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: TX send exception: %s", e.what());
|
||||
WARNING(dbg);
|
||||
}
|
||||
}
|
||||
|
||||
void UIManager::call_rx_audio_frame(const uint8_t* frame, size_t frame_len) {
|
||||
// Guard: packets can arrive after hangup from the network pipeline
|
||||
if (!_lxst_audio || _call_state == CallState::IDLE) return;
|
||||
|
||||
// Wire format: [codec_type_byte] + [mode_header + codec2_subframes...]
|
||||
// codec_type: 0x00=Raw, 0x01=Opus, 0x02=Codec2 (matches LXST Codecs/__init__.py)
|
||||
// For Codec2: mode_header (0x00-0x06) + raw sub-frames
|
||||
@@ -1057,12 +1078,21 @@ void UIManager::call_on_packet(const Bytes& data) {
|
||||
return;
|
||||
}
|
||||
|
||||
char dbg[48];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal);
|
||||
DEBUG(dbg);
|
||||
{
|
||||
char dbg[48];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal);
|
||||
INFO(dbg);
|
||||
}
|
||||
|
||||
// Queue for processing in call_update() under LVGL lock
|
||||
_call_signal_pending = (uint8_t)signal;
|
||||
// Enqueue for processing in call_update() under LVGL lock
|
||||
uint8_t w = _call_signal_write;
|
||||
uint8_t next_w = (w + 1) % SIGNAL_QUEUE_SIZE;
|
||||
if (next_w != _call_signal_read) { // Not full
|
||||
_call_signal_queue[w] = (uint8_t)signal;
|
||||
_call_signal_write = next_w;
|
||||
} else {
|
||||
WARNING("LXST: Signal queue full, dropping signal!");
|
||||
}
|
||||
|
||||
} else if (field == 0x01) {
|
||||
// Audio: {0x01: value} where value is either:
|
||||
@@ -1126,9 +1156,11 @@ void UIManager::call_on_packet(const Bytes& data) {
|
||||
|
||||
// Process received signal — runs under LVGL lock from call_update()
|
||||
void UIManager::call_process_signal(uint8_t signal) {
|
||||
char dbg[48];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: Processing signal 0x%02X (state=%d)", signal, (int)_call_state);
|
||||
DEBUG(dbg);
|
||||
{
|
||||
char dbg[48];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: Processing signal 0x%02X (state=%d)", signal, (int)_call_state);
|
||||
INFO(dbg);
|
||||
}
|
||||
|
||||
switch (_call_state) {
|
||||
case CallState::WAIT_AVAILABLE:
|
||||
@@ -1234,6 +1266,11 @@ void UIManager::call_process_signal(uint8_t signal) {
|
||||
void UIManager::call_ended() {
|
||||
INFO("LXST: Call ended");
|
||||
|
||||
// Set IDLE first — prevents pump_call_tx() (which runs without LVGL lock)
|
||||
// from accessing _lxst_audio after we delete it.
|
||||
_call_state = CallState::IDLE;
|
||||
s_call_instance = nullptr;
|
||||
|
||||
// Stop audio
|
||||
if (_lxst_audio) {
|
||||
_lxst_audio->stopCapture();
|
||||
@@ -1249,9 +1286,7 @@ void UIManager::call_ended() {
|
||||
_call_link = Link(Type::NONE);
|
||||
}
|
||||
|
||||
_call_state = CallState::IDLE;
|
||||
_call_peer_hash = Bytes();
|
||||
s_call_instance = nullptr;
|
||||
|
||||
_call_screen->set_state(CallScreen::CallState::ENDED);
|
||||
|
||||
@@ -1259,6 +1294,42 @@ void UIManager::call_ended() {
|
||||
show_conversation_list();
|
||||
}
|
||||
|
||||
void UIManager::pump_call_tx() {
|
||||
if (_call_state == CallState::IDLE) return;
|
||||
if (!_lxst_audio || !_lxst_audio->isCapturing()) return;
|
||||
if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return;
|
||||
|
||||
int available = _lxst_audio->capturePacketsAvailable();
|
||||
|
||||
// Drain all available batches — this runs on loopTask (core 1)
|
||||
// and doesn't touch LVGL, so no lock needed.
|
||||
while (available > 0) {
|
||||
uint8_t encoded_buf[128];
|
||||
int encoded_len = 0;
|
||||
if (!_lxst_audio->readEncodedPacket(encoded_buf, sizeof(encoded_buf), &encoded_len)) {
|
||||
break;
|
||||
}
|
||||
if (encoded_len < 2) { available--; continue; }
|
||||
|
||||
// Prepend codec type byte: [0x02] + [encoded: mode_header + 10*8 raw]
|
||||
uint8_t batch_data[128];
|
||||
batch_data[0] = LXST_CODEC_CODEC2;
|
||||
memcpy(batch_data + 1, encoded_buf, encoded_len);
|
||||
int batch_len = 1 + encoded_len;
|
||||
|
||||
call_send_audio_batch(batch_data, batch_len, 1, encoded_len / 8);
|
||||
_call_audio_tx_count++;
|
||||
available--;
|
||||
|
||||
if (_call_audio_tx_count <= 10 || (_call_audio_tx_count % 100 == 0)) {
|
||||
char dbg[96];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: TX batch #%lu (%d bytes, avail=%d)",
|
||||
(unsigned long)_call_audio_tx_count, batch_len, available);
|
||||
INFO(dbg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UIManager::call_update() {
|
||||
uint32_t now = millis();
|
||||
|
||||
@@ -1269,11 +1340,11 @@ void UIManager::call_update() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process deferred signal (set by Reticulum packet callback, consumed here under LVGL lock)
|
||||
uint8_t pending_sig = _call_signal_pending;
|
||||
if (pending_sig != 0xFF) {
|
||||
_call_signal_pending = 0xFF;
|
||||
call_process_signal(pending_sig);
|
||||
// Process all queued signals (set by Reticulum packet callback, consumed here under LVGL lock)
|
||||
while (_call_signal_read != _call_signal_write) {
|
||||
uint8_t sig = _call_signal_queue[_call_signal_read];
|
||||
_call_signal_read = (_call_signal_read + 1) % SIGNAL_QUEUE_SIZE;
|
||||
call_process_signal(sig);
|
||||
if (_call_state == CallState::IDLE) return; // Signal caused call to end
|
||||
}
|
||||
|
||||
@@ -1381,93 +1452,8 @@ void UIManager::call_update() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pump TX: batch codec frames to match Columba's expected ring buffer slot size.
|
||||
// Columba's native OboePlaybackEngine expects exactly frameSamples (1600 for 3200
|
||||
// mode) decoded samples per writeEncodedPacket call = 10 sub-frames.
|
||||
// Each encoded frame from ring buffer = [mode_header(1)] + [raw_codec2(8)] = 9 bytes.
|
||||
// We pack 10 frames per batch: [codec_type(1)] + [mode(1)] + [10*raw(80)] = 82 bytes.
|
||||
// Multiple batches go into a fixarray, matching Columba's wire format exactly.
|
||||
static constexpr int FRAMES_PER_BATCH = 10; // 10 * 160 = 1600 = Columba's frameSamples
|
||||
static constexpr int MAX_BATCHES = 2; // Up to 2 batches per packet (like Columba)
|
||||
static constexpr int TX_MAX_FRAMES = FRAMES_PER_BATCH * MAX_BATCHES;
|
||||
static constexpr uint32_t TX_INTERVAL_MS = 200; // Match LXST-kt LBW frame time
|
||||
static uint32_t last_tx_ms = 0;
|
||||
if (_lxst_audio && _lxst_audio->isCapturing()) {
|
||||
int available = _lxst_audio->capturePacketsAvailable();
|
||||
bool time_to_send = (now - last_tx_ms) >= TX_INTERVAL_MS;
|
||||
// Send if: enough time has passed AND we have frames,
|
||||
// OR ring is getting full (>60 frames = 1.2s buffered)
|
||||
if ((time_to_send && available >= FRAMES_PER_BATCH) || available > 60) {
|
||||
int to_send = available < TX_MAX_FRAMES ? available : TX_MAX_FRAMES;
|
||||
|
||||
// Read frames and pack into batches of 10.
|
||||
// batch_data: concatenated batches, each 82 bytes:
|
||||
// [codec_type(0x02)] + [mode_header] + [10 * 8 raw bytes]
|
||||
uint8_t batch_data[MAX_BATCHES * 82];
|
||||
int batch_count = 0;
|
||||
int total_frames = 0;
|
||||
uint8_t encoded_buf[16];
|
||||
|
||||
while (batch_count < MAX_BATCHES && to_send >= FRAMES_PER_BATCH) {
|
||||
uint8_t* bp = batch_data + batch_count * 82;
|
||||
bp[0] = LXST_CODEC_CODEC2; // codec_type = 0x02
|
||||
int frames_in_batch = 0;
|
||||
|
||||
for (int i = 0; i < FRAMES_PER_BATCH; i++) {
|
||||
int encoded_len = 0;
|
||||
if (!_lxst_audio->readEncodedPacket(encoded_buf, sizeof(encoded_buf), &encoded_len)) {
|
||||
break;
|
||||
}
|
||||
if (encoded_len < 2) continue;
|
||||
|
||||
if (frames_in_batch == 0) {
|
||||
// First frame: keep mode_header + raw
|
||||
memcpy(bp + 1, encoded_buf, encoded_len);
|
||||
} else {
|
||||
// Subsequent: append raw only (strip mode_header)
|
||||
memcpy(bp + 1 + 1 + frames_in_batch * 8, encoded_buf + 1, encoded_len - 1);
|
||||
}
|
||||
frames_in_batch++;
|
||||
_call_audio_tx_count++;
|
||||
to_send--;
|
||||
}
|
||||
|
||||
if (frames_in_batch == FRAMES_PER_BATCH) {
|
||||
batch_count++;
|
||||
total_frames += frames_in_batch;
|
||||
} else {
|
||||
// Incomplete batch — put back? Can't, so just count what we got
|
||||
total_frames += frames_in_batch;
|
||||
if (frames_in_batch > 0) batch_count++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (batch_count > 0) {
|
||||
call_send_audio_batch(batch_data, 82 * batch_count,
|
||||
batch_count, total_frames);
|
||||
last_tx_ms = now;
|
||||
if (_call_audio_tx_count <= 50) {
|
||||
char dbg[96];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: TX %d batches %d frames (%d avail), total=%lu",
|
||||
batch_count, total_frames, available,
|
||||
(unsigned long)_call_audio_tx_count);
|
||||
INFO(dbg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (_call_audio_tx_count == 0) {
|
||||
// Log once why TX is not running
|
||||
static uint32_t last_tx_warn = 0;
|
||||
if (now - last_tx_warn > 2000) {
|
||||
last_tx_warn = now;
|
||||
char dbg[96];
|
||||
snprintf(dbg, sizeof(dbg), "LXST: TX pump idle: audio=%p capturing=%d state=%d",
|
||||
_lxst_audio, _lxst_audio ? (int)_lxst_audio->isCapturing() : -1,
|
||||
_lxst_audio ? (int)_lxst_audio->state() : -1);
|
||||
WARNING(dbg);
|
||||
}
|
||||
}
|
||||
// TX pump — also called from main loop without LVGL lock for low latency
|
||||
pump_call_tx();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,12 @@ public:
|
||||
*/
|
||||
void update();
|
||||
|
||||
/**
|
||||
* Pump TX audio without LVGL lock — call from main loop for low-latency TX.
|
||||
* Safe to call on every loop iteration; no-ops when not in a call.
|
||||
*/
|
||||
void pump_call_tx();
|
||||
|
||||
/**
|
||||
* Show conversation list screen
|
||||
*/
|
||||
@@ -276,7 +282,11 @@ private:
|
||||
bool _call_muted;
|
||||
volatile bool _call_answer_pending; // Set by LVGL task, consumed by main loop
|
||||
volatile bool _call_link_closed_pending; // Set by link callback, consumed by call_update
|
||||
volatile uint8_t _call_signal_pending; // 0xFF = none; set by packet callback
|
||||
// Signal queue: written by Reticulum thread, consumed by call_update under LVGL lock
|
||||
static constexpr int SIGNAL_QUEUE_SIZE = 8;
|
||||
volatile uint8_t _call_signal_queue[SIGNAL_QUEUE_SIZE];
|
||||
volatile uint8_t _call_signal_write; // Next write index (Reticulum thread)
|
||||
volatile uint8_t _call_signal_read; // Next read index (main thread)
|
||||
uint32_t _call_audio_rx_count; // Count of received audio frames (for diagnostics)
|
||||
uint32_t _call_audio_tx_count; // Count of sent audio frames (for diagnostics)
|
||||
|
||||
|
||||
+8
-10
@@ -52,10 +52,9 @@ build_flags =
|
||||
-std=gnu++11
|
||||
-DBOARD_HAS_PSRAM
|
||||
-DBOARD_ESP32
|
||||
; Arduino loop task stack — measured peak is ~6KB (stack_hwm=43380/49152)
|
||||
; 16KB gives ~2.5x headroom for deep call chains
|
||||
; (transport → link → resource → LXMF unpack → crypto → msgpack)
|
||||
-DARDUINO_LOOP_STACK_SIZE=16384
|
||||
; Arduino loop task stack — Codec2 decode (lpc_post_filter + kiss_fft)
|
||||
; uses ~6KB stack on top of ~10KB normal peak. 24KB gives safe headroom.
|
||||
-DARDUINO_LOOP_STACK_SIZE=24576
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
-DARDUINO_USB_MODE=1
|
||||
-DLV_CONF_INCLUDE_SIMPLE
|
||||
@@ -133,10 +132,9 @@ build_flags =
|
||||
-std=gnu++11
|
||||
-DBOARD_HAS_PSRAM
|
||||
-DBOARD_ESP32
|
||||
; Arduino loop task stack — measured peak is ~6KB (stack_hwm=43380/49152)
|
||||
; 16KB gives ~2.5x headroom for deep call chains
|
||||
; (transport → link → resource → LXMF unpack → crypto → msgpack)
|
||||
-DARDUINO_LOOP_STACK_SIZE=16384
|
||||
; Arduino loop task stack — Codec2 decode (lpc_post_filter + kiss_fft)
|
||||
; uses ~6KB stack on top of ~10KB normal peak. 24KB gives safe headroom.
|
||||
-DARDUINO_LOOP_STACK_SIZE=24576
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
-DARDUINO_USB_MODE=1
|
||||
-DLV_CONF_INCLUDE_SIMPLE
|
||||
@@ -164,5 +162,5 @@ build_flags =
|
||||
; Usage: pio run -e tdeck-ota -t upload
|
||||
[env:tdeck-ota]
|
||||
extends = env:tdeck
|
||||
upload_protocol = espota
|
||||
upload_port = pyxis-tdeck.local
|
||||
upload_protocol = custom
|
||||
upload_command = python3 tools/espota.py -i pyxis-tdeck.local -p 3232 -f $SOURCE
|
||||
+108
-18
@@ -69,8 +69,11 @@
|
||||
// OTA flashing
|
||||
#include <ArduinoOTA.h>
|
||||
|
||||
// UDP log broadcasting
|
||||
#include <WiFiUdp.h>
|
||||
// UDP log broadcasting (POSIX socket — avoids WiFiUDP's per-packet heap allocation)
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
// Memory instrumentation
|
||||
#ifdef MEMORY_INSTRUMENTATION_ENABLED
|
||||
@@ -130,21 +133,53 @@ volatile bool wifi_reconnect_pending = false;
|
||||
String pending_wifi_ssid;
|
||||
String pending_wifi_password;
|
||||
|
||||
// UDP log broadcasting (multicast avoids duplicate delivery on multi-homed hosts)
|
||||
static WiFiUDP udp_log;
|
||||
static const IPAddress UDP_LOG_GROUP(239, 0, 99, 99);
|
||||
// UDP log broadcasting (POSIX socket — no per-packet heap allocation)
|
||||
// WiFiUDP::beginPacket() does new char[1460] on every call, causing severe
|
||||
// heap fragmentation over time. A raw POSIX socket with sendto() avoids this.
|
||||
static int udp_log_sock = -1;
|
||||
static struct sockaddr_in udp_log_dest;
|
||||
static bool udp_log_ready = false;
|
||||
|
||||
// Helper: send a string via UDP broadcast (for Serial.printf diagnostics)
|
||||
// Guards against sending during WiFi transitions and reentrant calls
|
||||
static volatile bool udp_sending = false;
|
||||
static void udp_log_init() {
|
||||
if (udp_log_sock >= 0) close(udp_log_sock); // Re-init safe
|
||||
udp_log_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
if (udp_log_sock < 0) return;
|
||||
|
||||
// Non-blocking so sendto() never stalls the log path
|
||||
int flags = fcntl(udp_log_sock, F_GETFL, 0);
|
||||
fcntl(udp_log_sock, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
// Multicast TTL = 1 (local network only)
|
||||
uint8_t ttl = 1;
|
||||
setsockopt(udp_log_sock, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
|
||||
|
||||
// Bind multicast output to the WiFi station interface — without this,
|
||||
// lwIP doesn't know which interface to send multicast packets on.
|
||||
struct in_addr iface;
|
||||
iface.s_addr = (uint32_t)WiFi.localIP();
|
||||
setsockopt(udp_log_sock, IPPROTO_IP, IP_MULTICAST_IF, &iface, sizeof(iface));
|
||||
|
||||
memset(&udp_log_dest, 0, sizeof(udp_log_dest));
|
||||
udp_log_dest.sin_family = AF_INET;
|
||||
udp_log_dest.sin_port = htons(9999);
|
||||
udp_log_dest.sin_addr.s_addr = inet_addr("239.0.99.99");
|
||||
}
|
||||
|
||||
// UDP send — no locking needed. sendto() is non-blocking (O_NONBLOCK) and
|
||||
// lwIP's internal TCPIP core lock serializes concurrent calls. Worst case
|
||||
// on contention: EAGAIN/ENOMEM and the packet is dropped (acceptable for logs).
|
||||
static void udp_send(const char* msg, size_t len) {
|
||||
if (!udp_log_ready || udp_sending || WiFi.status() != WL_CONNECTED) return;
|
||||
udp_sending = true;
|
||||
udp_log.beginPacket(UDP_LOG_GROUP, 9999);
|
||||
udp_log.write((const uint8_t*)msg, len);
|
||||
udp_log.endPacket();
|
||||
udp_sending = false;
|
||||
if (udp_log_sock < 0 || !udp_log_ready || WiFi.status() != WL_CONNECTED) return;
|
||||
sendto(udp_log_sock, msg, len, 0,
|
||||
(struct sockaddr*)&udp_log_dest, sizeof(udp_log_dest));
|
||||
}
|
||||
|
||||
// Global log function callable from any module (sends to UDP + Serial)
|
||||
extern "C" void pyxis_log(const char* msg) {
|
||||
Serial.println(msg);
|
||||
if (udp_log_ready) {
|
||||
udp_send(msg, strlen(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// Forward declarations
|
||||
@@ -419,7 +454,7 @@ void load_app_settings() {
|
||||
app_settings.ble_enabled = prefs.getBool("ble_en", false);
|
||||
|
||||
// Advanced
|
||||
app_settings.announce_interval = prefs.getULong("announce", 60);
|
||||
app_settings.announce_interval = prefs.getULong("announce", 3600);
|
||||
app_settings.sync_interval = prefs.getULong("sync_int", 3600); // Default 60 minutes
|
||||
app_settings.gps_time_sync = prefs.getBool("gps_sync", true);
|
||||
|
||||
@@ -505,15 +540,58 @@ void setup_wifi() {
|
||||
|
||||
// Initialize ArduinoOTA for wireless flashing
|
||||
ArduinoOTA.setHostname("pyxis-tdeck");
|
||||
ArduinoOTA.onStart([]() { INFO("OTA: Update starting..."); });
|
||||
ArduinoOTA.onStart([]() {
|
||||
INFO("OTA: Update starting — suspending BLE for clean WiFi");
|
||||
// Pause BLE to free the shared 2.4GHz radio for WiFi transfer
|
||||
if (ble_interface_impl) {
|
||||
ble_interface_impl->stop();
|
||||
INFO("OTA: BLE stopped");
|
||||
}
|
||||
// Disconnect TCP to reduce WiFi contention
|
||||
if (tcp_interface_impl) {
|
||||
tcp_interface_impl->stop();
|
||||
INFO("OTA: TCP stopped");
|
||||
}
|
||||
});
|
||||
ArduinoOTA.onEnd([]() { INFO("OTA: Update complete, rebooting"); });
|
||||
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
|
||||
// Tight OTA service loop: feed WDT and yield to OTA networking
|
||||
// without returning to the heavy main loop
|
||||
esp_task_wdt_reset();
|
||||
static unsigned int last_pct = 999;
|
||||
unsigned int pct = progress * 100 / total;
|
||||
if (pct != last_pct && pct % 10 == 0) {
|
||||
last_pct = pct;
|
||||
Serial.printf("OTA: %u%%\n", pct);
|
||||
}
|
||||
});
|
||||
ArduinoOTA.onError([](ota_error_t error) { ERROR("OTA: Error"); });
|
||||
ArduinoOTA.begin();
|
||||
INFO("OTA: Ready");
|
||||
|
||||
// Initialize UDP log broadcasting (multicast group 239.0.99.99:9999)
|
||||
udp_log_init();
|
||||
udp_log_ready = true;
|
||||
RNS::setLogCallback([](const char* msg, RNS::LogLevel level) {
|
||||
// Suppress noisy per-packet LoRa/transport trace lines on UDP
|
||||
// (still send to Serial for wired debugging)
|
||||
bool suppress_udp = false;
|
||||
if (level <= RNS::LOG_DEBUG) {
|
||||
// Quick prefix checks for the noisiest log sources
|
||||
if (strncmp(msg, "SX1262", 6) == 0 ||
|
||||
strncmp(msg, "Transport::inbound", 18) == 0 ||
|
||||
strncmp(msg, "AutoInterface:", 14) == 0 ||
|
||||
strncmp(msg, "Packet::", 8) == 0 ||
|
||||
strncmp(msg, "Creating packet", 15) == 0 ||
|
||||
strncmp(msg, "Checking to see", 15) == 0 ||
|
||||
strncmp(msg, "Caching packet", 14) == 0 ||
|
||||
strncmp(msg, "Adding destination", 18) == 0 ||
|
||||
strncmp(msg, "InterfaceImpl", 13) == 0 ||
|
||||
strncmp(msg, "Identity::", 10) == 0 ||
|
||||
strncmp(msg, "Dropped", 7) == 0) {
|
||||
suppress_udp = true;
|
||||
}
|
||||
}
|
||||
// Serial (preserve wired debugging)
|
||||
Serial.print(RNS::getTimeString());
|
||||
Serial.print(" [");
|
||||
@@ -521,8 +599,8 @@ void setup_wifi() {
|
||||
Serial.print("] ");
|
||||
Serial.println(msg);
|
||||
Serial.flush();
|
||||
// UDP broadcast
|
||||
if (udp_log_ready) {
|
||||
// UDP broadcast (filtered)
|
||||
if (udp_log_ready && !suppress_udp) {
|
||||
char buf[512];
|
||||
int len = snprintf(buf, sizeof(buf), "%s [%s] %s",
|
||||
RNS::getTimeString(), RNS::getLevelName(level), msg);
|
||||
@@ -610,6 +688,10 @@ void setup_reticulum() {
|
||||
// Create Reticulum instance (no auto-init)
|
||||
reticulum = new Reticulum();
|
||||
|
||||
// Reduce transport log verbosity — LOG_TRACE floods serial with
|
||||
// token/link/announce details that drown out audio diagnostics.
|
||||
RNS::loglevel(RNS::LOG_INFO);
|
||||
|
||||
// Load or create identity using NVS (Non-Volatile Storage)
|
||||
// NVS is preserved across flashes unlike SPIFFS
|
||||
Preferences prefs;
|
||||
@@ -914,6 +996,7 @@ void setup_ui_manager() {
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
udp_log_init(); // Rebind to new WiFi interface IP
|
||||
udp_log_ready = true; // Resume UDP logging
|
||||
INFO(("WiFi connected! IP: " + WiFi.localIP().toString()).c_str());
|
||||
} else {
|
||||
@@ -1325,6 +1408,7 @@ void loop() {
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
udp_log_init(); // Rebind to new WiFi interface IP
|
||||
udp_log_ready = true; // Resume UDP logging
|
||||
INFO(("WiFi connected! IP: " + WiFi.localIP().toString()).c_str());
|
||||
} else {
|
||||
@@ -1338,6 +1422,12 @@ void loop() {
|
||||
LOOP_STEP(4); // reticulum->loop()
|
||||
reticulum->loop();
|
||||
|
||||
// Pump TX audio immediately after Reticulum — low-latency path that
|
||||
// bypasses LVGL lock and all other loop steps. No-ops when not in a call.
|
||||
if (ui_manager) {
|
||||
ui_manager->pump_call_tx();
|
||||
}
|
||||
|
||||
// Periodically persist identity/transport data (display names, paths, etc.)
|
||||
// NOTE: Identity persistence writes 40-50 entries to SPIFFS flash, which
|
||||
// involves sector erases (100ms each) and can take 5-15s total.
|
||||
|
||||
Reference in new issue
Block a user