From 8f265da0bbe95c3b4c8c4db6ca45b8b5725667e7 Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Thu, 19 Feb 2026 16:41:40 -0500 Subject: [PATCH] LXST voice call UI and state machine - Add CallScreen with ring/active/ended states and call controls - Add call state machine to UIManager (link establish, identify, ring, answer) - Add call button to ChatScreen header - Add call initiate/hangup with Reticulum Link management - Add StatusScreen call status display Co-Authored-By: Claude Opus 4.6 --- lib/tdeck_ui/UI/LXMF/CallScreen.cpp | 197 ++++++++++++ lib/tdeck_ui/UI/LXMF/CallScreen.h | 96 ++++++ lib/tdeck_ui/UI/LXMF/ChatScreen.cpp | 30 +- lib/tdeck_ui/UI/LXMF/ChatScreen.h | 10 + lib/tdeck_ui/UI/LXMF/StatusScreen.cpp | 31 +- lib/tdeck_ui/UI/LXMF/StatusScreen.h | 1 + lib/tdeck_ui/UI/LXMF/UIManager.cpp | 442 ++++++++++++++++++++++++-- lib/tdeck_ui/UI/LXMF/UIManager.h | 73 ++++- 8 files changed, 851 insertions(+), 29 deletions(-) create mode 100644 lib/tdeck_ui/UI/LXMF/CallScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/CallScreen.h diff --git a/lib/tdeck_ui/UI/LXMF/CallScreen.cpp b/lib/tdeck_ui/UI/LXMF/CallScreen.cpp new file mode 100644 index 00000000..9116ec9a --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/CallScreen.cpp @@ -0,0 +1,197 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "CallScreen.h" + +#ifdef ARDUINO + +#include "Theme.h" +#include "../LVGL/LVGLInit.h" + +namespace UI { +namespace LXMF { + +CallScreen::CallScreen(lv_obj_t* parent) + : _screen(nullptr), _label_state(nullptr), _label_peer(nullptr), + _label_duration(nullptr), _btn_mute(nullptr), _label_mute(nullptr), + _btn_hangup(nullptr), _muted(false), _state(CallState::CONNECTING) { + + _screen = lv_obj_create(parent ? parent : lv_scr_act()); + lv_obj_set_size(_screen, 320, 240); + lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_pad_all(_screen, 0, 0); + + create_ui(); + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +CallScreen::~CallScreen() { + if (_screen) { + lv_obj_del(_screen); + } +} + +void CallScreen::create_ui() { + // State label (top area) - "Connecting...", "Ringing...", "In Call", "Call Ended" + _label_state = lv_label_create(_screen); + lv_label_set_text(_label_state, "Connecting..."); + lv_obj_set_style_text_color(_label_state, Theme::textSecondary(), 0); + lv_obj_set_style_text_font(_label_state, &lv_font_montserrat_16, 0); + lv_obj_align(_label_state, LV_ALIGN_TOP_MID, 0, 30); + + // Peer name/hash label + _label_peer = lv_label_create(_screen); + lv_label_set_text(_label_peer, ""); + lv_obj_set_style_text_color(_label_peer, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(_label_peer, &lv_font_montserrat_14, 0); + lv_obj_set_width(_label_peer, 280); + lv_obj_set_style_text_align(_label_peer, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(_label_peer, LV_LABEL_LONG_DOT); + lv_obj_align(_label_peer, LV_ALIGN_TOP_MID, 0, 60); + + // Duration timer label + _label_duration = lv_label_create(_screen); + lv_label_set_text(_label_duration, ""); + lv_obj_set_style_text_color(_label_duration, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(_label_duration, &lv_font_montserrat_16, 0); + lv_obj_align(_label_duration, LV_ALIGN_CENTER, 0, -10); + + // Button row at bottom + // Mute button (left) + _btn_mute = lv_btn_create(_screen); + lv_obj_set_size(_btn_mute, 100, 44); + lv_obj_align(_btn_mute, LV_ALIGN_BOTTOM_LEFT, 30, -30); + lv_obj_set_style_bg_color(_btn_mute, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(_btn_mute, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_set_style_bg_color(_btn_mute, Theme::surfaceElevated(), LV_STATE_FOCUSED); + lv_obj_set_style_radius(_btn_mute, 8, 0); + lv_obj_add_event_cb(_btn_mute, on_mute_clicked, LV_EVENT_CLICKED, this); + + _label_mute = lv_label_create(_btn_mute); + lv_label_set_text(_label_mute, LV_SYMBOL_AUDIO " Mute"); + lv_obj_set_style_text_color(_label_mute, Theme::textPrimary(), 0); + lv_obj_center(_label_mute); + + // Hangup button (right) - red + _btn_hangup = lv_btn_create(_screen); + lv_obj_set_size(_btn_hangup, 100, 44); + lv_obj_align(_btn_hangup, LV_ALIGN_BOTTOM_RIGHT, -30, -30); + lv_obj_set_style_bg_color(_btn_hangup, lv_color_hex(0xC62828), 0); // Dark red + lv_obj_set_style_bg_color(_btn_hangup, lv_color_hex(0xB71C1C), LV_STATE_PRESSED); + lv_obj_set_style_bg_color(_btn_hangup, lv_color_hex(0xD32F2F), LV_STATE_FOCUSED); + lv_obj_set_style_radius(_btn_hangup, 8, 0); + lv_obj_add_event_cb(_btn_hangup, on_hangup_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_hangup = lv_label_create(_btn_hangup); + lv_label_set_text(label_hangup, LV_SYMBOL_CLOSE " End"); + lv_obj_set_style_text_color(label_hangup, lv_color_white(), 0); + lv_obj_center(label_hangup); +} + +void CallScreen::set_peer(const RNS::Bytes& peer_hash, const String& display_name) { + if (display_name.length() > 0) { + char buf[64]; + snprintf(buf, sizeof(buf), "%s\n%.16s...", display_name.c_str(), + peer_hash.toHex().c_str()); + lv_label_set_text(_label_peer, buf); + } else { + lv_label_set_text(_label_peer, peer_hash.toHex().c_str()); + } +} + +void CallScreen::set_state(CallState state) { + _state = state; + switch (state) { + case CallState::CONNECTING: + lv_label_set_text(_label_state, "Connecting..."); + lv_label_set_text(_label_duration, ""); + break; + case CallState::RINGING: + lv_label_set_text(_label_state, "Ringing..."); + 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); + break; + case CallState::ENDED: + lv_label_set_text(_label_state, "Call Ended"); + lv_obj_set_style_text_color(_label_state, Theme::textSecondary(), 0); + break; + } +} + +void CallScreen::set_duration(uint32_t seconds) { + char buf[16]; + uint32_t m = seconds / 60; + uint32_t s = seconds % 60; + snprintf(buf, sizeof(buf), "%02lu:%02lu", (unsigned long)m, (unsigned long)s); + lv_label_set_text(_label_duration, buf); +} + +void CallScreen::set_muted(bool muted) { + _muted = muted; + if (muted) { + lv_label_set_text(_label_mute, LV_SYMBOL_MUTE " Muted"); + lv_obj_set_style_bg_color(_btn_mute, Theme::warning(), 0); + } else { + lv_label_set_text(_label_mute, LV_SYMBOL_AUDIO " Mute"); + lv_obj_set_style_bg_color(_btn_mute, Theme::btnSecondary(), 0); + } +} + +void CallScreen::set_hangup_callback(HangupCallback callback) { + _hangup_callback = callback; +} + +void CallScreen::set_mute_callback(MuteCallback callback) { + _mute_callback = callback; +} + +void CallScreen::show() { + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); + + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + 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); + } +} + +void CallScreen::hide() { + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); + + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + if (_btn_mute) lv_group_remove_obj(_btn_mute); + if (_btn_hangup) lv_group_remove_obj(_btn_hangup); + } +} + +lv_obj_t* CallScreen::get_object() { + return _screen; +} + +void CallScreen::on_hangup_clicked(lv_event_t* event) { + auto* self = static_cast(lv_event_get_user_data(event)); + if (self && self->_hangup_callback) { + self->_hangup_callback(); + } +} + +void CallScreen::on_mute_clicked(lv_event_t* event) { + auto* self = static_cast(lv_event_get_user_data(event)); + if (self && self->_mute_callback) { + self->_muted = !self->_muted; + self->set_muted(self->_muted); + self->_mute_callback(self->_muted); + } +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/CallScreen.h b/lib/tdeck_ui/UI/LXMF/CallScreen.h new file mode 100644 index 00000000..c64ab387 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/CallScreen.h @@ -0,0 +1,96 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_CALLSCREEN_H +#define UI_LXMF_CALLSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include "Bytes.h" + +namespace UI { +namespace LXMF { + +/** + * Call Screen + * + * Shows voice call status and controls: + * - Peer name/hash + * - Call state (connecting, ringing, active, ended) + * - Call duration timer + * - Mute toggle + * - Hangup button + * + * Layout: + * ┌─────────────────────────────────────┐ + * │ Calling... │ + * │ │ + * │ Alice (a1b2c3d4...) │ + * │ │ + * │ 00:00:32 │ + * │ │ + * │ [Mute] [Hangup] │ + * └─────────────────────────────────────┘ + */ +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) + }; + + using HangupCallback = std::function; + using MuteCallback = std::function; + + CallScreen(lv_obj_t* parent = nullptr); + ~CallScreen(); + + /** Set peer info for display */ + void set_peer(const RNS::Bytes& peer_hash, const String& display_name = ""); + + /** Update call state display */ + void set_state(CallState state); + + /** Update call duration display (seconds) */ + void set_duration(uint32_t seconds); + + /** Update mute button state */ + void set_muted(bool muted); + + void set_hangup_callback(HangupCallback callback); + void set_mute_callback(MuteCallback callback); + + void show(); + void hide(); + lv_obj_t* get_object(); + +private: + lv_obj_t* _screen; + lv_obj_t* _label_state; + lv_obj_t* _label_peer; + lv_obj_t* _label_duration; + lv_obj_t* _btn_mute; + lv_obj_t* _label_mute; + lv_obj_t* _btn_hangup; + + bool _muted; + CallState _state; + + HangupCallback _hangup_callback; + MuteCallback _mute_callback; + + void create_ui(); + + static void on_hangup_clicked(lv_event_t* event); + static void on_mute_clicked(lv_event_t* event); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_CALLSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp b/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp index d3aa6fdb..8bc34216 100644 --- a/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp @@ -20,7 +20,7 @@ namespace LXMF { ChatScreen::ChatScreen(lv_obj_t* parent) : _screen(nullptr), _header(nullptr), _message_list(nullptr), _input_area(nullptr), - _text_area(nullptr), _btn_send(nullptr), _btn_back(nullptr), + _text_area(nullptr), _btn_send(nullptr), _btn_back(nullptr), _btn_call(nullptr), _message_store(nullptr), _display_start_idx(0), _loading_more(false) { LVGL_LOCK(); @@ -85,7 +85,21 @@ void ChatScreen::create_header() { lv_obj_align(label_peer, LV_ALIGN_LEFT_MID, 60, 0); lv_obj_set_style_text_color(label_peer, Theme::textPrimary(), 0); lv_obj_set_style_text_font(label_peer, &lv_font_montserrat_16, 0); + lv_obj_set_width(label_peer, 195); + lv_label_set_long_mode(label_peer, LV_LABEL_LONG_DOT); + // Voice call button (right side of header) + _btn_call = lv_btn_create(_header); + lv_obj_set_size(_btn_call, 50, 28); + lv_obj_align(_btn_call, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_set_style_bg_color(_btn_call, Theme::successDark(), 0); + lv_obj_set_style_bg_color(_btn_call, Theme::successPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_call, on_call_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_call = lv_label_create(_btn_call); + lv_label_set_text(label_call, LV_SYMBOL_CALL); + lv_obj_center(label_call); + lv_obj_set_style_text_color(label_call, Theme::textPrimary(), 0); } void ChatScreen::create_message_list() { @@ -472,6 +486,10 @@ void ChatScreen::set_send_message_callback(SendMessageCallback callback) { _send_message_callback = callback; } +void ChatScreen::set_call_callback(CallCallback callback) { + _call_callback = callback; +} + void ChatScreen::show() { LVGL_LOCK(); lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); @@ -482,6 +500,7 @@ void ChatScreen::show() { lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { if (_btn_back) lv_group_add_obj(group, _btn_back); + if (_btn_call) lv_group_add_obj(group, _btn_call); if (_btn_send) lv_group_add_obj(group, _btn_send); // Focus on back button @@ -497,6 +516,7 @@ void ChatScreen::hide() { lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { if (_btn_back) lv_group_remove_obj(_btn_back); + if (_btn_call) lv_group_remove_obj(_btn_call); if (_btn_send) lv_group_remove_obj(_btn_send); } @@ -515,6 +535,14 @@ void ChatScreen::on_back_clicked(lv_event_t* event) { } } +void ChatScreen::on_call_clicked(lv_event_t* event) { + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + + if (screen->_call_callback) { + screen->_call_callback(); + } +} + void ChatScreen::on_send_clicked(lv_event_t* event) { ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); diff --git a/lib/tdeck_ui/UI/LXMF/ChatScreen.h b/lib/tdeck_ui/UI/LXMF/ChatScreen.h index 087916f5..41d1c16e 100644 --- a/lib/tdeck_ui/UI/LXMF/ChatScreen.h +++ b/lib/tdeck_ui/UI/LXMF/ChatScreen.h @@ -61,6 +61,7 @@ public: */ using BackCallback = std::function; using SendMessageCallback = std::function; + using CallCallback = std::function; /** * Create chat screen @@ -111,6 +112,12 @@ public: */ void set_send_message_callback(SendMessageCallback callback); + /** + * Set callback for voice call button + * @param callback Function to call when call button is pressed + */ + void set_call_callback(CallCallback callback); + /** * Show the screen */ @@ -135,6 +142,7 @@ private: lv_obj_t* _text_area; lv_obj_t* _btn_send; lv_obj_t* _btn_back; + lv_obj_t* _btn_call; RNS::Bytes _peer_hash; ::LXMF::MessageStore* _message_store; @@ -145,6 +153,7 @@ private: BackCallback _back_callback; SendMessageCallback _send_message_callback; + CallCallback _call_callback; // UI construction void create_header(); @@ -154,6 +163,7 @@ private: // Event handlers static void on_back_clicked(lv_event_t* event); + static void on_call_clicked(lv_event_t* event); static void on_send_clicked(lv_event_t* event); static void on_message_long_pressed(lv_event_t* event); static void on_copy_dialog_action(lv_event_t* event); diff --git a/lib/tdeck_ui/UI/LXMF/StatusScreen.cpp b/lib/tdeck_ui/UI/LXMF/StatusScreen.cpp index 0d62bbd5..a2f9cf8c 100644 --- a/lib/tdeck_ui/UI/LXMF/StatusScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/StatusScreen.cpp @@ -18,7 +18,8 @@ namespace LXMF { StatusScreen::StatusScreen(lv_obj_t* parent) : _screen(nullptr), _header(nullptr), _content(nullptr), _btn_back(nullptr), - _btn_share(nullptr), _label_identity_value(nullptr), _label_lxmf_value(nullptr), + _btn_share(nullptr), _label_uptime(nullptr), + _label_identity_value(nullptr), _label_lxmf_value(nullptr), _label_wifi_status(nullptr), _label_wifi_ip(nullptr), _label_wifi_rssi(nullptr), _label_rns_status(nullptr), _label_ble_header(nullptr), _rns_connected(false), _ble_peer_count(0) { @@ -117,6 +118,12 @@ void StatusScreen::create_content() { lv_obj_set_scroll_dir(_content, LV_DIR_VER); lv_obj_set_scrollbar_mode(_content, LV_SCROLLBAR_MODE_AUTO); + // Uptime section + _label_uptime = lv_label_create(_content); + lv_label_set_text(_label_uptime, "Uptime: 0s"); + lv_obj_set_style_text_color(_label_uptime, Theme::textPrimary(), 0); + lv_obj_set_style_pad_bottom(_label_uptime, 8, 0); + // Identity section lv_obj_t* label_identity = lv_label_create(_content); lv_label_set_text(label_identity, "Identity:"); @@ -216,6 +223,28 @@ void StatusScreen::refresh() { } void StatusScreen::update_labels() { + // Update uptime + { + unsigned long ms = millis(); + unsigned long total_secs = ms / 1000; + unsigned long days = total_secs / 86400; + unsigned long hours = (total_secs % 86400) / 3600; + unsigned long mins = (total_secs % 3600) / 60; + unsigned long secs = total_secs % 60; + + char uptime_text[40]; + if (days > 0) { + snprintf(uptime_text, sizeof(uptime_text), "Uptime: %lud %luh %lum %lus", days, hours, mins, secs); + } else if (hours > 0) { + snprintf(uptime_text, sizeof(uptime_text), "Uptime: %luh %lum %lus", hours, mins, secs); + } else if (mins > 0) { + snprintf(uptime_text, sizeof(uptime_text), "Uptime: %lum %lus", mins, secs); + } else { + snprintf(uptime_text, sizeof(uptime_text), "Uptime: %lus", secs); + } + lv_label_set_text(_label_uptime, uptime_text); + } + // Update identity - use stack buffer to avoid String fragmentation if (_identity_hash.size() > 0) { lv_label_set_text(_label_identity_value, _identity_hash.toHex().c_str()); diff --git a/lib/tdeck_ui/UI/LXMF/StatusScreen.h b/lib/tdeck_ui/UI/LXMF/StatusScreen.h index 082ada03..98155c39 100644 --- a/lib/tdeck_ui/UI/LXMF/StatusScreen.h +++ b/lib/tdeck_ui/UI/LXMF/StatusScreen.h @@ -133,6 +133,7 @@ private: lv_obj_t* _btn_share; // Labels for dynamic content + lv_obj_t* _label_uptime; lv_obj_t* _label_identity_value; lv_obj_t* _label_lxmf_value; lv_obj_t* _label_wifi_status; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index a6d7f772..4ab2d4ed 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -10,6 +10,8 @@ #include "Log.h" #include "tone/Tone.h" #include "../LVGL/LVGLLock.h" +#include "lxst_audio.h" +#include "Packet.h" using namespace RNS; @@ -21,6 +23,9 @@ static const char* KEY_NODE_HASH = "node_hash"; namespace UI { namespace LXMF { +// Static singleton for Link callbacks +UIManager* UIManager::s_call_instance = nullptr; + UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::MessageStore& store) : _reticulum(reticulum), _router(router), _store(store), _current_screen(SCREEN_CONVERSATION_LIST), @@ -32,36 +37,33 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::Me _qr_screen(nullptr), _settings_screen(nullptr), _propagation_nodes_screen(nullptr), + _call_screen(nullptr), _propagation_manager(nullptr), _ble_interface(nullptr), - _initialized(false) { + _initialized(false), + _call_state(CallState::IDLE), + _lxst_audio(nullptr), + _call_start_ms(0), + _call_timeout_ms(0), + _call_muted(false) { } UIManager::~UIManager() { - if (_conversation_list_screen) { - delete _conversation_list_screen; - } - if (_chat_screen) { - delete _chat_screen; - } - if (_compose_screen) { - delete _compose_screen; - } - if (_announce_list_screen) { - delete _announce_list_screen; - } - if (_status_screen) { - delete _status_screen; - } - if (_qr_screen) { - delete _qr_screen; - } - if (_settings_screen) { - delete _settings_screen; - } - if (_propagation_nodes_screen) { - delete _propagation_nodes_screen; + // Clean up call state + if (_call_state != CallState::IDLE) { + call_hangup(); } + delete _lxst_audio; + + if (_conversation_list_screen) delete _conversation_list_screen; + if (_chat_screen) delete _chat_screen; + if (_compose_screen) delete _compose_screen; + if (_announce_list_screen) delete _announce_list_screen; + if (_status_screen) delete _status_screen; + if (_qr_screen) delete _qr_screen; + if (_settings_screen) delete _settings_screen; + if (_propagation_nodes_screen) delete _propagation_nodes_screen; + if (_call_screen) delete _call_screen; } bool UIManager::init() { @@ -81,6 +83,7 @@ bool UIManager::init() { _qr_screen = new QRScreen(); _settings_screen = new SettingsScreen(); _propagation_nodes_screen = new PropagationNodesScreen(); + _call_screen = new CallScreen(); // Set up callbacks for conversation list screen _conversation_list_screen->set_conversation_selected_callback( @@ -112,6 +115,10 @@ bool UIManager::init() { [this](const String& content) { on_send_message_from_chat(content); } ); + _chat_screen->set_call_callback( + [this]() { on_call_from_chat(); } + ); + // Set up callbacks for compose screen _compose_screen->set_cancel_callback( [this]() { on_cancel_compose(); } @@ -179,6 +186,15 @@ bool UIManager::init() { [this]() { on_propagation_sync(); } ); + // Set up callbacks for call screen + _call_screen->set_hangup_callback( + [this]() { call_hangup(); } + ); + + _call_screen->set_mute_callback( + [this](bool muted) { call_set_mute(muted); } + ); + // Load settings from NVS _settings_screen->load_settings(); @@ -222,6 +238,11 @@ void UIManager::update() { // Process inbound LXMF messages _router.process_inbound(); + // Pump voice call state machine + if (_call_state != CallState::IDLE) { + call_update(); + } + // Update status indicators (WiFi/battery) on conversation list static uint32_t last_status_update = 0; uint32_t now = millis(); @@ -249,6 +270,7 @@ void UIManager::show_conversation_list() { _status_screen->hide(); _settings_screen->hide(); _propagation_nodes_screen->hide(); + if (_call_screen) _call_screen->hide(); _current_screen = SCREEN_CONVERSATION_LIST; } @@ -269,6 +291,7 @@ void UIManager::show_chat(const Bytes& peer_hash) { _status_screen->hide(); _settings_screen->hide(); _propagation_nodes_screen->hide(); + if (_call_screen) _call_screen->hide(); _current_screen = SCREEN_CHAT; } @@ -414,6 +437,16 @@ void UIManager::on_send_message_from_chat(const String& content) { send_message(_current_peer_hash, content); } +void UIManager::on_call_from_chat() { + if (!_current_peer_hash) return; + if (_call_state != CallState::IDLE) { + WARNING("Already in a call"); + return; + } + + call_initiate(_current_peer_hash); +} + void UIManager::on_send_message_from_compose(const Bytes& dest_hash, const String& message) { send_message(dest_hash, message); @@ -642,9 +675,370 @@ void UIManager::refresh_current_screen() { case SCREEN_PROPAGATION_NODES: _propagation_nodes_screen->refresh(); break; + case SCREEN_CALL: + break; + case SCREEN_QR: + break; } } +// ── LXST Voice Call Implementation ── + +// NVS breadcrumb for crash debugging (survives reboot, unlike USB CDC output) +static void lxst_breadcrumb(uint8_t step, uint32_t heap) { + Preferences prefs; + prefs.begin("lxst_dbg", false); + prefs.putUChar("step", step); + prefs.putUInt("heap", heap); + prefs.putUInt("stack", (unsigned)uxTaskGetStackHighWaterMark(nullptr) * 4); + prefs.end(); +} + +void UIManager::call_initiate(const Bytes& peer_hash) { + lxst_breadcrumb(1, ESP.getFreeHeap()); + + // Check heap before attempting — Link establishment needs ~10KB for crypto + size_t free_heap = ESP.getFreeHeap(); + if (free_heap < 40000) { + char buf[64]; + snprintf(buf, sizeof(buf), "LXST: Insufficient heap (%u bytes), aborting call", (unsigned)free_heap); + WARNING(buf); + return; + } + + _call_peer_hash = peer_hash; + _call_muted = false; + s_call_instance = this; + + lxst_breadcrumb(2, ESP.getFreeHeap()); + + // Look up peer identity + Identity peer_identity = Identity::recall(peer_hash); + if (!peer_identity) { + WARNING("LXST: Peer identity not known, cannot establish link"); + s_call_instance = nullptr; + return; + } + + lxst_breadcrumb(3, ESP.getFreeHeap()); + + // Create LXST destination for the peer (aspect: lxst.telephony) + Destination peer_dest(peer_identity, Type::Destination::OUT, + Type::Destination::SINGLE, "lxst", "telephony"); + + lxst_breadcrumb(4, ESP.getFreeHeap()); + + // Show call screen + _call_screen->set_peer(peer_dest.hash()); + _call_screen->set_state(CallScreen::CallState::CONNECTING); + _call_screen->set_muted(false); + _call_screen->show(); + _chat_screen->hide(); + _current_screen = SCREEN_CALL; + + lxst_breadcrumb(5, ESP.getFreeHeap()); + + // Establish Reticulum Link to peer's LXST destination + _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; + + lxst_breadcrumb(7, ESP.getFreeHeap()); +} + +void UIManager::call_hangup() { + INFO("LXST: Hanging up"); + + // Stop audio + if (_lxst_audio) { + _lxst_audio->stopCapture(); + _lxst_audio->stopPlayback(); + _lxst_audio->deinit(); + delete _lxst_audio; + _lxst_audio = nullptr; + } + + // Teardown link + if (_call_link) { + _call_link.teardown(); + _call_link = Link(Type::NONE); + } + + _call_state = CallState::IDLE; + _call_peer_hash = Bytes(); + s_call_instance = nullptr; + + // Return to chat screen + if (_call_screen) { + _call_screen->set_state(CallScreen::CallState::ENDED); + } + + // Brief delay then return to conversation list + show_conversation_list(); +} + +void UIManager::call_set_mute(bool muted) { + _call_muted = muted; + if (_lxst_audio) { + _lxst_audio->setCaptureMute(muted); + } +} + +void UIManager::call_send_signal(uint8_t signal) { + if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return; + + Bytes signal_data(&signal, 1); + Packet packet(_call_link, signal_data); + packet.send(); + + char buf[48]; + snprintf(buf, sizeof(buf), "LXST: Sent signal 0x%02X", signal); + DEBUG(buf); +} + +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 + uint8_t packet_buf[256]; + if (length + 1 > (int)sizeof(packet_buf)) return; + + packet_buf[0] = LXST_CODEC_CODEC2; + memcpy(packet_buf + 1, data, length); + + Bytes audio_data(packet_buf, length + 1); + Packet packet(_call_link, audio_data); + packet.send(); +} + +void UIManager::call_on_packet(const Bytes& data) { + if (data.size() == 0) return; + + uint8_t first_byte = data.data()[0]; + + // 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); + + 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(); + } + 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"); + call_ended(); + } + break; + + case CallState::RINGING: + if (first_byte == LXST_STATUS_CONNECTING) { + INFO("LXST: Remote is connecting audio..."); + _call_state = CallState::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 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; + } + } + // 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; + + 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); + } + } + } + } +} + +void UIManager::call_ended() { + INFO("LXST: Call ended"); + + // Stop audio + if (_lxst_audio) { + _lxst_audio->stopCapture(); + _lxst_audio->stopPlayback(); + _lxst_audio->deinit(); + delete _lxst_audio; + _lxst_audio = nullptr; + } + + // Teardown link + if (_call_link) { + _call_link.teardown(); + _call_link = Link(Type::NONE); + } + + _call_state = CallState::IDLE; + _call_peer_hash = Bytes(); + s_call_instance = nullptr; + + _call_screen->set_state(CallScreen::CallState::ENDED); + + // Return to conversation list after brief display + show_conversation_list(); +} + +void UIManager::call_update() { + uint32_t now = millis(); + + // Check timeouts + if (_call_timeout_ms > 0 && now > _call_timeout_ms) { + switch (_call_state) { + case CallState::LINK_ESTABLISHING: + WARNING("LXST: Link establishment timed out"); + call_ended(); + return; + case CallState::WAIT_AVAILABLE: + case CallState::WAIT_RINGING: + WARNING("LXST: Call setup timed out"); + call_ended(); + return; + case CallState::RINGING: + WARNING("LXST: Ring timed out (no answer)"); + call_ended(); + return; + default: + _call_timeout_ms = 0; // Clear timeout for active states + break; + } + } + + // Check link health during active call + if (_call_state == CallState::ACTIVE) { + if (!_call_link || _call_link.status() == Type::Link::CLOSED) { + WARNING("LXST: Link closed during active call"); + call_ended(); + return; + } + + // Update duration display + uint32_t duration_secs = (now - _call_start_ms) / 1000; + _call_screen->set_duration(duration_secs); + + // Pump TX: read encoded packets from capture and send over link + if (_lxst_audio && _lxst_audio->state() == LXSTAudio::State::CAPTURING) { + uint8_t encoded_buf[64]; + int encoded_len = 0; + // Send up to a few packets per update cycle + for (int i = 0; i < 4; i++) { + if (_lxst_audio->readEncodedPacket(encoded_buf, sizeof(encoded_buf), &encoded_len)) { + call_send_audio(encoded_buf, encoded_len); + } else { + break; + } + } + } + } +} + +// ── Static Link Callbacks ── + +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); + + // Transition to waiting for STATUS_AVAILABLE + s_call_instance->_call_state = CallState::WAIT_AVAILABLE; + s_call_instance->_call_timeout_ms = millis() + 10000; // 10s timeout +} + +void UIManager::on_call_link_closed(Link& link) { + if (!s_call_instance) return; + WARNING("LXST: Link closed"); + + if (s_call_instance->_call_state != CallState::IDLE) { + s_call_instance->call_ended(); + } +} + +void UIManager::on_call_link_packet(const Bytes& plaintext, const Packet& packet) { + if (!s_call_instance) return; + s_call_instance->call_on_packet(plaintext); +} + } // namespace LXMF } // namespace UI diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 0b91fee9..2aefb46c 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -16,10 +16,14 @@ #include "QRScreen.h" #include "SettingsScreen.h" #include "PropagationNodesScreen.h" +#include "CallScreen.h" #include "LXMF/LXMRouter.h" #include "LXMF/PropagationNodeManager.h" #include "LXMF/MessageStore.h" #include "Reticulum.h" +#include "Link.h" + +class LXSTAudio; namespace UI { namespace LXMF { @@ -28,16 +32,18 @@ namespace LXMF { * UI Manager * * Manages all LXMF UI screens and coordinates between: - * - UI screens (ConversationList, Chat, Compose) + * - UI screens (ConversationList, Chat, Compose, Call) * - LXMF router (message sending/receiving) * - Message store (persistence) * - Reticulum (network layer) + * - LXST voice calls (audio pipeline + Reticulum Links) * * Responsibilities: * - Screen navigation * - Message delivery callbacks * - UI updates on message events * - Integration with LXMF router + * - Voice call state machine */ class UIManager { public: @@ -62,7 +68,7 @@ public: /** * Update UI (call periodically from main loop) - * Processes pending LXMF messages and updates UI + * Processes pending LXMF messages, updates UI, pumps voice call */ void update(); @@ -171,7 +177,8 @@ private: SCREEN_STATUS, SCREEN_QR, SCREEN_SETTINGS, - SCREEN_PROPAGATION_NODES + SCREEN_PROPAGATION_NODES, + SCREEN_CALL }; RNS::Reticulum& _reticulum; @@ -189,6 +196,7 @@ private: QRScreen* _qr_screen; SettingsScreen* _settings_screen; PropagationNodesScreen* _propagation_nodes_screen; + CallScreen* _call_screen; ::LXMF::PropagationNodeManager* _propagation_manager; RNS::Interface* _ble_interface; @@ -200,6 +208,7 @@ private: void on_new_message(); void on_back_to_conversation_list(); void on_send_message_from_chat(const String& content); + void on_call_from_chat(); void on_send_message_from_compose(const RNS::Bytes& dest_hash, const String& message); void on_cancel_compose(); void on_announce_selected(const RNS::Bytes& dest_hash); @@ -218,6 +227,64 @@ private: // UI updates void refresh_current_screen(); + + // ── LXST Voice Call ── + + // LXST signalling byte constants (matches Python LXST / LXST-kt) + static constexpr uint8_t LXST_STATUS_BUSY = 0x00; + static constexpr uint8_t LXST_STATUS_REJECTED = 0x01; + static constexpr uint8_t LXST_STATUS_CALLING = 0x02; + static constexpr uint8_t LXST_STATUS_AVAILABLE = 0x03; + static constexpr uint8_t LXST_STATUS_RINGING = 0x04; + static constexpr uint8_t LXST_STATUS_CONNECTING = 0x05; + static constexpr uint8_t LXST_STATUS_ESTABLISHED = 0x06; + + // LXST codec header byte + static constexpr uint8_t LXST_CODEC_CODEC2 = 0x02; + + enum class CallState { + IDLE, + LINK_ESTABLISHING, // Outgoing: waiting for Link to come up + WAIT_AVAILABLE, // Outgoing: link up, waiting for STATUS_AVAILABLE + WAIT_RINGING, // Outgoing: sent identify, waiting for STATUS_RINGING + RINGING, // Outgoing: remote is ringing + CONNECTING, // Both: opening audio pipelines + ACTIVE, // Both: voice flowing + }; + + CallState _call_state; + RNS::Bytes _call_peer_hash; + RNS::Link _call_link; + LXSTAudio* _lxst_audio; + uint32_t _call_start_ms; // millis() when call became ACTIVE + uint32_t _call_timeout_ms; // millis() deadline for current wait state + bool _call_muted; + + // Singleton instance pointer for static Link callbacks + static UIManager* s_call_instance; + + // Voice call methods + void call_initiate(const RNS::Bytes& peer_hash); + void call_hangup(); + void call_set_mute(bool muted); + void call_update(); // Called from update() — pumps audio packets + state machine + + // 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) + void call_on_packet(const RNS::Bytes& data); + + // Transition to call ended and schedule return to chat + void call_ended(); + + // 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); + static void on_call_link_packet(const RNS::Bytes& plaintext, const RNS::Packet& packet); }; } // namespace LXMF