From 612f44e274a9299d2f9ccbfe3123f652a7f9d91d Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Fri, 8 May 2026 19:07:41 -0400 Subject: [PATCH] feat(lxst): Codec2-700C (ULBW) default profile + T:CALL_ANSWER hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two profile constants beyond the existing LXST_PROFILE_LBW (0x30, Codec2-3200): LXST_PROFILE_VLBW (0x20, Codec2-1600) and LXST_PROFILE_ULBW (0x10, Codec2-700C). Default is now ULBW — a 700C frame fits comfortably inside an SF7-9 LoRa packet, which is the target medium for pyxis voice. The previous 3200bps default was 4.5x larger and unsuitable for the radio path. Profile is selectable at runtime via T:CALL_PROFILE [hex]. Replaces five hardcoded LXST_PROFILE_LBW sites: three audio-init paths in call_process_signal and two profile-negotiation send_signal calls. Adds T:CALL_ANSWER for harness pyxis-as-callee testing — sets the same _call_answer_pending flag the UI button does so call_answer() runs on the main loop in its proper context. Validates against real LXST.Telephony.Telephone callers. Validated: pyxis dialed real LXST upstream Telephone bot, negotiated ULBW end-to-end, reached STATUS_ESTABLISHED, decoded frames cleanly both directions. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/tdeck_ui/UI/LXMF/UIManager.cpp | 65 +++++++++++++++++++++++------- lib/tdeck_ui/UI/LXMF/UIManager.h | 30 ++++++++++++++ src/main.cpp | 32 +++++++++++++++ 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index ed1869d8..c26791d1 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -43,6 +43,20 @@ public: }; static std::shared_ptr s_lxst_announce_handler; +// Default preferred profile: Codec2-700C (ULBW). Sized to fit a LoRa +// SF7-9 link with header overhead. T:CALL_PROFILE in the test hooks +// changes this between calls. +int UIManager::_preferred_profile = UIManager::LXST_PROFILE_ULBW; + +int UIManager::profile_to_codec2_mode(int profile) { + switch (profile) { + case LXST_PROFILE_ULBW: return CODEC2_MODE_700C; + case LXST_PROFILE_VLBW: return CODEC2_MODE_1600; + case LXST_PROFILE_LBW: return CODEC2_MODE_3200; + default: return -1; + } +} + UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::MessageStore& store) : _reticulum(reticulum), _router(router), _store(store), // Vanilla upstream RNS::Destination has no default ctor; construct in @@ -955,6 +969,18 @@ void UIManager::test_call_set_inject_sine(bool enabled, int freq, float amp) { if (_lxst_audio) _lxst_audio->captureSetInjectSine(enabled, freq, amp); } +bool UIManager::test_call_set_profile(int profile) { + if (profile_to_codec2_mode(profile) < 0) return false; + _preferred_profile = profile; + return true; +} + +bool UIManager::test_call_answer() { + if (_call_state != CallState::INCOMING_RINGING) return false; + _call_answer_pending = true; + return true; +} + const char* UIManager::test_call_state_name() const { switch (_call_state) { case CallState::IDLE: return "IDLE"; @@ -1215,12 +1241,12 @@ void UIManager::call_on_packet(const Bytes& data) { // Pyxis only supports Codec2, so respond with LBW (Codec2 3200bps). if (signal >= LXST_PREFERRED_PROFILE) { int remote_profile = signal - LXST_PREFERRED_PROFILE; - char dbg[64]; - snprintf(dbg, sizeof(dbg), "LXST: Remote prefers profile 0x%02X, responding LBW (Codec2)", - remote_profile); + char dbg[80]; + snprintf(dbg, sizeof(dbg), + "LXST: Remote prefers profile 0x%02X, responding 0x%02X", + remote_profile, _preferred_profile); INFO(dbg); - // Send our preferred profile (LBW = Codec2 3200bps) - call_send_signal(LXST_PREFERRED_PROFILE + LXST_PROFILE_LBW); + call_send_signal(LXST_PREFERRED_PROFILE + _preferred_profile); return; } @@ -1324,8 +1350,8 @@ void UIManager::call_process_signal(uint8_t signal) { case CallState::WAIT_RINGING: if (signal == LXST_STATUS_RINGING) { INFO("LXST: Remote is ringing"); - // Tell remote we need Codec2 (LBW = 3200bps) - call_send_signal(LXST_PREFERRED_PROFILE + LXST_PROFILE_LBW); + // Tell remote our preferred profile (default ULBW = Codec2-700C) + call_send_signal(LXST_PREFERRED_PROFILE + _preferred_profile); _call_state = CallState::RINGING; _call_timeout_ms = millis() + 60000; _call_screen->set_state(CallScreen::CallState::RINGING); @@ -1341,11 +1367,13 @@ void UIManager::call_process_signal(uint8_t signal) { _call_state = CallState::CONNECTING; lxst_breadcrumb(20, ESP.getFreeHeap()); + int codec_mode = profile_to_codec2_mode(_preferred_profile); + if (codec_mode < 0) codec_mode = CODEC2_MODE_700C; if (!_lxst_audio) { _lxst_audio = new LXSTAudio(); } lxst_breadcrumb(21, ESP.getFreeHeap()); - if (!_lxst_audio->init(CODEC2_MODE_3200)) { + if (!_lxst_audio->init(codec_mode)) { WARNING("LXST: Audio init failed"); call_ended(); return; @@ -1364,9 +1392,11 @@ void UIManager::call_process_signal(uint8_t signal) { _call_screen->set_state(CallScreen::CallState::ACTIVE); lxst_breadcrumb(24, ESP.getFreeHeap()); + int codec_mode = profile_to_codec2_mode(_preferred_profile); + if (codec_mode < 0) codec_mode = CODEC2_MODE_700C; if (!_lxst_audio) { _lxst_audio = new LXSTAudio(); - if (!_lxst_audio->init(CODEC2_MODE_3200)) { + if (!_lxst_audio->init(codec_mode)) { WARNING("LXST: Audio init failed"); call_ended(); return; @@ -1730,10 +1760,14 @@ void UIManager::call_answer() { _lxst_audio = new LXSTAudio(); } lxst_breadcrumb(31, ESP.getFreeHeap()); - if (!_lxst_audio->init(CODEC2_MODE_3200)) { - WARNING("LXST: Audio init failed"); - call_ended(); - return; + { + int codec_mode = profile_to_codec2_mode(_preferred_profile); + if (codec_mode < 0) codec_mode = CODEC2_MODE_700C; + if (!_lxst_audio->init(codec_mode)) { + WARNING("LXST: Audio init failed"); + call_ended(); + return; + } } lxst_breadcrumb(32, ESP.getFreeHeap()); @@ -1743,8 +1777,9 @@ void UIManager::call_answer() { } lxst_breadcrumb(33, ESP.getFreeHeap()); - // Send profile preference: LBW (Codec2 3200bps) — answerer sends last and "wins" - call_send_signal(LXST_PREFERRED_PROFILE + LXST_PROFILE_LBW); + // Send profile preference (default ULBW = Codec2-700C). Answerer + // sends last and wins. + call_send_signal(LXST_PREFERRED_PROFILE + _preferred_profile); // Send STATUS_ESTABLISHED call_send_signal(LXST_STATUS_ESTABLISHED); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 2b62d9e7..d31121dc 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -193,6 +193,16 @@ public: /** Hang up the active call (calls private call_hangup). */ void test_call_hangup() { call_hangup(); } + /** + * Programmatic answer for incoming calls. Sets the same + * _call_answer_pending flag the UI button does so the main loop + * picks it up and runs call_answer() in its proper context. Used + * by the harness for pyxis-as-callee interop tests with real + * LXST.Telephony.Telephone clients (Sideband, MeshChatX). Returns + * true if there was an incoming ring to accept. + */ + bool test_call_answer(); + /** * String name of the current call state, eg "IDLE", "ACTIVE", * "INCOMING_RINGING". Stable for harness assertions. @@ -232,6 +242,15 @@ public: * bidirectional content-fidelity validation. */ void test_call_set_inject_sine(bool enabled, int freq = 1000, float amp = 0.5f); + + /** + * Get/set the preferred Codec2 profile pyxis advertises and uses + * for the next call. Valid values: LXST_PROFILE_ULBW (0x10, + * Codec2-700C, LoRa-friendly default), LXST_PROFILE_VLBW (0x20, + * Codec2-1600), LXST_PROFILE_LBW (0x30, Codec2-3200, used pre-2026). + */ + int test_call_get_profile() const { return _preferred_profile; } + bool test_call_set_profile(int profile); #endif private: @@ -311,9 +330,20 @@ private: // LXST profile negotiation static constexpr int LXST_PREFERRED_PROFILE = 0xFF; + static constexpr int LXST_PROFILE_ULBW = 0x10; // Codec2 700C (~700 bps) — LoRa-friendly default static constexpr int LXST_PROFILE_VLBW = 0x20; // Codec2 1600bps static constexpr int LXST_PROFILE_LBW = 0x30; // Codec2 3200bps + // The profile we ASK the remote for and CONFIGURE locally on every + // new call. Defaults to ULBW (Codec2-700C) since pyxis is targeted + // at LoRa where 3200 bps would saturate even SF7 BW125. Test + // harness can override via T:CALL_PROFILE. + static int _preferred_profile; + + // Map profile byte to the Codec2 library mode constant + // (CODEC2_MODE_*). Returns -1 for unknown profiles. + static int profile_to_codec2_mode(int profile); + enum class CallState { IDLE, PATH_REQUESTING, // Outgoing: waiting for path to resolve diff --git a/src/main.cpp b/src/main.cpp index 3246c2da..4ae78d1c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1830,6 +1830,17 @@ static void handle_test_hook_command(const String& line) { ui_manager->test_call_hangup(); Serial.println("T:OK hung_up"); } + else if (cmd == "T:CALL_ANSWER") { + // T:CALL_ANSWER — accept an incoming ring. Only valid when state + // is INCOMING_RINGING. Used by the harness for pyxis-as-callee + // interop tests against real LXST.Telephony.Telephone clients. + if (!ui_manager) { Serial.println("T:ERR no ui_manager"); return; } + if (!ui_manager->test_call_answer()) { + Serial.println("T:ERR not_ringing"); + return; + } + Serial.println("T:OK answered"); + } else if (cmd == "T:CALL_STATS") { // T:CALL_STATS — return audio frame counters for the most recent // call. tx = frames sent over the wire (encoded by capture path), @@ -1863,6 +1874,27 @@ static void handle_test_hook_command(const String& line) { Serial.print(" state="); Serial.println(ui_manager->test_call_state_name()); } + else if (cmd == "T:CALL_PROFILE") { + // T:CALL_PROFILE [hex] — get/set pyxis's preferred Codec2 profile. + // No arg: print current. With arg: set. + // Valid: 0x10 (ULBW/700C), 0x20 (VLBW/1600), 0x30 (LBW/3200). + if (!ui_manager) { Serial.println("T:ERR no ui_manager"); return; } + if (args.length() == 0) { + int p = ui_manager->test_call_get_profile(); + Serial.print("T:OK profile=0x"); + if (p < 16) Serial.print("0"); + Serial.println(String(p, HEX)); + return; + } + int profile = (int)strtol(args.c_str(), nullptr, 0); + if (!ui_manager->test_call_set_profile(profile)) { + Serial.println("T:ERR unknown profile"); + return; + } + Serial.print("T:OK profile=0x"); + if (profile < 16) Serial.print("0"); + Serial.println(String(profile, HEX)); + } else if (cmd == "T:CALL_INJECT") { // T:CALL_INJECT [freq_hz] [amp_pct] // Replace mic capture with a synthesized sine wave for the