Files
pyxis/lib/tdeck_ui/UI/LXMF/UIManager.cpp
T
torlando-tech ddd19a04db Fix LXST TX audio wire format to match Columba's expected batch size
Columba's native OboePlaybackEngine ring buffer expects exactly
frameSamples (1600 for Codec2 3200 mode) decoded samples per
writeEncodedPacket call = 10 sub-frames of 160 samples each.

Changes:
- Batch exactly 10 sub-frames per fixarray element (82 bytes each:
  codec_type + mode_header + 10*8 raw bytes)
- Up to 2 batches per msgpack packet, matching Columba C2C format
- Proper fixarray wrapping for multi-batch, bare bin8 for single
- Add codec_type byte (0x02) prefix per batch element
- Respond to PREFERRED_PROFILE negotiation with LBW (Codec2 3200)
- Add capture diagnostics (raw PCM peaks, I2S dump, rate logging)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:57:14 -05:00

1636 lines
55 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/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";
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_pending(0xFF),
_call_audio_rx_count(0),
_call_audio_tx_count(0) {
}
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();
// 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");
_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);
// 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.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); // Clear saved node when auto-select enabled
}
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_pending = 0xFF;
{
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");
// 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);
}
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;
}
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);
}
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);
}
Bytes audio_data(packet_buf, pos);
Packet packet(_call_link, audio_data);
packet.send();
}
void UIManager::call_rx_audio_frame(const uint8_t* frame, size_t frame_len) {
// 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);
DEBUG(dbg);
// Queue for processing in call_update() under LVGL lock
_call_signal_pending = (uint8_t)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);
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");
// 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");
// 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();
// 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);
}
}
}
// 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);
}
}
}
// Pump TX: batch codec frames to match Columba's expected ring buffer slot size.
// Columba's native OboePlaybackEngine expects exactly frameSamples (1600 for 3200
// mode) decoded samples per writeEncodedPacket call = 10 sub-frames.
// Each encoded frame from ring buffer = [mode_header(1)] + [raw_codec2(8)] = 9 bytes.
// We pack 10 frames per batch: [codec_type(1)] + [mode(1)] + [10*raw(80)] = 82 bytes.
// Multiple batches go into a fixarray, matching Columba's wire format exactly.
static constexpr int FRAMES_PER_BATCH = 10; // 10 * 160 = 1600 = Columba's frameSamples
static constexpr int MAX_BATCHES = 2; // Up to 2 batches per packet (like Columba)
static constexpr int TX_MAX_FRAMES = FRAMES_PER_BATCH * MAX_BATCHES;
static constexpr uint32_t TX_INTERVAL_MS = 200; // Match LXST-kt LBW frame time
static uint32_t last_tx_ms = 0;
if (_lxst_audio && _lxst_audio->isCapturing()) {
int available = _lxst_audio->capturePacketsAvailable();
bool time_to_send = (now - last_tx_ms) >= TX_INTERVAL_MS;
// Send if: enough time has passed AND we have frames,
// OR ring is getting full (>60 frames = 1.2s buffered)
if ((time_to_send && available >= FRAMES_PER_BATCH) || available > 60) {
int to_send = available < TX_MAX_FRAMES ? available : TX_MAX_FRAMES;
// Read frames and pack into batches of 10.
// batch_data: concatenated batches, each 82 bytes:
// [codec_type(0x02)] + [mode_header] + [10 * 8 raw bytes]
uint8_t batch_data[MAX_BATCHES * 82];
int batch_count = 0;
int total_frames = 0;
uint8_t encoded_buf[16];
while (batch_count < MAX_BATCHES && to_send >= FRAMES_PER_BATCH) {
uint8_t* bp = batch_data + batch_count * 82;
bp[0] = LXST_CODEC_CODEC2; // codec_type = 0x02
int frames_in_batch = 0;
for (int i = 0; i < FRAMES_PER_BATCH; i++) {
int encoded_len = 0;
if (!_lxst_audio->readEncodedPacket(encoded_buf, sizeof(encoded_buf), &encoded_len)) {
break;
}
if (encoded_len < 2) continue;
if (frames_in_batch == 0) {
// First frame: keep mode_header + raw
memcpy(bp + 1, encoded_buf, encoded_len);
} else {
// Subsequent: append raw only (strip mode_header)
memcpy(bp + 1 + 1 + frames_in_batch * 8, encoded_buf + 1, encoded_len - 1);
}
frames_in_batch++;
_call_audio_tx_count++;
to_send--;
}
if (frames_in_batch == FRAMES_PER_BATCH) {
batch_count++;
total_frames += frames_in_batch;
} else {
// Incomplete batch — put back? Can't, so just count what we got
total_frames += frames_in_batch;
if (frames_in_batch > 0) batch_count++;
break;
}
}
if (batch_count > 0) {
call_send_audio_batch(batch_data, 82 * batch_count,
batch_count, total_frames);
last_tx_ms = now;
if (_call_audio_tx_count <= 50) {
char dbg[96];
snprintf(dbg, sizeof(dbg), "LXST: TX %d batches %d frames (%d avail), total=%lu",
batch_count, total_frames, available,
(unsigned long)_call_audio_tx_count);
INFO(dbg);
}
}
}
} else if (_call_audio_tx_count == 0) {
// Log once why TX is not running
static uint32_t last_tx_warn = 0;
if (now - last_tx_warn > 2000) {
last_tx_warn = now;
char dbg[96];
snprintf(dbg, sizeof(dbg), "LXST: TX pump idle: audio=%p capturing=%d state=%d",
_lxst_audio, _lxst_audio ? (int)_lxst_audio->isCapturing() : -1,
_lxst_audio ? (int)_lxst_audio->state() : -1);
WARNING(dbg);
}
}
}
}
// ── 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