mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-05-15 03:45:05 +00:00
ddd19a04db
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>
1636 lines
55 KiB
C++
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
|