Files
pyxis/lib/tdeck_ui/UI/LXMF/UIManager.cpp
davidcranor 827ff2eb42 Fix cross-platform build: replace ${PROJECT_DIR} with relative paths
platformio.ini:
- Replace -I${PROJECT_DIR}/lib, -I${PROJECT_DIR}/deps/... with relative
  paths (-Ilib, -Ideps/...) in both tdeck-bluedroid and tdeck environments;
  ${PROJECT_DIR} is mangled on Windows inside build_flags, causing include
  paths to resolve inside the PlatformIO builder directory instead of the
  project root
- Remove hardcoded -I.pio/libdeps/tdeck/TinyGPSPlus/src and
  -I.pio/libdeps/tdeck/NimBLE-Arduino/src; these paths reference generated
  cache, break on fresh clones, and are redundant with lib_ldf_mode = deep+
- Fix OTA upload_command: replace python3 with $PYTHONEXE so it resolves
  to PlatformIO's bundled Python on Windows, macOS, and Linux

src/main.cpp, lib/tdeck_ui/UI/LXMF/UIManager.cpp:
- Change #include "tone/Tone.h" to #include "Tone.h"; PlatformIO
  automatically adds -Ilib/tone for local libraries, making the
  subdirectory prefix unnecessary and broken when -Ilib is not effective

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:19:32 -05:00

1707 lines
56 KiB
C++

// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "UIManager.h"
#ifdef ARDUINO
#include <lvgl.h>
#include <Preferences.h>
#include "Log.h"
#include "Tone.h"
#include "../LVGL/LVGLLock.h"
#include "lxst_audio.h"
#include "Packet.h"
#include "Transport.h"
#include "Destination.h"
using namespace RNS;
// NVS keys for propagation settings
static const char* NVS_NAMESPACE = "propagation";
static const char* KEY_AUTO_SELECT = "auto_select";
static const char* KEY_NODE_HASH = "node_hash";
static const char* KEY_STAMP_COST = "stamp_cost";
namespace UI {
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<LXSTAnnounceHandler> 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),
_conversation_list_screen(nullptr),
_chat_screen(nullptr),
_compose_screen(nullptr),
_announce_list_screen(nullptr),
_status_screen(nullptr),
_qr_screen(nullptr),
_settings_screen(nullptr),
_propagation_nodes_screen(nullptr),
_call_screen(nullptr),
_propagation_manager(nullptr),
_ble_interface(nullptr),
_initialized(false),
_call_state(CallState::IDLE),
_lxst_audio(nullptr),
_call_start_ms(0),
_call_timeout_ms(0),
_call_muted(false),
_call_answer_pending(false),
_call_link_closed_pending(false),
_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() {
// 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() {
LVGL_LOCK();
if (_initialized) {
return true;
}
INFO("Initializing UIManager");
// Create all screens
_conversation_list_screen = new ConversationListScreen();
_chat_screen = new ChatScreen();
_compose_screen = new ComposeScreen();
_announce_list_screen = new AnnounceListScreen();
_status_screen = new StatusScreen();
_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(
[this](const Bytes& peer_hash) { on_conversation_selected(peer_hash); }
);
_conversation_list_screen->set_compose_callback(
[this]() { on_new_message(); }
);
_conversation_list_screen->set_sync_callback(
[this]() { on_propagation_sync(); }
);
_conversation_list_screen->set_settings_callback(
[this]() { show_settings(); }
);
_conversation_list_screen->set_announces_callback(
[this]() { show_announces(); }
);
// Set up callbacks for chat screen
_chat_screen->set_back_callback(
[this]() { on_back_to_conversation_list(); }
);
_chat_screen->set_send_message_callback(
[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(); }
);
_compose_screen->set_send_callback(
[this](const Bytes& dest_hash, const String& message) {
on_send_message_from_compose(dest_hash, message);
}
);
// Set up callbacks for announce list screen
_announce_list_screen->set_announce_selected_callback(
[this](const Bytes& dest_hash) { on_announce_selected(dest_hash); }
);
_announce_list_screen->set_back_callback(
[this]() { on_back_from_announces(); }
);
_announce_list_screen->set_send_announce_callback(
[this]() {
INFO("Sending LXMF announce...");
try {
_router.announce();
INFO("LXMF announce sent successfully");
} catch (const std::exception& e) {
ERRORF("LXMF announce failed: %s", e.what());
}
}
);
// Set up callbacks for status screen
_status_screen->set_back_callback(
[this]() { on_back_from_status(); }
);
_status_screen->set_share_callback(
[this]() { on_share_from_status(); }
);
// Set up callbacks for QR screen
_qr_screen->set_back_callback(
[this]() { on_back_from_qr(); }
);
// Set up callbacks for settings screen
_settings_screen->set_back_callback(
[this]() { on_back_from_settings(); }
);
_settings_screen->set_propagation_nodes_callback(
[this]() { show_propagation_nodes(); }
);
// Set up callbacks for propagation nodes screen
_propagation_nodes_screen->set_back_callback(
[this]() { on_back_from_propagation_nodes(); }
);
_propagation_nodes_screen->set_node_selected_callback(
[this](const Bytes& node_hash) { on_propagation_node_selected(node_hash); }
);
_propagation_nodes_screen->set_auto_select_changed_callback(
[this](bool enabled) { on_propagation_auto_select_changed(enabled); }
);
_propagation_nodes_screen->set_sync_callback(
[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();
// Restore propagation node selection from NVS
{
Preferences prefs;
prefs.begin(NVS_NAMESPACE, true);
bool auto_select = prefs.getBool(KEY_AUTO_SELECT, true);
uint8_t stamp_cost = prefs.getUChar(KEY_STAMP_COST, 0);
Bytes saved_hash;
size_t hash_len = prefs.getBytesLength(KEY_NODE_HASH);
if (hash_len > 0 && hash_len <= 32) {
uint8_t buf[32];
prefs.getBytes(KEY_NODE_HASH, buf, hash_len);
saved_hash = Bytes(buf, hash_len);
}
prefs.end();
if (!auto_select && saved_hash.size() > 0) {
_router.set_outbound_propagation_node(saved_hash);
_router.set_outbound_propagation_stamp_cost(stamp_cost);
INFO(("Restored propagation node from NVS: " + saved_hash.toHex().substr(0, 16) + "...").c_str());
}
}
// Set identity and LXMF address on settings screen
_settings_screen->set_identity_hash(_router.identity().hash());
_settings_screen->set_lxmf_address(_router.delivery_destination().hash());
// Set up callback for status button in conversation list
_conversation_list_screen->set_status_callback(
[this]() { show_status(); }
);
// Set identity hash and LXMF address on status screen
_status_screen->set_identity_hash(_router.identity().hash());
_status_screen->set_lxmf_address(_router.delivery_destination().hash());
// Set identity and LXMF address on QR screen
_qr_screen->set_identity(_router.identity());
_qr_screen->set_lxmf_address(_router.delivery_destination().hash());
// Register LXMF delivery callback
_router.register_delivery_callback(
[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<LXSTAnnounceHandler>();
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");
return true;
}
void UIManager::update() {
LVGL_LOCK();
// Process outbound LXMF messages
_router.process_outbound();
// 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();
if (now - last_status_update > 3000) { // Update every 3 seconds
last_status_update = now;
if (_conversation_list_screen) {
_conversation_list_screen->update_status();
}
// Update status screen if visible
if (_current_screen == SCREEN_STATUS && _status_screen) {
_status_screen->refresh();
}
}
}
void UIManager::show_conversation_list() {
LVGL_LOCK();
INFO("Showing conversation list");
_conversation_list_screen->refresh();
_conversation_list_screen->show();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
if (_call_screen) _call_screen->hide();
_current_screen = SCREEN_CONVERSATION_LIST;
}
void UIManager::show_chat(const Bytes& peer_hash) {
LVGL_LOCK();
std::string hash_hex = peer_hash.toHex().substr(0, 8);
std::string msg = "Showing chat with peer " + hash_hex + "...";
INFO(msg.c_str());
_current_peer_hash = peer_hash;
_chat_screen->load_conversation(peer_hash, _store);
_chat_screen->show();
_conversation_list_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
if (_call_screen) _call_screen->hide();
_current_screen = SCREEN_CHAT;
}
void UIManager::show_compose() {
LVGL_LOCK();
INFO("Showing compose screen");
_compose_screen->clear();
_compose_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
_current_screen = SCREEN_COMPOSE;
}
void UIManager::show_announces() {
LVGL_LOCK();
INFO("Showing announces screen");
_announce_list_screen->refresh();
_announce_list_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_status_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
_current_screen = SCREEN_ANNOUNCES;
}
void UIManager::show_status() {
LVGL_LOCK();
INFO("Showing status screen");
// Build propagation node display string
if (_propagation_manager) {
Preferences prefs;
prefs.begin(NVS_NAMESPACE, true);
bool auto_select = prefs.getBool(KEY_AUTO_SELECT, true);
Bytes saved_hash;
size_t hash_len = prefs.getBytesLength(KEY_NODE_HASH);
if (hash_len > 0 && hash_len <= 32) {
uint8_t buf[32];
prefs.getBytes(KEY_NODE_HASH, buf, hash_len);
saved_hash = Bytes(buf, hash_len);
}
prefs.end();
Bytes effective = auto_select ? _propagation_manager->get_effective_node() : saved_hash;
String display;
if (auto_select) {
if (effective.size() > 0) {
auto info = _propagation_manager->get_node(effective);
if (info && !info.name.empty()) {
display = "Auto (" + String(info.name.c_str()) + ")";
} else {
display = "Auto (" + String(effective.toHex().substr(0, 12).c_str()) + "...)";
}
} else {
display = "Auto";
}
} else {
if (effective.size() > 0) {
auto info = _propagation_manager->get_node(effective);
if (info && !info.name.empty()) {
display = String(info.name.c_str());
} else {
display = String(effective.toHex().substr(0, 12).c_str()) + "...";
}
} else {
display = "None";
}
}
_status_screen->set_propagation_node(display);
}
_status_screen->refresh();
_status_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
_current_screen = SCREEN_STATUS;
}
void UIManager::on_conversation_selected(const Bytes& peer_hash) {
show_chat(peer_hash);
}
void UIManager::on_new_message() {
show_compose();
}
void UIManager::show_settings() {
LVGL_LOCK();
INFO("Showing settings screen");
_settings_screen->refresh();
_settings_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
_propagation_nodes_screen->hide();
_current_screen = SCREEN_SETTINGS;
}
void UIManager::show_propagation_nodes() {
LVGL_LOCK();
INFO("Showing propagation nodes screen");
if (_propagation_manager) {
// Load settings from NVS
Preferences prefs;
prefs.begin(NVS_NAMESPACE, true); // read-only
bool auto_select = prefs.getBool(KEY_AUTO_SELECT, true);
Bytes selected_hash;
size_t hash_len = prefs.getBytesLength(KEY_NODE_HASH);
if (hash_len > 0 && hash_len <= 32) {
uint8_t buf[32];
prefs.getBytes(KEY_NODE_HASH, buf, hash_len);
selected_hash = Bytes(buf, hash_len);
}
prefs.end();
// If not auto-select and we have a saved hash, use it
if (!auto_select && selected_hash.size() > 0) {
_router.set_outbound_propagation_node(selected_hash);
}
_propagation_nodes_screen->load_nodes(*_propagation_manager, selected_hash, auto_select);
}
_propagation_nodes_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
_settings_screen->hide();
_current_screen = SCREEN_PROPAGATION_NODES;
}
void UIManager::set_propagation_node_manager(::LXMF::PropagationNodeManager* manager) {
_propagation_manager = manager;
}
void UIManager::set_lora_interface(Interface* iface) {
if (_conversation_list_screen) {
_conversation_list_screen->set_lora_interface(iface);
}
}
void UIManager::set_ble_interface(Interface* iface) {
_ble_interface = iface;
if (_conversation_list_screen) {
_conversation_list_screen->set_ble_interface(iface);
}
}
void UIManager::set_gps(TinyGPSPlus* gps) {
if (_conversation_list_screen) {
_conversation_list_screen->set_gps(gps);
}
}
void UIManager::on_back_to_conversation_list() {
show_conversation_list();
}
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);
// Switch to chat screen for this conversation
show_chat(dest_hash);
}
void UIManager::on_cancel_compose() {
show_conversation_list();
}
void UIManager::on_announce_selected(const Bytes& dest_hash) {
std::string hash_hex = dest_hash.toHex().substr(0, 8);
std::string msg = "Announce selected: " + hash_hex + "...";
INFO(msg.c_str());
// Go directly to chat screen with this destination
show_chat(dest_hash);
}
void UIManager::on_back_from_announces() {
show_conversation_list();
}
void UIManager::on_back_from_status() {
show_conversation_list();
}
void UIManager::on_share_from_status() {
LVGL_LOCK();
_status_screen->hide();
_qr_screen->show();
_current_screen = SCREEN_QR;
}
void UIManager::on_back_from_qr() {
LVGL_LOCK();
_qr_screen->hide();
_status_screen->show();
_current_screen = SCREEN_STATUS;
}
void UIManager::on_back_from_settings() {
show_conversation_list();
}
void UIManager::on_back_from_propagation_nodes() {
show_settings();
}
void UIManager::on_propagation_node_selected(const Bytes& node_hash) {
std::string hash_hex = node_hash.toHex().substr(0, 16);
std::string msg = "Propagation node selected: " + hash_hex + "...";
INFO(msg.c_str());
// Set the node in the router
_router.set_outbound_propagation_node(node_hash);
// Set stamp cost from node info if available
uint8_t stamp_cost = 0;
if (_propagation_manager) {
auto node_info = _propagation_manager->get_node(node_hash);
if (node_info) {
stamp_cost = node_info.stamp_cost;
}
}
_router.set_outbound_propagation_stamp_cost(stamp_cost);
// Proactively request path if we don't have one
if (!Transport::has_path(node_hash)) {
DEBUG("Requesting path for propagation node");
Transport::request_path(node_hash);
}
// Save to NVS
Preferences prefs;
prefs.begin(NVS_NAMESPACE, false);
prefs.putBool(KEY_AUTO_SELECT, false);
prefs.putBytes(KEY_NODE_HASH, node_hash.data(), node_hash.size());
prefs.putUChar(KEY_STAMP_COST, stamp_cost);
prefs.end();
DEBUG("Propagation node saved to NVS");
}
void UIManager::on_propagation_auto_select_changed(bool enabled) {
std::string msg = "Propagation auto-select changed: ";
msg += enabled ? "enabled" : "disabled";
INFO(msg.c_str());
if (enabled) {
// Clear manual selection, router will use best node
_router.set_outbound_propagation_node(Bytes());
}
// Save to NVS
Preferences prefs;
prefs.begin(NVS_NAMESPACE, false);
prefs.putBool(KEY_AUTO_SELECT, enabled);
if (enabled) {
prefs.remove(KEY_NODE_HASH);
prefs.remove(KEY_STAMP_COST);
}
prefs.end();
DEBUG("Propagation auto-select saved to NVS");
}
void UIManager::on_propagation_sync() {
INFO("Requesting messages from propagation node");
_router.request_messages_from_propagation_node();
}
void UIManager::set_rns_status(bool connected, const String& server_name) {
if (_status_screen) {
_status_screen->set_rns_status(connected, server_name);
}
}
void UIManager::send_message(const Bytes& dest_hash, const String& content) {
std::string hash_hex = dest_hash.toHex().substr(0, 8);
std::string msg = "Sending message to " + hash_hex + "...";
INFO(msg.c_str());
// Mark recipient as a persistent contact (survives reboot)
Identity::mark_persistent(dest_hash);
// Get our source destination (needed for signing)
Destination source = _router.delivery_destination();
// Create message content
Bytes content_bytes((const uint8_t*)content.c_str(), content.length());
Bytes title; // Empty title
// Look up destination identity
Identity dest_identity = Identity::recall(dest_hash);
// Create destination object - either real or placeholder
Destination destination(Type::NONE);
if (dest_identity) {
destination = Destination(dest_identity, Type::Destination::OUT, Type::Destination::SINGLE, "lxmf", "delivery");
INFO(" Destination identity known");
} else {
WARNING(" Destination identity not known, message may fail until peer announces");
}
// Create message with destination and source objects
// Source is needed for signing
::LXMF::LXMessage message(destination, source, content_bytes, title);
// If destination identity was unknown, manually set the destination hash
if (!dest_identity) {
message.destination_hash(dest_hash);
DEBUG(" Set destination hash manually");
}
// Pack the message to generate hash and signature before saving
message.pack();
// Add to UI immediately (optimistic update)
if (_current_screen == SCREEN_CHAT && _current_peer_hash == dest_hash) {
_chat_screen->add_message(message, true);
}
// Save to store (now has valid hash from pack())
_store.save_message(message);
// Queue for sending (pack already called, will use cached packed data)
_router.handle_outbound(message);
INFO(" Message queued for delivery");
}
void UIManager::on_message_received(::LXMF::LXMessage& message) {
LVGL_LOCK();
std::string source_hex = message.source_hash().toHex().substr(0, 8);
std::string msg = "Message received from " + source_hex + "...";
INFO(msg.c_str());
// Mark sender as a persistent contact (survives reboot)
RNS::Identity::mark_persistent(message.source_hash());
// Save to store
_store.save_message(message);
// Update UI if we're viewing this conversation
bool viewing_this_chat = (_current_screen == SCREEN_CHAT && _current_peer_hash == message.source_hash());
if (viewing_this_chat) {
_chat_screen->add_message(message, false);
}
// Play notification sound if enabled and not viewing this conversation
if (_settings_screen) {
const auto& settings = _settings_screen->get_settings();
if (settings.notification_sound && !viewing_this_chat) {
Notification::tone_play(1000, 100, settings.notification_volume); // 1kHz beep, 100ms
}
}
// Update conversation list unread count
// TODO: Track unread counts
_conversation_list_screen->refresh();
INFO(" Message processed");
}
void UIManager::on_message_delivered(::LXMF::LXMessage& message) {
LVGL_LOCK();
std::string hash_hex = message.hash().toHex().substr(0, 8);
std::string msg = "Message delivered: " + hash_hex + "...";
INFO(msg.c_str());
// Update UI if we're viewing this conversation
if (_current_screen == SCREEN_CHAT && _current_peer_hash == message.destination_hash()) {
_chat_screen->update_message_status(message.hash(), true);
}
}
void UIManager::on_message_failed(::LXMF::LXMessage& message) {
LVGL_LOCK();
std::string hash_hex = message.hash().toHex().substr(0, 8);
std::string msg = "Message delivery failed: " + hash_hex + "...";
WARNING(msg.c_str());
// Update UI if we're viewing this conversation
if (_current_screen == SCREEN_CHAT && _current_peer_hash == message.destination_hash()) {
_chat_screen->update_message_status(message.hash(), false);
}
}
void UIManager::refresh_current_screen() {
LVGL_LOCK();
switch (_current_screen) {
case SCREEN_CONVERSATION_LIST:
_conversation_list_screen->refresh();
break;
case SCREEN_CHAT:
_chat_screen->refresh();
break;
case SCREEN_COMPOSE:
// No refresh needed
break;
case SCREEN_ANNOUNCES:
_announce_list_screen->refresh();
break;
case SCREEN_STATUS:
_status_screen->refresh();
break;
case SCREEN_SETTINGS:
_settings_screen->refresh();
break;
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) {
{
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
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());
_call_dest_hash = peer_dest.hash();
_call_audio_rx_count = 0;
_call_audio_tx_count = 0;
_call_link_closed_pending = false;
_call_signal_write = 0;
_call_signal_read = 0;
{
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);
}
if (Transport::has_path(peer_dest.hash())) {
// Path known — create link immediately
INFO("LXST: Creating link...");
_call_link = Link(peer_dest, on_call_link_established, on_call_link_closed);
_call_state = CallState::LINK_ESTABLISHING;
_call_timeout_ms = millis() + 30000;
INFO("LXST: Link establishing, 30s timeout");
} else {
// Path unknown — request and wait for it in call_update()
INFO("LXST: No path, requesting (10s timeout)...");
Transport::request_path(peer_dest.hash());
_call_state = CallState::PATH_REQUESTING;
_call_timeout_ms = millis() + 10000;
}
lxst_breadcrumb(7, ESP.getFreeHeap());
}
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();
_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_peer_hash = Bytes();
// 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);
}
INFO(muted ? "LXST: Mic muted" : "LXST: Mic unmuted");
}
void UIManager::call_send_signal(int signal) {
if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return;
// Msgpack: {0x00: [signal]}
// fixmap(1) + key(0) + fixarray(1) + msgpack-encoded integer
uint8_t msgpack_buf[7];
int len;
msgpack_buf[0] = 0x81; // fixmap(1)
msgpack_buf[1] = 0x00; // key: FIELD_SIGNAL
msgpack_buf[2] = 0x91; // fixarray(1)
if (signal <= 0x7F) {
// fixint: single byte
msgpack_buf[3] = (uint8_t)signal;
len = 4;
} else if (signal <= 0xFF) {
// uint8: 0xCC + byte
msgpack_buf[3] = 0xCC;
msgpack_buf[4] = (uint8_t)signal;
len = 5;
} else {
// uint16: 0xCD + big-endian 2 bytes
msgpack_buf[3] = 0xCD;
msgpack_buf[4] = (uint8_t)(signal >> 8);
msgpack_buf[5] = (uint8_t)(signal & 0xFF);
len = 6;
}
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);
} 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,
int batch_count, int total_frames) {
if (!_call_link || _call_link.status() != Type::Link::ACTIVE) {
if (_call_audio_tx_count == 0) {
char dbg[64];
snprintf(dbg, sizeof(dbg), "LXST: TX drop: link=%p status=%d",
(void*)&_call_link, _call_link ? (int)_call_link.status() : -99);
WARNING(dbg);
}
return;
}
// Match LXST-kt (Columba) wire format exactly:
// {0x01: bin8(batch)} for single batch, or
// {0x01: fixarray(N)[bin8(b1), bin8(b2), ...]} for multiple batches.
// Each batch = [codec_type(0x02)] + [mode_header] + [10 * raw_codec2].
// Columba's native ring buffer expects exactly frameSamples (1600) decoded
// samples per writeEncodedPacket call. For Codec2 3200: 10 * 160 = 1600.
// batch_data contains batch_count concatenated batches of 82 bytes each.
static constexpr int BATCH_BYTES = 82; // codec_type(1) + mode(1) + 10*8
uint8_t packet_buf[256];
int pos = 0;
packet_buf[pos++] = 0x81; // fixmap(1)
packet_buf[pos++] = 0x01; // key: FIELD_FRAMES
if (batch_count == 1) {
// Single batch: bare bin8
packet_buf[pos++] = 0xC4; // bin8
packet_buf[pos++] = (uint8_t)BATCH_BYTES;
memcpy(packet_buf + pos, batch_data, BATCH_BYTES);
pos += BATCH_BYTES;
} else {
// Multiple batches: fixarray(N) of bin8 entries
packet_buf[pos++] = 0x90 | (uint8_t)batch_count; // fixarray(N), N≤15
for (int b = 0; b < batch_count; b++) {
packet_buf[pos++] = 0xC4; // bin8
packet_buf[pos++] = (uint8_t)BATCH_BYTES;
memcpy(packet_buf + pos, batch_data + b * BATCH_BYTES, BATCH_BYTES);
pos += BATCH_BYTES;
}
}
// Hex dump first TX packet for wire format verification
if (_call_audio_tx_count < 2) {
char hex[128];
int hpos = 0;
for (int i = 0; i < pos && i < 24 && hpos < 120; i++) {
hpos += snprintf(hex + hpos, 128 - hpos, "%02X ", packet_buf[i]);
}
char dbg[196];
snprintf(dbg, sizeof(dbg), "LXST: TX wire[%d] %d batches %d frames: %s",
pos, batch_count, total_frames, hex);
INFO(dbg);
}
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
uint8_t codec_type = frame[0];
const uint8_t* codec_data = frame + 1;
size_t codec_data_len = frame_len - 1;
if (codec_type != LXST_CODEC_CODEC2) {
if (_call_audio_rx_count == 0) {
char dbg[64];
snprintf(dbg, sizeof(dbg), "LXST: RX codec=0x%02X (need 0x02=Codec2), dropping",
codec_type);
WARNING(dbg);
}
return; // Can't decode Opus (0x01) or Raw (0x00) — only Codec2
}
if (_lxst_audio && _lxst_audio->isPlaying()) {
_lxst_audio->writeEncodedPacket(codec_data, codec_data_len);
_call_audio_rx_count++;
if (_call_audio_rx_count <= 3) {
char dbg[80];
snprintf(dbg, sizeof(dbg), "LXST: RX audio #%lu mode=0x%02X len=%d",
(unsigned long)_call_audio_rx_count, codec_data[0], (int)codec_data_len);
INFO(dbg);
}
} else if (_call_audio_rx_count == 0) {
WARNING("LXST: RX audio dropped (playback not active)");
}
}
void UIManager::call_on_packet(const Bytes& data) {
// 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.
{
char dbg[64];
snprintf(dbg, sizeof(dbg), "LXST: call_on_packet len=%d state=%d", (int)data.size(), (int)_call_state);
DEBUG(dbg);
}
if (data.size() < 4) return;
const uint8_t* buf = data.data();
// 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;
}
uint8_t field = buf[1];
if (field == 0x00) {
// Signalling: {0x00: [signal]}
// fixarray(1) = 0x91, then signal is a msgpack integer:
// 0x00-0x7F = fixint (1 byte)
// 0xCC XX = uint8 (2 bytes)
// 0xCD XX XX = uint16 (3 bytes)
if (buf[2] != 0x91) return;
int signal = -1;
if (buf[3] <= 0x7F) {
// fixint: value is the byte itself
signal = buf[3];
} else if (buf[3] == 0xCC && data.size() >= 5) {
// uint8
signal = buf[4];
} else if (buf[3] == 0xCD && data.size() >= 6) {
// uint16 (big-endian)
signal = ((int)buf[4] << 8) | buf[5];
}
if (signal < 0) {
char dbg[64];
snprintf(dbg, sizeof(dbg), "LXST: Unparseable signal (0x%02X), %d bytes", buf[3], (int)data.size());
WARNING(dbg);
return;
}
// Handle PREFERRED_PROFILE signals (0xFF+)
// Remote sends PREFERRED_PROFILE + profile_id to request a codec profile.
// 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);
INFO(dbg);
// Send our preferred profile (LBW = Codec2 3200bps)
call_send_signal(LXST_PREFERRED_PROFILE + LXST_PROFILE_LBW);
return;
}
{
char dbg[48];
snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal);
INFO(dbg);
}
// 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:
// - bin8/bin16: single frame (codec_header + frame_data)
// - fixarray: batched frames [bin8(...), bin8(...), ...]
// Audio buffer writes don't touch LVGL — safe to process here
if ((_call_state != CallState::ACTIVE && _call_state != CallState::CONNECTING)
|| !_lxst_audio) {
return;
}
uint8_t fmt = buf[2];
if ((fmt & 0xF0) == 0x90) {
// fixarray: batched frames — Columba sends up to 3 per packet
int array_len = fmt & 0x0F;
size_t pos = 3; // start after fixarray byte
for (int i = 0; i < array_len; i++) {
if (pos >= data.size()) break;
size_t frame_len;
size_t frame_start;
if (buf[pos] == 0xC4) {
// bin8
if (pos + 1 >= data.size()) break;
frame_len = buf[pos + 1];
frame_start = pos + 2;
} else if (buf[pos] == 0xC5) {
// bin16
if (pos + 2 >= data.size()) break;
frame_len = ((size_t)buf[pos + 1] << 8) | buf[pos + 2];
frame_start = pos + 3;
} else {
// Unknown format in array — skip rest
break;
}
if (frame_start + frame_len > data.size() || frame_len < 2) break;
call_rx_audio_frame(buf + frame_start, frame_len);
pos = frame_start + frame_len;
}
} else if (fmt == 0xC4) {
// bin8: single frame
if (data.size() < 5) return;
size_t frame_len = buf[3];
if (data.size() < 4 + frame_len || frame_len < 2) return;
call_rx_audio_frame(buf + 4, frame_len);
} else if (fmt == 0xC5) {
// bin16: single frame
if (data.size() < 6) return;
size_t frame_len = ((size_t)buf[3] << 8) | buf[4];
if (data.size() < 5 + frame_len || frame_len < 2) return;
call_rx_audio_frame(buf + 5, frame_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);
INFO(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");
// Tell remote we need Codec2 (LBW = 3200bps)
call_send_signal(LXST_PREFERRED_PROFILE + LXST_PROFILE_LBW);
_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;
lxst_breadcrumb(20, ESP.getFreeHeap());
if (!_lxst_audio) {
_lxst_audio = new LXSTAudio();
}
lxst_breadcrumb(21, ESP.getFreeHeap());
if (!_lxst_audio->init(CODEC2_MODE_3200)) {
WARNING("LXST: Audio init failed");
call_ended();
return;
}
lxst_breadcrumb(22, ESP.getFreeHeap());
// Start full-duplex audio (mic + speaker)
if (!_lxst_audio->startFullDuplex()) {
WARNING("LXST: Full-duplex start failed");
}
lxst_breadcrumb(23, ESP.getFreeHeap());
} 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);
lxst_breadcrumb(24, ESP.getFreeHeap());
if (!_lxst_audio) {
_lxst_audio = new LXSTAudio();
if (!_lxst_audio->init(CODEC2_MODE_3200)) {
WARNING("LXST: Audio init failed");
call_ended();
return;
}
}
lxst_breadcrumb(25, ESP.getFreeHeap());
if (!_lxst_audio->isPlaying()) {
if (!_lxst_audio->startFullDuplex()) {
WARNING("LXST: Full-duplex start failed");
}
}
lxst_breadcrumb(26, ESP.getFreeHeap());
INFO("LXST: Call active (caller, full-duplex)");
} 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 full-duplex is running
if (_lxst_audio && !_lxst_audio->isPlaying()) {
if (!_lxst_audio->startFullDuplex()) {
WARNING("LXST: Full-duplex start failed");
}
}
INFO("LXST: Call active (full-duplex)");
}
break;
default:
break;
}
}
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();
_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_peer_hash = Bytes();
_call_screen->set_state(CallScreen::CallState::ENDED);
// Return to conversation list after brief display
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();
// 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 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
}
// 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);
}
}
}
// Poll for path resolution (PATH_REQUESTING state)
if (_call_state == CallState::PATH_REQUESTING) {
if (Transport::has_path(_call_dest_hash)) {
INFO("LXST: Path resolved, creating link...");
Identity peer_identity = Identity::recall(_call_peer_hash);
if (!peer_identity) {
WARNING("LXST: Peer identity lost during path request");
call_ended();
return;
}
Destination peer_dest(peer_identity, Type::Destination::OUT,
Type::Destination::SINGLE, "lxst", "telephony");
_call_link = Link(peer_dest, on_call_link_established, on_call_link_closed);
_call_state = CallState::LINK_ESTABLISHING;
_call_timeout_ms = millis() + 30000;
INFO("LXST: Link establishing, 30s timeout");
}
}
// Check timeouts
if (_call_timeout_ms > 0 && now > _call_timeout_ms) {
switch (_call_state) {
case CallState::PATH_REQUESTING:
WARNING("LXST: Path request timed out");
call_ended();
return;
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;
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;
}
}
// Check link health during active/connecting call
if (_call_state == CallState::ACTIVE || _call_state == CallState::CONNECTING) {
if (!_call_link || _call_link.status() == Type::Link::CLOSED) {
WARNING("LXST: Link closed during call");
call_ended();
return;
}
// Update duration display (ACTIVE only)
if (_call_state == CallState::ACTIVE) {
uint32_t duration_secs = (now - _call_start_ms) / 1000;
_call_screen->set_duration(duration_secs);
// Periodic audio stats (every 2 seconds)
if (duration_secs > 0 && duration_secs % 2 == 0 &&
now - _call_start_ms > duration_secs * 1000 - 500) {
static uint32_t last_stats_sec = 0;
if (duration_secs != last_stats_sec) {
last_stats_sec = duration_secs;
char dbg[128];
snprintf(dbg, sizeof(dbg), "LXST: Audio stats: TX=%lu RX=%lu playBuf=%d capAvail=%d state=%d link=%d",
(unsigned long)_call_audio_tx_count,
(unsigned long)_call_audio_rx_count,
_lxst_audio ? _lxst_audio->playbackFramesBuffered() : -1,
_lxst_audio ? _lxst_audio->capturePacketsAvailable() : -1,
_lxst_audio ? (int)_lxst_audio->state() : -1,
_call_link ? (int)_call_link.status() : -99);
INFO(dbg);
}
}
}
// TX pump — also called from main loop without LVGL lock for low latency
pump_call_tx();
}
}
// ── Static Link Callbacks ──
void UIManager::on_call_link_established(Link& link) {
if (!s_call_instance) return;
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);
INFO("LXST: Packet callback registered on outgoing link");
// 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;
// Ignore stale link closures (e.g. old link teardown completing after new call started)
if (s_call_instance->_call_link && link != s_call_instance->_call_link) {
WARNING("LXST: Stale link closed (ignoring)");
return;
}
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_link_closed_pending = true;
}
}
void UIManager::on_call_link_packet(const Bytes& plaintext, const Packet& packet) {
if (!s_call_instance) return;
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");
_call_audio_rx_count = 0;
_call_audio_tx_count = 0;
// 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
lxst_breadcrumb(30, ESP.getFreeHeap());
if (!_lxst_audio) {
_lxst_audio = new LXSTAudio();
}
lxst_breadcrumb(31, ESP.getFreeHeap());
if (!_lxst_audio->init(CODEC2_MODE_3200)) {
WARNING("LXST: Audio init failed");
call_ended();
return;
}
lxst_breadcrumb(32, ESP.getFreeHeap());
// Start full-duplex audio (mic + speaker)
if (!_lxst_audio->startFullDuplex()) {
WARNING("LXST: Full-duplex start failed");
}
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 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, full-duplex)");
}
void UIManager::announce_lxst() {
if (_lxst_destination) {
_lxst_destination.announce();
}
}
} // namespace LXMF
} // namespace UI
#endif // ARDUINO