From f73fbb256866f132a7046e30795302bb0c03379b Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Thu, 19 Feb 2026 21:00:07 -0500 Subject: [PATCH] Fix LXST voice call interop with Python LXST/Columba - Fix thread safety: defer Reticulum link callbacks (packet, link_closed) to call_update() which runs under LVGL lock, preventing crashes from concurrent LVGL access across cores - Fix outgoing call signal handling: store link reference and re-register packet/link_closed callbacks in on_call_link_established so signals are actually received - Fix call answer screen freeze: update UI before blocking audio init (I2S/ES7210/Codec2 setup) so screen renders immediately - Fix audio direction: use startPlayback() (speaker RX) instead of startCapture() (mic TX) so received audio is actually heard - Add msgpack wire format for LXST signalling and audio frames - Add LXST IN destination for receiving calls + announce support - Add incoming call UI (Answer/Reject buttons) on CallScreen - Add path request before outgoing call link establishment - Add LXST announce handler registration Co-Authored-By: Claude Opus 4.6 --- lib/tdeck_ui/UI/LXMF/CallScreen.cpp | 40 ++- lib/tdeck_ui/UI/LXMF/CallScreen.h | 12 +- lib/tdeck_ui/UI/LXMF/UIManager.cpp | 478 +++++++++++++++++++++------- lib/tdeck_ui/UI/LXMF/UIManager.h | 21 +- src/main.cpp | 8 + 5 files changed, 444 insertions(+), 115 deletions(-) diff --git a/lib/tdeck_ui/UI/LXMF/CallScreen.cpp b/lib/tdeck_ui/UI/LXMF/CallScreen.cpp index 9116ec9a..7143b4fc 100644 --- a/lib/tdeck_ui/UI/LXMF/CallScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/CallScreen.cpp @@ -111,10 +111,27 @@ void CallScreen::set_state(CallState state) { case CallState::RINGING: lv_label_set_text(_label_state, "Ringing..."); break; - case CallState::ACTIVE: + case CallState::INCOMING_RINGING: { + lv_label_set_text(_label_state, LV_SYMBOL_CALL " Incoming Call"); + lv_label_set_text(_label_duration, ""); + // Repurpose mute button as Answer + lv_label_set_text(_label_mute, LV_SYMBOL_OK " Answer"); + lv_obj_set_style_bg_color(_btn_mute, lv_color_hex(0x2E7D32), 0); // Green + // Change hangup label to Reject + lv_obj_t* hangup_label = lv_obj_get_child(_btn_hangup, 0); + if (hangup_label) lv_label_set_text(hangup_label, LV_SYMBOL_CLOSE " Reject"); + break; + } + case CallState::ACTIVE: { lv_label_set_text(_label_state, LV_SYMBOL_CALL " In Call"); lv_obj_set_style_text_color(_label_state, Theme::success(), 0); + // Restore button labels (may have been Answer/Reject for incoming) + lv_label_set_text(_label_mute, _muted ? LV_SYMBOL_MUTE " Muted" : LV_SYMBOL_AUDIO " Mute"); + lv_obj_set_style_bg_color(_btn_mute, _muted ? Theme::warning() : Theme::btnSecondary(), 0); + lv_obj_t* hangup_label = lv_obj_get_child(_btn_hangup, 0); + if (hangup_label) lv_label_set_text(hangup_label, LV_SYMBOL_CLOSE " End"); break; + } case CallState::ENDED: lv_label_set_text(_label_state, "Call Ended"); lv_obj_set_style_text_color(_label_state, Theme::textSecondary(), 0); @@ -132,6 +149,8 @@ void CallScreen::set_duration(uint32_t seconds) { void CallScreen::set_muted(bool muted) { _muted = muted; + // Don't change button appearance during incoming call (button shows "Answer") + if (_state == CallState::INCOMING_RINGING) return; if (muted) { lv_label_set_text(_label_mute, LV_SYMBOL_MUTE " Muted"); lv_obj_set_style_bg_color(_btn_mute, Theme::warning(), 0); @@ -149,6 +168,10 @@ void CallScreen::set_mute_callback(MuteCallback callback) { _mute_callback = callback; } +void CallScreen::set_answer_callback(AnswerCallback callback) { + _answer_callback = callback; +} + void CallScreen::show() { lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(_screen); @@ -157,7 +180,12 @@ void CallScreen::show() { if (group) { if (_btn_mute) lv_group_add_obj(group, _btn_mute); if (_btn_hangup) lv_group_add_obj(group, _btn_hangup); - lv_group_focus_obj(_btn_hangup); + // Focus answer button for incoming calls, hangup for outgoing + if (_state == CallState::INCOMING_RINGING && _btn_mute) { + lv_group_focus_obj(_btn_mute); + } else { + lv_group_focus_obj(_btn_hangup); + } } } @@ -184,7 +212,13 @@ void CallScreen::on_hangup_clicked(lv_event_t* event) { void CallScreen::on_mute_clicked(lv_event_t* event) { auto* self = static_cast(lv_event_get_user_data(event)); - if (self && self->_mute_callback) { + if (!self) return; + // In incoming call state, this button acts as "Answer" + if (self->_state == CallState::INCOMING_RINGING && self->_answer_callback) { + self->_answer_callback(); + return; + } + if (self->_mute_callback) { self->_muted = !self->_muted; self->set_muted(self->_muted); self->_mute_callback(self->_muted); diff --git a/lib/tdeck_ui/UI/LXMF/CallScreen.h b/lib/tdeck_ui/UI/LXMF/CallScreen.h index c64ab387..6a0572de 100644 --- a/lib/tdeck_ui/UI/LXMF/CallScreen.h +++ b/lib/tdeck_ui/UI/LXMF/CallScreen.h @@ -37,14 +37,16 @@ namespace LXMF { class CallScreen { public: enum class CallState { - CONNECTING, // Link being established - RINGING, // Waiting for remote to answer - ACTIVE, // Voice flowing - ENDED // Call ended (brief display before returning) + CONNECTING, // Link being established (outgoing) + RINGING, // Waiting for remote to answer (outgoing) + INCOMING_RINGING, // Incoming call, waiting for user to answer + ACTIVE, // Voice flowing + ENDED // Call ended (brief display before returning) }; using HangupCallback = std::function; using MuteCallback = std::function; + using AnswerCallback = std::function; CallScreen(lv_obj_t* parent = nullptr); ~CallScreen(); @@ -63,6 +65,7 @@ public: void set_hangup_callback(HangupCallback callback); void set_mute_callback(MuteCallback callback); + void set_answer_callback(AnswerCallback callback); void show(); void hide(); @@ -82,6 +85,7 @@ private: HangupCallback _hangup_callback; MuteCallback _mute_callback; + AnswerCallback _answer_callback; void create_ui(); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 4ab2d4ed..da06e7e3 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -12,6 +12,8 @@ #include "../LVGL/LVGLLock.h" #include "lxst_audio.h" #include "Packet.h" +#include "Transport.h" +#include "Destination.h" using namespace RNS; @@ -26,6 +28,17 @@ namespace LXMF { // Static singleton for Link callbacks UIManager* UIManager::s_call_instance = nullptr; +// LXST announce handler — tracks peers that support voice calls +class LXSTAnnounceHandler : public AnnounceHandler { +public: + LXSTAnnounceHandler() : AnnounceHandler("lxst.telephony") {} + void received_announce(const Bytes& dest_hash, const Identity& identity, const Bytes& app_data) override { + std::string hash_hex = dest_hash.toHex().substr(0, 16); + INFO(("LXST: Voice announce from " + hash_hex + "...").c_str()); + } +}; +static std::shared_ptr s_lxst_announce_handler; + UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::MessageStore& store) : _reticulum(reticulum), _router(router), _store(store), _current_screen(SCREEN_CONVERSATION_LIST), @@ -45,7 +58,10 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::Me _lxst_audio(nullptr), _call_start_ms(0), _call_timeout_ms(0), - _call_muted(false) { + _call_muted(false), + _call_answer_pending(false), + _call_link_closed_pending(false), + _call_signal_pending(0xFF) { } UIManager::~UIManager() { @@ -220,10 +236,29 @@ bool UIManager::init() { [this](::LXMF::LXMessage& message) { on_message_received(message); } ); + // Set up answer callback for incoming calls (deferred to main loop) + _call_screen->set_answer_callback( + [this]() { _call_answer_pending = true; } + ); + // Load conversations and show conversation list _conversation_list_screen->load_conversations(_store); show_conversation_list(); + // Create LXST IN destination for incoming voice calls + _lxst_destination = Destination(_router.identity(), Type::Destination::IN, + Type::Destination::SINGLE, "lxst", "telephony"); + _lxst_destination.set_proof_strategy(Type::Destination::PROVE_NONE); + _lxst_destination.set_link_established_callback(on_lxst_link_established); + s_call_instance = this; + + // Register LXST announce handler + s_lxst_announce_handler = std::make_shared(); + Transport::register_announce_handler(HAnnounceHandler(s_lxst_announce_handler)); + + std::string lxst_hash = _lxst_destination.hash().toHex(); + INFO(("LXST: Listening on " + lxst_hash).c_str()); + _initialized = true; INFO("UIManager initialized"); @@ -695,6 +730,10 @@ static void lxst_breadcrumb(uint8_t step, uint32_t heap) { } void UIManager::call_initiate(const Bytes& peer_hash) { + { + std::string h = peer_hash.toHex().substr(0, 16); + INFO(("LXST: Initiating call to " + h + "...").c_str()); + } lxst_breadcrumb(1, ESP.getFreeHeap()); // Check heap before attempting — Link establishment needs ~10KB for crypto @@ -738,13 +777,29 @@ void UIManager::call_initiate(const Bytes& peer_hash) { lxst_breadcrumb(5, ESP.getFreeHeap()); + { + std::string dh = peer_dest.hash().toHex().substr(0, 16); + bool has_path = Transport::has_path(peer_dest.hash()); + char buf[80]; + snprintf(buf, sizeof(buf), "LXST: Dest hash=%s path=%s", dh.c_str(), has_path ? "yes" : "no"); + INFO(buf); + } + + // Request path if not cached + if (!Transport::has_path(peer_dest.hash())) { + INFO("LXST: No path to destination, requesting..."); + Transport::request_path(peer_dest.hash()); + } + // Establish Reticulum Link to peer's LXST destination + INFO("LXST: Creating link..."); _call_link = Link(peer_dest, on_call_link_established, on_call_link_closed); lxst_breadcrumb(6, ESP.getFreeHeap()); _call_state = CallState::LINK_ESTABLISHING; _call_timeout_ms = millis() + 30000; + INFO("LXST: Link establishing, 30s timeout"); lxst_breadcrumb(7, ESP.getFreeHeap()); } @@ -790,7 +845,9 @@ void UIManager::call_set_mute(bool muted) { void UIManager::call_send_signal(uint8_t signal) { if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return; - Bytes signal_data(&signal, 1); + // Msgpack: {0x00: [signal]} = fixmap(1) + key(0) + fixarray(1) + signal + uint8_t msgpack_buf[4] = { 0x81, 0x00, 0x91, signal }; + Bytes signal_data(msgpack_buf, 4); Packet packet(_call_link, signal_data); packet.send(); @@ -802,131 +859,183 @@ void UIManager::call_send_signal(uint8_t signal) { void UIManager::call_send_audio(const uint8_t* data, int length) { if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return; - // Prepend codec header byte: 0x02 = Codec2 + // Msgpack: {0x01: bin8(codec_header + frame_data)} uint8_t packet_buf[256]; - if (length + 1 > (int)sizeof(packet_buf)) return; + int total_len = 1 + length; // codec header + frame data + if (total_len > 250 || total_len < 1) return; - packet_buf[0] = LXST_CODEC_CODEC2; - memcpy(packet_buf + 1, data, length); + packet_buf[0] = 0x81; // fixmap(1) + packet_buf[1] = 0x01; // key: FIELD_FRAMES + packet_buf[2] = 0xC4; // bin8 + packet_buf[3] = (uint8_t)total_len; // length + packet_buf[4] = LXST_CODEC_CODEC2; // codec header + memcpy(packet_buf + 5, data, length); - Bytes audio_data(packet_buf, length + 1); + Bytes audio_data(packet_buf, 5 + length); Packet packet(_call_link, audio_data); packet.send(); } void UIManager::call_on_packet(const Bytes& data) { - if (data.size() == 0) return; + // NOTE: This runs on the Reticulum transport thread (during reticulum->loop()), + // NOT under the LVGL lock. Do NOT touch LVGL objects here. + // Signals are queued and processed in call_update() under the LVGL lock. + if (data.size() < 4) return; - uint8_t first_byte = data.data()[0]; + const uint8_t* buf = data.data(); - // Single-byte packets are signalling messages - if (data.size() == 1) { - char buf[48]; - snprintf(buf, sizeof(buf), "LXST: Received signal 0x%02X", first_byte); - DEBUG(buf); + // Expect msgpack fixmap(1): 0x81 + if (buf[0] != 0x81) { + char dbg[64]; + snprintf(dbg, sizeof(dbg), "LXST: Invalid packet (0x%02X, expected fixmap)", buf[0]); + DEBUG(dbg); + return; + } - switch (_call_state) { - case CallState::WAIT_AVAILABLE: - if (first_byte == LXST_STATUS_AVAILABLE) { - INFO("LXST: Remote is available, identifying..."); - _call_link.identify(_router.identity()); - _call_state = CallState::WAIT_RINGING; - _call_timeout_ms = millis() + 15000; - } else if (first_byte == LXST_STATUS_BUSY) { - INFO("LXST: Remote is busy"); - call_ended(); + uint8_t field = buf[1]; + + if (field == 0x00) { + // Signalling: {0x00: [signal]} = 81 00 91 XX + if (buf[2] != 0x91) return; + uint8_t signal = buf[3]; + + char dbg[48]; + snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal); + DEBUG(dbg); + + // Queue for processing in call_update() under LVGL lock + _call_signal_pending = signal; + + } else if (field == 0x01) { + // Audio: {0x01: bin8/bin16(codec_header + frame_data)} + // Audio buffer writes don't touch LVGL — safe to process here + size_t frame_offset; + size_t frame_len; + + if (buf[2] == 0xC4) { + // bin8 + if (data.size() < 5) return; + frame_len = buf[3]; + frame_offset = 4; + } else if (buf[2] == 0xC5) { + // bin16 + if (data.size() < 6) return; + frame_len = ((size_t)buf[3] << 8) | buf[4]; + frame_offset = 5; + } else { + return; + } + + if (data.size() < frame_offset + frame_len || frame_len < 2) return; + + uint8_t codec = buf[frame_offset]; + const uint8_t* frame_data = buf + frame_offset + 1; + size_t frame_data_len = frame_len - 1; + + if ((_call_state == CallState::ACTIVE || _call_state == CallState::CONNECTING) + && codec == LXST_CODEC_CODEC2 && _lxst_audio) { + if (_lxst_audio->state() == LXSTAudio::State::PLAYING) { + _lxst_audio->writeEncodedPacket(frame_data, frame_data_len); + } + } + } +} + +// 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); + + switch (_call_state) { + case CallState::WAIT_AVAILABLE: + if (signal == LXST_STATUS_AVAILABLE) { + INFO("LXST: Remote is available, identifying..."); + _call_link.identify(_router.identity()); + _call_state = CallState::WAIT_RINGING; + _call_timeout_ms = millis() + 15000; + } else if (signal == LXST_STATUS_BUSY) { + INFO("LXST: Remote is busy"); + call_ended(); + } + break; + + case CallState::WAIT_RINGING: + if (signal == LXST_STATUS_RINGING) { + INFO("LXST: Remote is ringing"); + _call_state = CallState::RINGING; + _call_timeout_ms = millis() + 60000; + _call_screen->set_state(CallScreen::CallState::RINGING); + } else if (signal == LXST_STATUS_BUSY || signal == LXST_STATUS_REJECTED) { + INFO("LXST: Call rejected or busy"); + call_ended(); + } + break; + + case CallState::RINGING: + if (signal == LXST_STATUS_CONNECTING) { + INFO("LXST: Remote is connecting audio..."); + _call_state = CallState::CONNECTING; + + if (!_lxst_audio) { + _lxst_audio = new LXSTAudio(); } - break; - - case CallState::WAIT_RINGING: - if (first_byte == LXST_STATUS_RINGING) { - INFO("LXST: Remote is ringing"); - _call_state = CallState::RINGING; - _call_timeout_ms = millis() + 60000; // 60s ring timeout - _call_screen->set_state(CallScreen::CallState::RINGING); - } else if (first_byte == LXST_STATUS_BUSY || first_byte == LXST_STATUS_REJECTED) { - INFO("LXST: Call rejected or busy"); + if (!_lxst_audio->init(CODEC2_MODE_1600)) { + WARNING("LXST: Audio init failed"); call_ended(); + return; + } + // Start playback (RX) so we can hear the remote + if (!_lxst_audio->startPlayback()) { + WARNING("LXST: Playback start failed"); } - break; - case CallState::RINGING: - if (first_byte == LXST_STATUS_CONNECTING) { - INFO("LXST: Remote is connecting audio..."); - _call_state = CallState::CONNECTING; + } else if (signal == LXST_STATUS_ESTABLISHED) { + INFO("LXST: Call established!"); + _call_state = CallState::ACTIVE; + _call_start_ms = millis(); + _call_screen->set_state(CallScreen::CallState::ACTIVE); - // Initialize audio pipeline - if (!_lxst_audio) { - _lxst_audio = new LXSTAudio(); - } + if (!_lxst_audio) { + _lxst_audio = new LXSTAudio(); if (!_lxst_audio->init(CODEC2_MODE_1600)) { WARNING("LXST: Audio init failed"); call_ended(); return; } - // Start capture (TX) — we're the caller, start talking - _lxst_audio->startCapture(); - _lxst_audio->setCaptureMute(_call_muted); - - } else if (first_byte == LXST_STATUS_ESTABLISHED) { - INFO("LXST: Call established!"); - _call_state = CallState::ACTIVE; - _call_start_ms = millis(); - _call_screen->set_state(CallScreen::CallState::ACTIVE); - - // Ensure audio is running - if (!_lxst_audio) { - _lxst_audio = new LXSTAudio(); - if (!_lxst_audio->init(CODEC2_MODE_1600)) { - WARNING("LXST: Audio init failed"); - call_ended(); - return; - } + } + if (_lxst_audio->state() != LXSTAudio::State::PLAYING) { + if (!_lxst_audio->startPlayback()) { + WARNING("LXST: Playback start failed"); } - // Start capture if not already capturing - if (_lxst_audio->state() != LXSTAudio::State::CAPTURING) { - _lxst_audio->startCapture(); - _lxst_audio->setCaptureMute(_call_muted); - } - - } else if (first_byte == LXST_STATUS_REJECTED) { - INFO("LXST: Call rejected"); - call_ended(); } - break; + INFO("LXST: Call active (caller, RX mode)"); - case CallState::CONNECTING: - if (first_byte == LXST_STATUS_ESTABLISHED) { - INFO("LXST: Call established!"); - _call_state = CallState::ACTIVE; - _call_start_ms = millis(); - _call_screen->set_state(CallScreen::CallState::ACTIVE); - } - break; - - default: - break; - } - return; - } - - // Multi-byte packets are audio frames: [codec_header] + [encoded_data] - if (_call_state == CallState::ACTIVE || _call_state == CallState::CONNECTING) { - if (first_byte == LXST_CODEC_CODEC2 && data.size() > 1) { - // Switch to playback mode if we're capturing and receive audio - // (half-duplex: for now just write to playback buffer) - if (_lxst_audio) { - // If not yet playing, start playback (switches from capture) - if (_lxst_audio->state() == LXSTAudio::State::CAPTURING) { - // For half-duplex PTT: stay in capture mode, don't switch - // The remote is transmitting, we buffer but don't play yet - // TODO: For full-duplex, start playback here - } - if (_lxst_audio->state() == LXSTAudio::State::PLAYING) { - _lxst_audio->writeEncodedPacket(data.data() + 1, data.size() - 1); - } + } else if (signal == LXST_STATUS_REJECTED) { + INFO("LXST: Call rejected"); + call_ended(); } - } + break; + + case CallState::CONNECTING: + if (signal == LXST_STATUS_ESTABLISHED) { + INFO("LXST: Call established!"); + _call_state = CallState::ACTIVE; + _call_start_ms = millis(); + _call_screen->set_state(CallScreen::CallState::ACTIVE); + + // Ensure playback is running + if (_lxst_audio && _lxst_audio->state() != LXSTAudio::State::PLAYING) { + if (!_lxst_audio->startPlayback()) { + WARNING("LXST: Playback start failed"); + } + } + INFO("LXST: Call active (RX mode)"); + } + break; + + default: + break; } } @@ -961,6 +1070,44 @@ void UIManager::call_ended() { void UIManager::call_update() { uint32_t now = millis(); + // Process deferred link closed (set by Reticulum callback, consumed here under LVGL lock) + if (_call_link_closed_pending) { + _call_link_closed_pending = false; + call_ended(); + 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); + if (_call_state == CallState::IDLE) return; // Signal caused call to end + } + + // Process deferred answer (set by LVGL task, consumed here on main thread) + if (_call_answer_pending) { + _call_answer_pending = false; + call_answer(); + } + + // Show incoming call UI (deferred from link callback to LVGL-safe context) + if (_call_state == CallState::INCOMING_RINGING && _current_screen != SCREEN_CALL) { + _call_screen->set_peer(_call_peer_hash); + _call_screen->set_state(CallScreen::CallState::INCOMING_RINGING); + _call_screen->set_muted(false); + _call_screen->show(); + _current_screen = SCREEN_CALL; + + // Play notification tone + if (_settings_screen) { + const auto& settings = _settings_screen->get_settings(); + if (settings.notification_sound) { + Notification::tone_play(800, 200, settings.notification_volume); + } + } + } + // Check timeouts if (_call_timeout_ms > 0 && now > _call_timeout_ms) { switch (_call_state) { @@ -977,6 +1124,10 @@ void UIManager::call_update() { WARNING("LXST: Ring timed out (no answer)"); call_ended(); return; + case CallState::INCOMING_RINGING: + WARNING("LXST: Incoming call timed out (no answer)"); + call_ended(); + return; default: _call_timeout_ms = 0; // Clear timeout for active states break; @@ -1015,22 +1166,30 @@ void UIManager::call_update() { void UIManager::on_call_link_established(Link& link) { if (!s_call_instance) return; - INFO("LXST: Link established"); - // Register packet callback on the link - link.set_packet_callback(on_call_link_packet); + char buf[80]; + snprintf(buf, sizeof(buf), "LXST: Outgoing link established (status=%d)", (int)link.status()); + INFO(buf); + + // Update stored link with the established reference and register callbacks + s_call_instance->_call_link = link; + s_call_instance->_call_link.set_packet_callback(on_call_link_packet); + s_call_instance->_call_link.set_link_closed_callback(on_call_link_closed); // Transition to waiting for STATUS_AVAILABLE s_call_instance->_call_state = CallState::WAIT_AVAILABLE; s_call_instance->_call_timeout_ms = millis() + 10000; // 10s timeout + INFO("LXST: Waiting for STATUS_AVAILABLE (10s timeout)"); } void UIManager::on_call_link_closed(Link& link) { if (!s_call_instance) return; - WARNING("LXST: Link closed"); + WARNING("LXST: Link closed (deferred)"); + // Don't call call_ended() here — runs on Reticulum thread without LVGL lock. + // Defer to call_update() which runs under LVGL lock. if (s_call_instance->_call_state != CallState::IDLE) { - s_call_instance->call_ended(); + s_call_instance->_call_link_closed_pending = true; } } @@ -1039,6 +1198,111 @@ void UIManager::on_call_link_packet(const Bytes& plaintext, const Packet& packet s_call_instance->call_on_packet(plaintext); } +// ── LXST Incoming Call Callbacks ── + +void UIManager::on_lxst_link_established(Link& link) { + if (!s_call_instance) return; + auto* self = s_call_instance; + lxst_breadcrumb(10, ESP.getFreeHeap()); + INFO("LXST: Incoming link established"); + + if (self->_call_state != CallState::IDLE) { + // Already in a call — send busy directly on the new link + INFO("LXST: Busy, rejecting incoming link"); + uint8_t busy_buf[4] = { 0x81, 0x00, 0x91, LXST_STATUS_BUSY }; + Bytes busy_data(busy_buf, 4); + Packet pkt(link, busy_data); + pkt.send(); + link.teardown(); + return; + } + + // Accept the incoming link + lxst_breadcrumb(11, ESP.getFreeHeap()); + self->_call_link = link; + self->_call_muted = false; + + // Send STATUS_AVAILABLE + lxst_breadcrumb(12, ESP.getFreeHeap()); + self->call_send_signal(LXST_STATUS_AVAILABLE); + + // Wait for caller to identify themselves + lxst_breadcrumb(13, ESP.getFreeHeap()); + link.set_remote_identified_callback(on_lxst_caller_identified); + link.set_link_closed_callback(on_call_link_closed); + lxst_breadcrumb(14, ESP.getFreeHeap()); +} + +void UIManager::on_lxst_caller_identified(const Link& link, const Identity& identity) { + if (!s_call_instance) return; + auto* self = s_call_instance; + lxst_breadcrumb(15, ESP.getFreeHeap()); + + std::string hash_hex = identity.hash().toHex().substr(0, 16); + INFO(("LXST: Caller identified: " + hash_hex + "...").c_str()); + + // Store peer info + self->_call_peer_hash = identity.hash(); + + // Set packet callback for signalling + audio on this link + self->_call_link.set_packet_callback(on_call_link_packet); + + // Send STATUS_RINGING + self->call_send_signal(LXST_STATUS_RINGING); + + // Transition to incoming ringing — UI will be shown in call_update() + self->_call_state = CallState::INCOMING_RINGING; + self->_call_timeout_ms = millis() + 60000; // 60s ring timeout + lxst_breadcrumb(16, ESP.getFreeHeap()); +} + +void UIManager::call_answer() { + if (_call_state != CallState::INCOMING_RINGING) { + char buf[64]; + snprintf(buf, sizeof(buf), "LXST: call_answer() skipped, state=%d", (int)_call_state); + WARNING(buf); + return; + } + INFO("LXST: Answering incoming call"); + + // Update screen FIRST (before audio init which may block briefly) + _call_state = CallState::CONNECTING; + _call_screen->set_state(CallScreen::CallState::ACTIVE); + _call_screen->set_muted(_call_muted); + + // Send STATUS_CONNECTING + call_send_signal(LXST_STATUS_CONNECTING); + + // Initialize audio pipeline + if (!_lxst_audio) { + _lxst_audio = new LXSTAudio(); + } + if (!_lxst_audio->init(CODEC2_MODE_1600)) { + WARNING("LXST: Audio init failed"); + call_ended(); + return; + } + + // Start playback (RX) so we can hear the caller + if (!_lxst_audio->startPlayback()) { + WARNING("LXST: Playback start failed"); + } + + // Send STATUS_ESTABLISHED + call_send_signal(LXST_STATUS_ESTABLISHED); + + // Transition to active call + _call_state = CallState::ACTIVE; + _call_start_ms = millis(); + INFO("LXST: Call active (answerer, RX mode)"); +} + +void UIManager::announce_lxst() { + if (_lxst_destination) { + _lxst_destination.announce(); + } +} + } // namespace LXMF } // namespace UI diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 2aefb46c..a21a366f 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -149,6 +149,12 @@ public: */ void set_rns_status(bool connected, const String& server_name = ""); + /** + * Announce LXST voice call destination + * Called periodically from main loop + */ + void announce_lxst(); + /** * Handle incoming LXMF message * Called by LXMF router delivery callback @@ -184,6 +190,7 @@ private: RNS::Reticulum& _reticulum; ::LXMF::LXMRouter& _router; ::LXMF::MessageStore& _store; + RNS::Destination _lxst_destination; Screen _current_screen; RNS::Bytes _current_peer_hash; @@ -248,6 +255,7 @@ private: WAIT_AVAILABLE, // Outgoing: link up, waiting for STATUS_AVAILABLE WAIT_RINGING, // Outgoing: sent identify, waiting for STATUS_RINGING RINGING, // Outgoing: remote is ringing + INCOMING_RINGING, // Incoming: waiting for user to answer/reject CONNECTING, // Both: opening audio pipelines ACTIVE, // Both: voice flowing }; @@ -259,6 +267,9 @@ private: uint32_t _call_start_ms; // millis() when call became ACTIVE uint32_t _call_timeout_ms; // millis() deadline for current wait state 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 // Singleton instance pointer for static Link callbacks static UIManager* s_call_instance; @@ -269,18 +280,26 @@ private: void call_set_mute(bool muted); void call_update(); // Called from update() — pumps audio packets + state machine + // Process a received signalling byte (runs under LVGL lock in call_update) + void call_process_signal(uint8_t signal); + // Send a signalling byte over the call link void call_send_signal(uint8_t signal); // Send encoded audio packet over the call link void call_send_audio(const uint8_t* data, int length); - // Handle received packet on call link (signalling or audio) + // Handle received packet on call link (queues signals for call_update) void call_on_packet(const RNS::Bytes& data); // Transition to call ended and schedule return to chat void call_ended(); + // Incoming call callbacks (LXST IN destination) + static void on_lxst_link_established(RNS::Link& link); + static void on_lxst_caller_identified(const RNS::Link& link, const RNS::Identity& identity); + void call_answer(); + // Static Link callbacks (delegate to s_call_instance) static void on_call_link_established(RNS::Link& link); static void on_call_link_closed(RNS::Link& link); diff --git a/src/main.cpp b/src/main.cpp index 44b05ae7..8c3f9051 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1130,6 +1130,11 @@ void setup() { setup_ui_manager(); BOOT_PROFILE_END("ui_manager"); + // Send initial LXST voice destination announce + if (ui_manager) { + ui_manager->announce_lxst(); + } + // Register delivered callback to update message status in storage and UI router->register_delivered_callback([](LXMF::LXMessage& msg) { INFO(">>> APP DELIVERED CALLBACK ENTRY"); @@ -1281,6 +1286,9 @@ void loop() { (ble_interface && ble_interface->online()); if (router && has_online_interface) { router->announce(); + if (ui_manager) { + ui_manager->announce_lxst(); + } last_announce = millis(); INFO("Periodic announce sent (interval: " + std::to_string(app_settings.announce_interval) + "s)"); }