mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-09-16 09:52:36 +00:00
4439 lines
174 KiB
C++
4439 lines
174 KiB
C++
// Copyright (c) 2024 microReticulum contributors
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
#include "UIManager.h"
|
|
#include "RouterLock.h"
|
|
|
|
#ifdef ARDUINO
|
|
|
|
#include <lvgl.h>
|
|
#include <Preferences.h>
|
|
#include <LittleFS.h>
|
|
#include <microReticulum/Log.h>
|
|
#include <microReticulum/Utilities/OS.h>
|
|
#ifdef PYXIS_TEST_HOOKS
|
|
#include "pyxis_test_hooks.h"
|
|
#endif
|
|
#include "Tone.h"
|
|
#include "../LVGL/LVGLLock.h"
|
|
#include "lxst_audio.h"
|
|
#include "LXSTSignalParser.h"
|
|
#include "ULBWVoiceProfilePolicy.h"
|
|
#include <microReticulum/Packet.h>
|
|
#include <microReticulum/Resource.h>
|
|
#include <microReticulum/Transport.h>
|
|
#include <microReticulum/Destination.h>
|
|
#include <microReticulum/Utilities/OS.h>
|
|
#include <esp_heap_caps.h>
|
|
#include <esp_timer.h>
|
|
#include <cstring>
|
|
#include <new>
|
|
#include <utility>
|
|
|
|
using namespace RNS;
|
|
|
|
// Arm/disarm the decoded-PCM dump used by the audio-loopback test mode.
|
|
// Defined in src/main.cpp (owns the UDP multicast socket).
|
|
extern "C" void pyxis_audio_dump_arm(bool on);
|
|
|
|
// NVS keys for propagation settings
|
|
static const char* NVS_NAMESPACE = "propagation";
|
|
static const char* KEY_AUTO_SELECT = "auto_select";
|
|
static const char* KEY_NODE_HASH = "node_hash";
|
|
static const char* KEY_STAMP_COST = "stamp_cost";
|
|
|
|
namespace UI {
|
|
namespace LXMF {
|
|
|
|
namespace {
|
|
|
|
constexpr const char* NOMAD_LIBRARY_DIR = "/nomadnet";
|
|
constexpr const char* NOMAD_LIBRARY_PATH = "/nomadnet/library.pxnn";
|
|
constexpr const char* NOMAD_LIBRARY_TMP = "/nomadnet/library.tmp";
|
|
constexpr const char* NOMAD_LIBRARY_STAGE = "/nomadnet/library.new";
|
|
constexpr const char* NOMAD_LIBRARY_BACKUP = "/nomadnet/library.bak";
|
|
constexpr const char* NOMAD_LIBRARY_OLD = "/nomadnet/library.old";
|
|
|
|
bool bytes_equal_lower_hex(const Bytes& bytes, const std::string& hex) {
|
|
static constexpr char DIGITS[] = "0123456789abcdef";
|
|
if (hex.size() != bytes.size() * 2U) return false;
|
|
for (std::size_t index = 0; index < bytes.size(); ++index) {
|
|
const uint8_t value = bytes[index];
|
|
if (hex[index * 2U] != DIGITS[value >> 4U] ||
|
|
hex[index * 2U + 1U] != DIGITS[value & 0x0fU])
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#if defined(MEMORY_INSTRUMENTATION_ENABLED) || defined(PYXIS_NOMAD_MEMORY_DIAGNOSTIC)
|
|
void nomad_heap_checkpoint(const char* phase) {
|
|
Serial.printf(
|
|
"T:NOMAD_HEAP phase=%s free=%u largest=%u minimum=%u psram=%u\n",
|
|
phase,
|
|
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
|
|
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)),
|
|
static_cast<unsigned>(heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL)),
|
|
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)));
|
|
}
|
|
#else
|
|
void nomad_heap_checkpoint(const char*) {}
|
|
#endif
|
|
|
|
bool read_nomad_library_file(const char* path, NomadNet::ExternalVector<uint8_t>& bytes) {
|
|
File file = LittleFS.open(path, "r");
|
|
if (!file) return false;
|
|
const std::size_t size = file.size();
|
|
if (size == 0 || size > NomadNet::Library::MAX_ENCODED_BYTES) {
|
|
file.close();
|
|
return false;
|
|
}
|
|
bytes.resize(size);
|
|
const std::size_t read = file.read(bytes.data(), size);
|
|
file.close();
|
|
return read == size;
|
|
}
|
|
|
|
lv_obj_t* storage_error_dialog = nullptr;
|
|
|
|
void close_storage_error(lv_event_t* event) {
|
|
lv_obj_t* message_box = lv_event_get_current_target(event);
|
|
if (lv_msgbox_get_active_btn(message_box) == 0) {
|
|
storage_error_dialog = nullptr;
|
|
lv_msgbox_close(message_box);
|
|
}
|
|
}
|
|
|
|
void show_storage_error(const char* message) {
|
|
// Coalesce receive bursts into one dialog rather than allocating one per
|
|
// rejected packet while storage remains unavailable.
|
|
if (storage_error_dialog) return;
|
|
static const char* buttons[] = {"OK", ""};
|
|
storage_error_dialog = lv_msgbox_create(
|
|
nullptr, "Message not saved", message, buttons, false
|
|
);
|
|
lv_obj_center(storage_error_dialog);
|
|
lv_obj_add_event_cb(storage_error_dialog, close_storage_error, LV_EVENT_VALUE_CHANGED, nullptr);
|
|
}
|
|
|
|
std::vector<uint8_t> token(const RNS::Bytes& bytes) {
|
|
if (!bytes || bytes.size() == 0) return {};
|
|
return std::vector<uint8_t>(bytes.data(), bytes.data() + bytes.size());
|
|
}
|
|
|
|
struct OutboundPersistenceContext {
|
|
::LXMF::MessageStore* store;
|
|
::LXMF::LXMessage* message;
|
|
};
|
|
|
|
bool persistOutgoingMessage(void* raw_context) {
|
|
auto& context = *static_cast<OutboundPersistenceContext*>(raw_context);
|
|
return context.store->save_message(*context.message);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// Static singletons for Link callbacks
|
|
UIManager* UIManager::s_call_instance = nullptr;
|
|
UIManager* UIManager::s_nomad_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;
|
|
|
|
class NomadNetAnnounceHandler : public AnnounceHandler {
|
|
public:
|
|
using Callback = std::function<void(const Bytes&, const Bytes&)>;
|
|
|
|
explicit NomadNetAnnounceHandler(Callback callback)
|
|
: AnnounceHandler("nomadnetwork.node"), callback_(std::move(callback)) {}
|
|
|
|
void received_announce(const Bytes& destination_hash, const Identity& /*identity*/,
|
|
const Bytes& app_data) override {
|
|
if (callback_) callback_(destination_hash, app_data);
|
|
}
|
|
|
|
private:
|
|
Callback callback_;
|
|
};
|
|
|
|
uint64_t monotonicMillis() {
|
|
return static_cast<uint64_t>(esp_timer_get_time() / 1000LL);
|
|
}
|
|
|
|
bool peerIdFromHash(const Bytes& hash, Telemetry::PeerId& output) {
|
|
if (hash.size() != Telemetry::PEER_ID_SIZE) return false;
|
|
std::memcpy(output.bytes, hash.data(), Telemetry::PEER_ID_SIZE);
|
|
return true;
|
|
}
|
|
|
|
template <typename T, typename... Args>
|
|
T* allocateLocationObject(Args&&... args) {
|
|
void* memory = heap_caps_calloc(
|
|
1, sizeof(T), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
return memory ? new (memory) T(std::forward<Args>(args)...) : nullptr;
|
|
}
|
|
|
|
template <typename T>
|
|
void releaseLocationObject(T*& object) {
|
|
if (!object) return;
|
|
object->~T();
|
|
heap_caps_free(object);
|
|
object = nullptr;
|
|
}
|
|
|
|
class LiveLocationEnvelopeRouter : public Telemetry::LocationEnvelopeRouter {
|
|
public:
|
|
explicit LiveLocationEnvelopeRouter(::LXMF::LXMRouter& router)
|
|
: router_(router) {}
|
|
|
|
bool queue(
|
|
const Telemetry::OutboundLocationEnvelope& envelope,
|
|
uint64_t exclusive_deadline_monotonic_millis,
|
|
uint64_t& ownership_monotonic_millis) override {
|
|
RouterLock router_lock;
|
|
if (!router_lock.acquired()) return false;
|
|
try {
|
|
Bytes destination_hash(
|
|
envelope.destination.bytes, Telemetry::PEER_ID_SIZE);
|
|
Identity destination_identity = Identity::recall(destination_hash);
|
|
Destination destination(Type::NONE);
|
|
if (destination_identity) {
|
|
destination = Destination(
|
|
destination_identity, Type::Destination::OUT,
|
|
Type::Destination::SINGLE, "lxmf", "delivery");
|
|
}
|
|
|
|
::LXMF::LXMessage message(
|
|
destination,
|
|
router_.delivery_destination(),
|
|
Bytes(),
|
|
Bytes(),
|
|
::LXMF::Type::Message::OPPORTUNISTIC);
|
|
if (!destination_identity) {
|
|
message.destination_hash(destination_hash);
|
|
}
|
|
for (std::size_t index = 0; index < envelope.field_count; ++index) {
|
|
const auto& field = envelope.fields[index];
|
|
if (!message.fields_set(
|
|
Bytes(field.key, field.key_size),
|
|
Bytes(field.value, field.value_size))) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
GuardContext guard_context{
|
|
exclusive_deadline_monotonic_millis,
|
|
&ownership_monotonic_millis};
|
|
return router_.try_handle_outbound(
|
|
message, claimOwnership, &guard_context) ==
|
|
::LXMF::OutboundAdmissionResult::ACCEPTED;
|
|
} catch (const std::exception& error) {
|
|
WARNINGF("Location outbound preparation failed: %s", error.what());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private:
|
|
struct GuardContext {
|
|
uint64_t deadline;
|
|
uint64_t* ownership_time;
|
|
};
|
|
|
|
static bool claimOwnership(void* raw_context) {
|
|
auto& context = *static_cast<GuardContext*>(raw_context);
|
|
const uint64_t now = monotonicMillis();
|
|
if (now >= context.deadline) return false;
|
|
*context.ownership_time = now;
|
|
return true;
|
|
}
|
|
|
|
::LXMF::LXMRouter& router_;
|
|
};
|
|
|
|
// LoRa bandwidth is a production constraint: Pyxis advertises and accepts only
|
|
// LXST ULBW/Codec2-700C.
|
|
int UIManager::_preferred_profile = UIManager::LXST_PROFILE_ULBW;
|
|
|
|
int UIManager::profile_to_codec2_mode(int profile) {
|
|
return ULBWVoiceProfilePolicy::codecModeForProfile(profile);
|
|
}
|
|
|
|
UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router,
|
|
::LXMF::MessageStore& store,
|
|
bool location_filesystem_available)
|
|
: _reticulum(reticulum), _router(router), _store(store),
|
|
_location_storage(nullptr),
|
|
_location_transaction(nullptr),
|
|
_location_persistence_controller(nullptr),
|
|
_gps(nullptr),
|
|
// Vanilla upstream RNS::Destination has no default ctor; construct in
|
|
// a Type::NONE state, then assign a real Destination later. (The fork
|
|
// had a default ctor that pyxis was implicitly relying on.)
|
|
_lxst_destination(RNS::Type::NONE),
|
|
_home_screen(nullptr),
|
|
_network_screen(nullptr),
|
|
_nomadnet_screen(nullptr),
|
|
_conversation_list_screen(nullptr),
|
|
_chat_screen(nullptr),
|
|
_compose_screen(nullptr),
|
|
_announce_list_screen(nullptr),
|
|
_status_screen(nullptr),
|
|
_radio_activity_screen(nullptr),
|
|
_qr_screen(nullptr),
|
|
_settings_screen(nullptr),
|
|
_propagation_nodes_screen(nullptr),
|
|
_location_share_screen(nullptr),
|
|
_call_screen(nullptr),
|
|
_map_screen(nullptr),
|
|
_propagation_manager(nullptr),
|
|
_ble_interface(nullptr),
|
|
_initialized(false),
|
|
_nomad_cache(_nomad_storage),
|
|
_nomad_cache_flow(_nomad_cache),
|
|
_call_state(CallState::IDLE),
|
|
_call_loopback(false),
|
|
_lxst_audio(nullptr),
|
|
_call_start_ms(0),
|
|
_call_timeout_ms(0),
|
|
_call_muted(false),
|
|
_call_answer_pending(false),
|
|
_call_signal_write(0),
|
|
_call_signal_read(0),
|
|
_call_audio_rx_count(0),
|
|
_call_audio_tx_count(0),
|
|
_pending_conversation_refresh(false),
|
|
_last_conversation_refresh_ms(0) {
|
|
memset((void*)_call_signal_queue, 0, sizeof(_call_signal_queue));
|
|
_location_storage = allocateLocationObject<
|
|
Telemetry::LocationPersistenceLittleFS>(location_filesystem_available);
|
|
if (_location_storage) {
|
|
_location_transaction = allocateLocationObject<
|
|
Telemetry::TransactionalLocationPersistence>(*_location_storage);
|
|
}
|
|
if (_location_transaction) {
|
|
_location_persistence_controller = allocateLocationObject<
|
|
Telemetry::LocationPersistenceController>(
|
|
_location_shares, _peer_locations, *_location_transaction);
|
|
}
|
|
if (!_location_persistence_controller) {
|
|
ERROR("Location persistence allocation failed; sharing remains disabled");
|
|
}
|
|
}
|
|
|
|
UIManager::~UIManager() {
|
|
// Joins the sole SD/decoder worker before any map buffers are released.
|
|
if (_map_screen) delete _map_screen;
|
|
_map_screen = nullptr;
|
|
releaseLocationObject(_location_persistence_controller);
|
|
releaseLocationObject(_location_transaction);
|
|
releaseLocationObject(_location_storage);
|
|
// Clean up call state
|
|
if (_call_state != CallState::IDLE ||
|
|
call_current_generation() != 0) {
|
|
call_hangup();
|
|
}
|
|
call_teardown_audio();
|
|
|
|
// This pointer owns the long-lived incoming-destination callback, not only
|
|
// the current call. Clear it only when the manager itself is destroyed.
|
|
if (s_call_instance == this) s_call_instance = nullptr;
|
|
if (s_nomad_instance == this) s_nomad_instance = nullptr;
|
|
if (_nomad_announce_handler) {
|
|
RouterLock router_lock;
|
|
if (router_lock.acquired()) {
|
|
Transport::deregister_announce_handler(_nomad_announce_handler);
|
|
_nomad_announce_handler = HAnnounceHandler();
|
|
}
|
|
}
|
|
nomad_stop_transport();
|
|
|
|
if (_home_screen) delete _home_screen;
|
|
if (_network_screen) delete _network_screen;
|
|
if (_nomadnet_screen) delete _nomadnet_screen;
|
|
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 (_radio_activity_screen) delete _radio_activity_screen;
|
|
if (_qr_screen) delete _qr_screen;
|
|
if (_settings_screen) delete _settings_screen;
|
|
if (_propagation_nodes_screen) delete _propagation_nodes_screen;
|
|
if (_location_share_screen) delete _location_share_screen;
|
|
if (_call_screen) delete _call_screen;
|
|
}
|
|
|
|
bool UIManager::init() {
|
|
if (_initialized) {
|
|
return true;
|
|
}
|
|
// Read the bounded NomadNet index before taking the render lock. A corrupt
|
|
// primary never replaces the in-memory library; the backup is tried next.
|
|
nomad_load_library();
|
|
LVGL_LOCK();
|
|
|
|
INFO("Initializing UIManager");
|
|
|
|
// Create all screens
|
|
_home_screen = new HomeScreen();
|
|
_network_screen = new NetworkScreen();
|
|
_nomadnet_screen = new NomadNetScreen();
|
|
_conversation_list_screen = new ConversationListScreen();
|
|
_chat_screen = new ChatScreen();
|
|
_compose_screen = new ComposeScreen();
|
|
_announce_list_screen = new AnnounceListScreen();
|
|
_status_screen = new StatusScreen();
|
|
_radio_activity_screen = new RadioActivityScreen();
|
|
_qr_screen = new QRScreen();
|
|
_settings_screen = new SettingsScreen();
|
|
_propagation_nodes_screen = new PropagationNodesScreen();
|
|
_location_share_screen = new LocationShareScreen();
|
|
_call_screen = new CallScreen();
|
|
_map_screen = new MapScreen();
|
|
|
|
_home_screen->set_messages_callback([this]() { show_conversation_list(); });
|
|
_home_screen->set_nomadnet_callback([this]() { show_nomadnet(); });
|
|
_home_screen->set_network_callback([this]() { show_network(); });
|
|
_home_screen->set_settings_callback([this]() { show_settings(); });
|
|
_home_screen->set_map_callback([this]() { show_map(); });
|
|
_network_screen->set_back_callback([this]() { back(); });
|
|
_network_screen->set_home_callback([this]() { home(); });
|
|
_network_screen->set_status_callback([this]() { show_status(); });
|
|
_network_screen->set_radio_activity_callback([this]() { show_radio_activity(); });
|
|
_network_screen->set_propagation_nodes_callback([this]() { show_propagation_nodes(); });
|
|
_nomadnet_screen->set_back_callback([this]() { _nomad_actions.publish(NomadNet::UserActionKind::BACK, {}); });
|
|
_nomadnet_screen->set_home_callback([this]() { _nomad_actions.publish(NomadNet::UserActionKind::HOME, {}); });
|
|
_nomadnet_screen->set_reload_callback([this](const std::string& address) {
|
|
return _nomad_actions.publish(NomadNet::UserActionKind::RELOAD, address);
|
|
});
|
|
_nomadnet_screen->set_open_callback([this](const std::string& address) {
|
|
return _nomad_actions.publish(NomadNet::UserActionKind::OPEN, address);
|
|
});
|
|
_nomadnet_screen->set_link_callback([this](const std::string& target) {
|
|
return _nomad_actions.publish(NomadNet::UserActionKind::OPEN, target);
|
|
});
|
|
_nomadnet_screen->set_submit_callback([this](uint16_t link_id, uint32_t generation) {
|
|
return _nomad_actions.publish_submit(link_id, generation);
|
|
});
|
|
_nomadnet_screen->set_save_callback([this](const std::string& target) {
|
|
return _nomad_actions.publish(NomadNet::UserActionKind::SAVE, target);
|
|
});
|
|
_nomadnet_screen->set_identify_callback([this](const std::string& target, bool identified) {
|
|
return _nomad_actions.publish_identify(target, identified);
|
|
});
|
|
_nomadnet_screen->set_library(_nomad_library);
|
|
|
|
// 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(); }
|
|
);
|
|
|
|
_map_screen->set_back_callback(
|
|
[this]() { on_back_from_map(); }
|
|
);
|
|
|
|
_conversation_list_screen->set_sync_callback(
|
|
[this]() { on_propagation_sync(); }
|
|
);
|
|
_conversation_list_screen->set_home_callback([this]() { home(); });
|
|
_conversation_list_screen->set_peers_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) { return on_send_message_from_chat(content); }
|
|
);
|
|
|
|
_chat_screen->set_call_callback(
|
|
[this]() { on_call_from_chat(); }
|
|
);
|
|
_chat_screen->set_location_callback(
|
|
[this]() { on_location_from_chat(); }
|
|
);
|
|
|
|
_location_share_screen->set_back_callback(
|
|
[this]() { on_back_from_location_sharing(); }
|
|
);
|
|
_location_share_screen->set_start_callback(
|
|
[this](const uint8_t* peer, std::size_t peer_size, uint8_t duration,
|
|
uint32_t cadence, bool approximate, int32_t radius) {
|
|
return _location_share_commands.requestStart(
|
|
peer, peer_size, duration, cadence, approximate, radius);
|
|
}
|
|
);
|
|
_location_share_screen->set_stop_callback(
|
|
[this](const uint8_t* peer, std::size_t peer_size) {
|
|
return _location_share_commands.requestStop(peer, peer_size);
|
|
}
|
|
);
|
|
|
|
// 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) {
|
|
return 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...");
|
|
RouterLock router_lock(0);
|
|
if (!router_lock.acquired()) {
|
|
WARNING("Router busy; announce deferred by user retry");
|
|
return;
|
|
}
|
|
try {
|
|
_router.announce();
|
|
announce_lxst();
|
|
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(); }
|
|
);
|
|
|
|
_status_screen->set_radio_activity_callback(
|
|
[this]() { show_radio_activity(); }
|
|
);
|
|
|
|
_radio_activity_screen->set_back_callback(
|
|
[this]() { on_back_from_radio_activity(); }
|
|
);
|
|
|
|
// 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(); }
|
|
);
|
|
|
|
|
|
// 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_request_hangup(); }
|
|
);
|
|
|
|
_call_screen->set_mute_callback(
|
|
[this](bool muted) { call_request_mute(muted); }
|
|
);
|
|
|
|
// Load settings from NVS
|
|
_settings_screen->load_settings();
|
|
|
|
// Restore propagation node selection from NVS
|
|
{
|
|
Preferences prefs;
|
|
prefs.begin(NVS_NAMESPACE, true);
|
|
bool auto_select = prefs.getBool(KEY_AUTO_SELECT, true);
|
|
uint8_t stamp_cost = prefs.getUChar(KEY_STAMP_COST, 0);
|
|
Bytes saved_hash;
|
|
size_t hash_len = prefs.getBytesLength(KEY_NODE_HASH);
|
|
if (hash_len > 0 && hash_len <= 32) {
|
|
uint8_t buf[32];
|
|
prefs.getBytes(KEY_NODE_HASH, buf, hash_len);
|
|
saved_hash = Bytes(buf, hash_len);
|
|
}
|
|
prefs.end();
|
|
|
|
if (!auto_select && saved_hash.size() > 0) {
|
|
_router.set_outbound_propagation_node(saved_hash);
|
|
_router.set_outbound_propagation_stamp_cost(stamp_cost);
|
|
INFO(("Restored propagation node from NVS: " + saved_hash.toHex().substr(0, 16) + "...").c_str());
|
|
}
|
|
}
|
|
|
|
// Set identity and LXMF address on settings screen
|
|
_settings_screen->set_identity_hash(_router.identity().hash());
|
|
_settings_screen->set_lxmf_address(_router.delivery_destination().hash());
|
|
|
|
|
|
// Set 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.store(true, std::memory_order_release);
|
|
}
|
|
);
|
|
|
|
// Load conversations; outer launcher is the application root.
|
|
_conversation_list_screen->load_conversations(_store);
|
|
show_home();
|
|
|
|
// 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;
|
|
s_nomad_instance = this;
|
|
|
|
// Canonical NomadNet records matching announces as they arrive and then
|
|
// persists its bounded stream. Do the same instead of depending on the
|
|
// directory screen being opened before the underlying route expires.
|
|
_nomad_announce_handler = HAnnounceHandler(
|
|
std::make_shared<NomadNetAnnounceHandler>(
|
|
[this](const Bytes& destination_hash, const Bytes& app_data) {
|
|
nomad_hear_node(destination_hash, app_data);
|
|
}));
|
|
{
|
|
RouterLock router_lock;
|
|
if (!router_lock.acquired()) return false;
|
|
Transport::register_announce_handler(_nomad_announce_handler);
|
|
}
|
|
|
|
// 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() {
|
|
// Settings Save only publishes a snapshot from the LVGL event. Persistence
|
|
// and interface changes execute here on the main owner loop, before LVGL.
|
|
if (_settings_screen) _settings_screen->service_pending_save();
|
|
// Flush display-name write-throughs the last conversation-list refresh
|
|
// deferred. Done here, BEFORE LVGL_LOCK, so the microStore/LittleFS I/O
|
|
// never runs under the render lock (same reason as on_message_received).
|
|
if (_conversation_list_screen) {
|
|
_conversation_list_screen->flush_pending_name_writes();
|
|
}
|
|
|
|
// SERVICE ORDER CONTRACT: consume peer consent commands and complete the
|
|
// existing controller-owned durable start/stop transaction before location
|
|
// service/dispatch and before LVGL_LOCK. LVGL callbacks perform no I/O.
|
|
const LocationShareCommandMailbox::Command location_command =
|
|
_location_share_commands.take();
|
|
bool has_location_ui_result = false;
|
|
Telemetry::LocationConsentResult location_ui_result =
|
|
Telemetry::LocationConsentResult::INVALID_ARGUMENT;
|
|
Telemetry::ShareSession location_ui_session{};
|
|
bool has_location_ui_session = false;
|
|
if (location_command.action != LocationShareCommandMailbox::Action::NONE) {
|
|
Bytes command_peer(location_command.peer, Telemetry::PEER_ID_SIZE);
|
|
if (location_command.action == LocationShareCommandMailbox::Action::QUERY) {
|
|
const Telemetry::LocationControllerState query_state =
|
|
_location_persistence_controller
|
|
? _location_persistence_controller->service(
|
|
static_cast<uint64_t>(RNS::Utilities::OS::ltime()),
|
|
monotonicMillis())
|
|
: Telemetry::LocationControllerState::BLOCKED;
|
|
if (query_state == Telemetry::LocationControllerState::READY) {
|
|
has_location_ui_session = get_location_share_session(
|
|
command_peer, location_ui_session);
|
|
location_ui_result = has_location_ui_session
|
|
? Telemetry::LocationConsentResult::UPDATED
|
|
: Telemetry::LocationConsentResult::NOT_FOUND;
|
|
} else {
|
|
location_ui_result =
|
|
query_state == Telemetry::LocationControllerState::WAITING_FOR_CLOCK
|
|
? Telemetry::LocationConsentResult::CLOCK_UNAVAILABLE
|
|
: Telemetry::LocationConsentResult::STORAGE_FAILURE;
|
|
}
|
|
} else if (location_command.action == LocationShareCommandMailbox::Action::STOP) {
|
|
location_ui_result = stop_location_sharing(command_peer);
|
|
} else {
|
|
Telemetry::ShareStartOptions options{};
|
|
bool valid = true;
|
|
switch (location_command.duration) {
|
|
case 0: options.duration = Telemetry::ShareDuration::MINUTES_15; break;
|
|
case 1: options.duration = Telemetry::ShareDuration::HOUR_1; break;
|
|
case 2: options.duration = Telemetry::ShareDuration::HOURS_4; break;
|
|
default: valid = false; break;
|
|
}
|
|
if (location_command.cadenceMillis != 60000U &&
|
|
location_command.cadenceMillis != 300000U &&
|
|
location_command.cadenceMillis != 900000U) valid = false;
|
|
if ((!location_command.hasApproximation && location_command.approximationMeters != 0) ||
|
|
(location_command.hasApproximation &&
|
|
location_command.approximationMeters != 100 &&
|
|
location_command.approximationMeters != 1000 &&
|
|
location_command.approximationMeters != 10000)) valid = false;
|
|
options.cadence_millis = location_command.cadenceMillis;
|
|
options.has_approx_radius = location_command.hasApproximation;
|
|
options.approx_radius_meters = location_command.approximationMeters;
|
|
location_ui_result = valid
|
|
? start_location_sharing(command_peer, options)
|
|
: Telemetry::LocationConsentResult::INVALID_ARGUMENT;
|
|
}
|
|
has_location_ui_result = true;
|
|
if (location_command.action != LocationShareCommandMailbox::Action::QUERY) {
|
|
has_location_ui_session = get_location_share_session(
|
|
command_peer, location_ui_session);
|
|
}
|
|
}
|
|
// Stream the rest of the open conversation's first page in a few at a time
|
|
// (the newest were rendered synchronously on open). Done here, on the main
|
|
// loop, so each small batch only briefly holds the LVGL lock instead of the
|
|
// whole page blocking it past LVGLLock's 5s timeout.
|
|
if (_navigation.current() == Route::CHAT && _chat_screen) {
|
|
_chat_screen->tick_background_fill();
|
|
}
|
|
// Gather + render the announce list off the LVGL lock on the main loop (its
|
|
// refresh() just arms a flag). Iterating the path table on the LVGL task raced
|
|
// the main-loop path-table writes and blocked past the lock timeout once the
|
|
// table was non-empty — this serializes the gather with the writes instead.
|
|
if (_navigation.current() == Route::ANNOUNCES && _announce_list_screen) {
|
|
_announce_list_screen->tick();
|
|
}
|
|
if (_navigation.current() == Route::SETTINGS && _settings_screen) {
|
|
_settings_screen->tick(); // keep the live clock / GPS / system readouts ticking
|
|
}
|
|
const uint32_t now = millis();
|
|
nomad_update_library();
|
|
nomad_update();
|
|
// Snapshot acquisition is deliberately before LVGL_LOCK and is coalesced
|
|
// to the screen's render cadence. The provider never touches radio or SPI.
|
|
if (_navigation.current() == Route::RADIO_ACTIVITY &&
|
|
_radio_activity_screen && _radio_activity_snapshot_provider &&
|
|
_radio_activity_screen->render_due(now)) {
|
|
const RadioActivity::Snapshot snapshot = _radio_activity_snapshot_provider();
|
|
_radio_activity_screen->render(snapshot, _radio_activity_config, now);
|
|
}
|
|
|
|
// Poll one consent-gated location item outside LVGL_LOCK. Packing and the
|
|
// router's synchronous ownership copy may perform crypto work; neither is
|
|
// a rendering operation. With no explicit sessions this remains a no-op.
|
|
const uint64_t wall_now_millis =
|
|
static_cast<uint64_t>(RNS::Utilities::OS::ltime());
|
|
const uint64_t monotonic_now_millis = monotonicMillis();
|
|
const Telemetry::LocationControllerState location_state =
|
|
_location_persistence_controller
|
|
? _location_persistence_controller->service(
|
|
wall_now_millis, monotonic_now_millis)
|
|
: Telemetry::LocationControllerState::BLOCKED;
|
|
Telemetry::GpsFixSample gps_sample{};
|
|
if (_gps) {
|
|
gps_sample.location_valid = _gps->location.isValid();
|
|
gps_sample.location_age_millis = _gps->location.age();
|
|
gps_sample.latitude_degrees = _gps->location.lat();
|
|
gps_sample.longitude_degrees = _gps->location.lng();
|
|
gps_sample.altitude_valid = _gps->altitude.isValid();
|
|
gps_sample.altitude_meters = _gps->altitude.meters();
|
|
gps_sample.speed_valid = _gps->speed.isValid();
|
|
gps_sample.speed_kilometers_per_hour = _gps->speed.kmph();
|
|
gps_sample.bearing_valid = _gps->course.isValid();
|
|
gps_sample.bearing_degrees = _gps->course.deg();
|
|
|
|
}
|
|
Telemetry::LocationTelemetry current_location{};
|
|
const bool current_location_valid = Telemetry::locationTelemetryFromGpsFix(
|
|
gps_sample, wall_now_millis, current_location);
|
|
Telemetry::DispatchResult location_result = Telemetry::DispatchResult::NO_WORK;
|
|
if (location_state == Telemetry::LocationControllerState::READY) {
|
|
LiveLocationEnvelopeRouter location_router(_router);
|
|
location_result = Telemetry::dispatchLocationShare(
|
|
_location_shares,
|
|
wall_now_millis,
|
|
monotonic_now_millis,
|
|
current_location_valid,
|
|
current_location,
|
|
location_router);
|
|
}
|
|
if (location_result == Telemetry::DispatchResult::QUEUED) {
|
|
INFO("Location telemetry queued");
|
|
} else if (location_result == Telemetry::DispatchResult::CEASE_QUEUED) {
|
|
INFO("Location cease queued");
|
|
}
|
|
|
|
// Build the fixed map model and service its worker before LVGL_LOCK. All
|
|
// authenticated ingress drains on this router-owner loop, so this fixed
|
|
// peer snapshot cannot race PeerLocationStore::apply().
|
|
if (_navigation.current() == Route::MAP && _map_screen) {
|
|
Telemetry::PeerLocationRecord peers[Telemetry::MAX_PEER_LOCATIONS]{};
|
|
static constexpr uint64_t MAP_PEER_MAX_AGE_MS =
|
|
24ULL * 60ULL * 60ULL * 1000ULL;
|
|
const std::size_t peer_count = _peer_locations.snapshot(
|
|
wall_now_millis, MAP_PEER_MAX_AGE_MS, peers,
|
|
Telemetry::MAX_PEER_LOCATIONS);
|
|
Pyxis::MapView::Request map_request{};
|
|
map_request.center = {0.0, 0.0};
|
|
map_request.zoom = 2U;
|
|
map_request.width = Pyxis::MapScreenPresenter::VIEWPORT_WIDTH;
|
|
map_request.height = Pyxis::MapScreenPresenter::VIEWPORT_HEIGHT;
|
|
map_request.include_tile_border = false;
|
|
map_request.has_local_location = current_location_valid;
|
|
map_request.local_location = current_location;
|
|
map_request.peers = peers;
|
|
map_request.peer_count = peer_count;
|
|
map_request.wall_now_millis = wall_now_millis;
|
|
_map_screen->updateModel(map_request);
|
|
_map_screen->serviceIo();
|
|
}
|
|
LVGL_LOCK();
|
|
|
|
// Outgoing starts are initiated here while the recursive LVGL mutex is
|
|
// held. Interface workers (notably BLE) can synchronously dispatch
|
|
// Reticulum callbacks on their own tasks, so every call-lifecycle callback
|
|
// takes this same mutex. A response therefore cannot run until Link
|
|
// construction, exact-ID publication, and state assignment finish.
|
|
CallStartMailbox::PeerHash startPeerHash{};
|
|
if (_call_starts.take(startPeerHash)) {
|
|
if (_call_state == CallState::IDLE &&
|
|
call_current_generation() == 0) {
|
|
call_initiate(Bytes(startPeerHash.data(), startPeerHash.size()));
|
|
} else {
|
|
WARNING("LXST: Discarding stale/busy call start request");
|
|
}
|
|
}
|
|
|
|
if (_navigation.current() == Route::MAP && _map_screen) {
|
|
// One predecoded completion at most per tick, then fixed-pool positions.
|
|
(void)_map_screen->applyOneCompletion();
|
|
_map_screen->applyFrame();
|
|
}
|
|
if (has_location_ui_result && _location_share_screen &&
|
|
_location_share_screen->matches_peer(
|
|
location_command.peer, Telemetry::PEER_ID_SIZE)) {
|
|
_location_share_screen->apply_result(
|
|
location_ui_result,
|
|
has_location_ui_session ? &location_ui_session : nullptr);
|
|
}
|
|
// Consume UI commands unconditionally. LVGL callbacks only publish into
|
|
// the mailbox; loopTask remains the sole owner of the audio pipeline.
|
|
const uint32_t generation = call_current_generation();
|
|
const CallCommandMailbox::Command command =
|
|
_call_commands.takeForGeneration(generation);
|
|
if (command.action == CallCommandMailbox::Action::HANGUP) {
|
|
call_hangup();
|
|
} else if (command.action == CallCommandMailbox::Action::MUTE) {
|
|
call_set_mute(command.muted);
|
|
}
|
|
|
|
// Pump voice call state while a call is active or its generation remains
|
|
// reserved during owner-side teardown.
|
|
if (_call_state != CallState::IDLE ||
|
|
call_current_generation() != 0) {
|
|
call_update();
|
|
}
|
|
|
|
// Update status indicators (WiFi/battery) on conversation list
|
|
static uint32_t last_status_update = 0;
|
|
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 (_navigation.current() == Route::STATUS && _status_screen) {
|
|
_status_screen->refresh();
|
|
}
|
|
}
|
|
|
|
// Drain coalesced conversation-list refresh requests. Only when
|
|
// the user is actually viewing the list — if they're in chat or
|
|
// settings, the list rebuilds the next time they navigate back
|
|
// (show_conversation_list already calls refresh()). Throttle to
|
|
// at most once every 750ms even when the screen IS visible so
|
|
// the SPI flush isn't saturated by sustained inbound traffic.
|
|
static constexpr uint32_t COALESCE_MS = 750;
|
|
if (_pending_conversation_refresh
|
|
&& _navigation.current() == Route::MESSAGES
|
|
&& (now - _last_conversation_refresh_ms) >= COALESCE_MS) {
|
|
_pending_conversation_refresh = false;
|
|
_last_conversation_refresh_ms = now;
|
|
if (_conversation_list_screen) {
|
|
_conversation_list_screen->refresh();
|
|
}
|
|
}
|
|
}
|
|
|
|
void UIManager::hide_all_screens() {
|
|
if (_home_screen) _home_screen->hide();
|
|
if (_network_screen) _network_screen->hide();
|
|
if (_nomadnet_screen) _nomadnet_screen->hide();
|
|
if (_conversation_list_screen) _conversation_list_screen->hide();
|
|
if (_chat_screen) _chat_screen->hide();
|
|
if (_compose_screen) _compose_screen->hide();
|
|
if (_announce_list_screen) _announce_list_screen->hide();
|
|
if (_status_screen) _status_screen->hide();
|
|
if (_radio_activity_screen) _radio_activity_screen->hide();
|
|
if (_qr_screen) _qr_screen->hide();
|
|
if (_settings_screen) _settings_screen->hide();
|
|
if (_propagation_nodes_screen) _propagation_nodes_screen->hide();
|
|
_location_share_screen->hide();
|
|
if (_call_screen) _call_screen->hide();
|
|
if (_map_screen) _map_screen->hide();
|
|
}
|
|
|
|
void UIManager::show_home() {
|
|
home();
|
|
}
|
|
|
|
void UIManager::show_network() {
|
|
navigate(Route::NETWORK);
|
|
}
|
|
|
|
void UIManager::show_nomadnet() {
|
|
nomad_heap_checkpoint("show-enter");
|
|
_nomad_history.clear();
|
|
_nomad_directory_refresh_pending.store(true, std::memory_order_release);
|
|
{
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->show_start();
|
|
}
|
|
navigate(Route::NOMADNET);
|
|
nomad_heap_checkpoint("show-ready");
|
|
}
|
|
|
|
void UIManager::navigate(Route route) {
|
|
const bool leaving_nomadnet = _navigation.current() == Route::NOMADNET && route != Route::NOMADNET;
|
|
if (leaving_nomadnet) {
|
|
nomad_advance_navigation_generation();
|
|
_nomad_cache_flow.cancel();
|
|
_nomad_state = NomadState::IDLE;
|
|
_nomad_mailbox.seal();
|
|
}
|
|
{
|
|
LVGL_LOCK();
|
|
_navigation.navigate(route);
|
|
render_route(route);
|
|
}
|
|
if (leaving_nomadnet) nomad_stop_transport();
|
|
}
|
|
|
|
void UIManager::replace_route(Route route) {
|
|
LVGL_LOCK();
|
|
_navigation.replace(route);
|
|
render_route(route);
|
|
}
|
|
|
|
void UIManager::back() {
|
|
if (_navigation.current() == Route::NOMADNET) {
|
|
_nomad_actions.publish(NomadNet::UserActionKind::BACK, {});
|
|
return;
|
|
}
|
|
LVGL_LOCK();
|
|
if (!_navigation.back()) return;
|
|
render_route(_navigation.current());
|
|
}
|
|
|
|
void UIManager::nomad_back_empty() {
|
|
if (_navigation.current() == Route::NOMADNET) {
|
|
nomad_advance_navigation_generation();
|
|
bool handled = false;
|
|
{
|
|
LVGL_LOCK();
|
|
handled = _nomadnet_screen->handle_library_back();
|
|
}
|
|
if (handled) {
|
|
_nomad_history.clear();
|
|
nomad_stop_transport();
|
|
_nomad_directory_refresh_pending.store(true, std::memory_order_release);
|
|
return;
|
|
}
|
|
_nomad_cache_flow.cancel();
|
|
_nomad_state = NomadState::IDLE;
|
|
_nomad_mailbox.seal();
|
|
}
|
|
const bool leaving_nomadnet = _navigation.current() == Route::NOMADNET;
|
|
{
|
|
LVGL_LOCK();
|
|
if (!_navigation.back()) return;
|
|
render_route(_navigation.current());
|
|
}
|
|
if (leaving_nomadnet) nomad_stop_transport();
|
|
}
|
|
|
|
void UIManager::home() {
|
|
const bool leaving_nomadnet = _navigation.current() == Route::NOMADNET;
|
|
if (leaving_nomadnet) {
|
|
nomad_advance_navigation_generation();
|
|
_nomad_cache_flow.cancel();
|
|
_nomad_state = NomadState::IDLE;
|
|
_nomad_mailbox.seal();
|
|
}
|
|
{
|
|
LVGL_LOCK();
|
|
_navigation.home();
|
|
render_route(Route::HOME);
|
|
}
|
|
if (leaving_nomadnet) nomad_stop_transport();
|
|
}
|
|
|
|
void UIManager::render_route(Route route) {
|
|
// This is the only place that changes screen visibility. Every hide()
|
|
// removes its focus objects before the selected screen adds its own.
|
|
hide_all_screens();
|
|
switch (route) {
|
|
case Route::HOME:
|
|
_home_screen->show();
|
|
break;
|
|
case Route::MESSAGES:
|
|
_conversation_list_screen->refresh();
|
|
_pending_conversation_refresh = false;
|
|
_last_conversation_refresh_ms = millis();
|
|
_conversation_list_screen->show();
|
|
break;
|
|
case Route::MAP:
|
|
_map_screen->show();
|
|
break;
|
|
case Route::LOCATION_SHARING:
|
|
_location_share_screen->show();
|
|
break;
|
|
case Route::CHAT:
|
|
_chat_screen->load_conversation(_current_peer_hash, _store);
|
|
_chat_screen->show();
|
|
break;
|
|
case Route::COMPOSE:
|
|
_compose_screen->clear();
|
|
_compose_screen->show();
|
|
break;
|
|
case Route::NETWORK:
|
|
_network_screen->show();
|
|
break;
|
|
case Route::ANNOUNCES:
|
|
_announce_list_screen->refresh();
|
|
_announce_list_screen->show();
|
|
break;
|
|
case Route::STATUS:
|
|
_status_screen->refresh();
|
|
_status_screen->show();
|
|
break;
|
|
case Route::RADIO_ACTIVITY:
|
|
_radio_activity_screen->show();
|
|
break;
|
|
case Route::QR:
|
|
_qr_screen->show();
|
|
break;
|
|
case Route::PROPAGATION_NODES:
|
|
_propagation_nodes_screen->show();
|
|
break;
|
|
case Route::NOMADNET:
|
|
_nomadnet_screen->show();
|
|
break;
|
|
case Route::SETTINGS:
|
|
_settings_screen->refresh();
|
|
_settings_screen->show();
|
|
break;
|
|
case Route::CALL: if (_call_screen) _call_screen->show(); break;
|
|
}
|
|
}
|
|
|
|
void UIManager::show_conversation_list() {
|
|
INFO("Showing conversation list");
|
|
navigate(Route::MESSAGES);
|
|
}
|
|
|
|
void UIManager::show_chat(const Bytes& peer_hash) {
|
|
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;
|
|
navigate(Route::CHAT);
|
|
}
|
|
|
|
void UIManager::show_location_sharing(const Bytes& peer_hash) {
|
|
if (peer_hash.size() != Telemetry::PEER_ID_SIZE ||
|
|
!_current_peer_hash || peer_hash != _current_peer_hash) {
|
|
WARNING("Refusing location controls for invalid or non-current peer");
|
|
return;
|
|
}
|
|
if (!_location_share_screen->open_for_peer(
|
|
peer_hash.data(), peer_hash.size(), nullptr)) {
|
|
WARNING("Refusing location controls: peer must be exactly 16 bytes");
|
|
return;
|
|
}
|
|
if (!_location_share_commands.requestQuery(peer_hash.data(), peer_hash.size())) {
|
|
WARNING("Location controls busy; current status will refresh on retry");
|
|
}
|
|
navigate(Route::LOCATION_SHARING);
|
|
}
|
|
|
|
void UIManager::on_back_from_location_sharing() {
|
|
// _current_peer_hash is retained while controls are open, so Back always
|
|
// returns to the exact chat that opened this peer-scoped screen.
|
|
back();
|
|
}
|
|
|
|
void UIManager::show_compose() {
|
|
INFO("Showing compose screen");
|
|
navigate(Route::COMPOSE);
|
|
}
|
|
|
|
void UIManager::show_announces() {
|
|
INFO("Showing announces screen");
|
|
navigate(Route::ANNOUNCES);
|
|
}
|
|
|
|
void UIManager::show_status() {
|
|
LVGL_LOCK();
|
|
INFO("Showing status screen");
|
|
|
|
// Build propagation node display string
|
|
if (_propagation_manager) {
|
|
Preferences prefs;
|
|
prefs.begin(NVS_NAMESPACE, true);
|
|
bool auto_select = prefs.getBool(KEY_AUTO_SELECT, true);
|
|
|
|
Bytes saved_hash;
|
|
size_t hash_len = prefs.getBytesLength(KEY_NODE_HASH);
|
|
if (hash_len > 0 && hash_len <= 32) {
|
|
uint8_t buf[32];
|
|
prefs.getBytes(KEY_NODE_HASH, buf, hash_len);
|
|
saved_hash = Bytes(buf, hash_len);
|
|
}
|
|
prefs.end();
|
|
|
|
Bytes effective = auto_select ? _propagation_manager->get_effective_node() : saved_hash;
|
|
|
|
String display;
|
|
if (auto_select) {
|
|
if (effective.size() > 0) {
|
|
auto info = _propagation_manager->get_node(effective);
|
|
if (info && !info.name.empty()) {
|
|
display = "Auto (" + String(info.name.c_str()) + ")";
|
|
} else {
|
|
display = "Auto (" + String(effective.toHex().substr(0, 12).c_str()) + "...)";
|
|
}
|
|
} else {
|
|
display = "Auto";
|
|
}
|
|
} else {
|
|
if (effective.size() > 0) {
|
|
auto info = _propagation_manager->get_node(effective);
|
|
if (info && !info.name.empty()) {
|
|
display = String(info.name.c_str());
|
|
} else {
|
|
display = String(effective.toHex().substr(0, 12).c_str()) + "...";
|
|
}
|
|
} else {
|
|
display = "None";
|
|
}
|
|
}
|
|
_status_screen->set_propagation_node(display);
|
|
}
|
|
|
|
navigate(Route::STATUS);
|
|
}
|
|
|
|
void UIManager::show_radio_activity() {
|
|
INFO("Showing Radio Activity screen");
|
|
navigate(Route::RADIO_ACTIVITY);
|
|
}
|
|
|
|
void UIManager::set_radio_activity_source(
|
|
std::function<RadioActivity::Snapshot()> snapshot_provider,
|
|
const RadioActivityScreen::RadioConfig& config) {
|
|
_radio_activity_snapshot_provider = std::move(snapshot_provider);
|
|
_radio_activity_config = config;
|
|
}
|
|
|
|
void UIManager::on_conversation_selected(const Bytes& peer_hash) {
|
|
show_chat(peer_hash);
|
|
}
|
|
|
|
void UIManager::on_new_message() {
|
|
show_compose();
|
|
}
|
|
|
|
void UIManager::show_map() {
|
|
INFO("Showing offline map");
|
|
navigate(Route::MAP);
|
|
}
|
|
|
|
void UIManager::on_back_from_map() {
|
|
back();
|
|
}
|
|
|
|
void UIManager::show_settings() {
|
|
INFO("Showing settings screen");
|
|
navigate(Route::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);
|
|
}
|
|
|
|
navigate(Route::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) {
|
|
_gps = gps;
|
|
if (_conversation_list_screen) {
|
|
_conversation_list_screen->set_gps(gps);
|
|
}
|
|
}
|
|
|
|
Telemetry::LocationConsentResult UIManager::start_location_sharing(
|
|
const Bytes& peer_hash,
|
|
const Telemetry::ShareStartOptions& options) {
|
|
Telemetry::PeerId peer{};
|
|
if (!peerIdFromHash(peer_hash, peer)) {
|
|
return Telemetry::LocationConsentResult::INVALID_ARGUMENT;
|
|
}
|
|
if (!_location_persistence_controller) {
|
|
return Telemetry::LocationConsentResult::NOT_READY;
|
|
}
|
|
const uint64_t wall_now =
|
|
static_cast<uint64_t>(RNS::Utilities::OS::ltime());
|
|
const uint64_t monotonic_now = monotonicMillis();
|
|
_location_persistence_controller->service(wall_now, monotonic_now);
|
|
return _location_persistence_controller->startSharing(
|
|
peer, options, wall_now, monotonic_now);
|
|
}
|
|
|
|
Telemetry::LocationConsentResult UIManager::stop_location_sharing(
|
|
const Bytes& peer_hash) {
|
|
Telemetry::PeerId peer{};
|
|
if (!peerIdFromHash(peer_hash, peer)) {
|
|
return Telemetry::LocationConsentResult::INVALID_ARGUMENT;
|
|
}
|
|
if (!_location_persistence_controller) {
|
|
return Telemetry::LocationConsentResult::NOT_READY;
|
|
}
|
|
const uint64_t wall_now =
|
|
static_cast<uint64_t>(RNS::Utilities::OS::ltime());
|
|
const uint64_t monotonic_now = monotonicMillis();
|
|
_location_persistence_controller->service(wall_now, monotonic_now);
|
|
return _location_persistence_controller->stopSharing(
|
|
peer, wall_now, monotonic_now);
|
|
}
|
|
|
|
bool UIManager::get_location_share_session(
|
|
const Bytes& peer_hash,
|
|
Telemetry::ShareSession& output) const {
|
|
Telemetry::PeerId peer{};
|
|
return peerIdFromHash(peer_hash, peer) && _location_shares.get(peer, output);
|
|
}
|
|
|
|
void UIManager::on_back_to_conversation_list() {
|
|
back();
|
|
}
|
|
|
|
bool UIManager::on_send_message_from_chat(const String& content) {
|
|
return send_message(_current_peer_hash, content);
|
|
}
|
|
|
|
void UIManager::on_call_from_chat() {
|
|
if (!_current_peer_hash) return;
|
|
if (_current_peer_hash.size() != CallStartMailbox::PeerHash{}.size()) {
|
|
WARNING("LXST: Call peer hash is not exactly 16 bytes");
|
|
return;
|
|
}
|
|
|
|
CallStartMailbox::PeerHash peerHash{};
|
|
for (size_t i = 0; i < peerHash.size(); ++i) {
|
|
peerHash[i] = _current_peer_hash[i];
|
|
}
|
|
if (!_call_starts.request(peerHash)) {
|
|
WARNING("LXST: Call start already pending");
|
|
}
|
|
}
|
|
|
|
void UIManager::on_location_from_chat() {
|
|
if (_current_peer_hash.size() != Telemetry::PEER_ID_SIZE) {
|
|
WARNING("Location controls require an exact 16-byte current peer");
|
|
return;
|
|
}
|
|
show_location_sharing(_current_peer_hash);
|
|
}
|
|
|
|
bool UIManager::on_send_message_from_compose(const Bytes& dest_hash, const String& message) {
|
|
if (!send_message(dest_hash, message)) return false;
|
|
|
|
// Replace Compose with Chat so Back returns to Messages instead of
|
|
// reopening a cleared compose form.
|
|
_current_peer_hash = dest_hash;
|
|
replace_route(Route::CHAT);
|
|
return true;
|
|
}
|
|
|
|
void UIManager::on_cancel_compose() {
|
|
back();
|
|
}
|
|
|
|
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() {
|
|
back();
|
|
}
|
|
|
|
void UIManager::on_back_from_status() {
|
|
back();
|
|
}
|
|
|
|
void UIManager::on_back_from_radio_activity() {
|
|
back();
|
|
}
|
|
|
|
void UIManager::on_share_from_status() {
|
|
navigate(Route::QR);
|
|
}
|
|
|
|
void UIManager::on_back_from_qr() {
|
|
back();
|
|
}
|
|
|
|
void UIManager::on_back_from_settings() {
|
|
back();
|
|
}
|
|
|
|
void UIManager::on_back_from_propagation_nodes() {
|
|
back();
|
|
}
|
|
|
|
void UIManager::on_propagation_node_selected(const Bytes& node_hash) {
|
|
RouterLock router_lock(0);
|
|
if (!router_lock.acquired()) {
|
|
WARNING("Router busy; propagation selection not applied");
|
|
return;
|
|
}
|
|
std::string hash_hex = node_hash.toHex().substr(0, 16);
|
|
std::string msg = "Propagation node selected: " + hash_hex + "...";
|
|
INFO(msg.c_str());
|
|
|
|
// Set the node in the router
|
|
_router.set_outbound_propagation_node(node_hash);
|
|
|
|
// Set stamp cost from node info if available
|
|
uint8_t stamp_cost = 0;
|
|
if (_propagation_manager) {
|
|
auto node_info = _propagation_manager->get_node(node_hash);
|
|
if (node_info) {
|
|
stamp_cost = node_info.stamp_cost;
|
|
}
|
|
}
|
|
_router.set_outbound_propagation_stamp_cost(stamp_cost);
|
|
|
|
// Proactively request path if we don't have one
|
|
if (!Transport::has_path(node_hash)) {
|
|
DEBUG("Requesting path for propagation node");
|
|
Transport::request_path(node_hash);
|
|
}
|
|
|
|
// Save to NVS
|
|
Preferences prefs;
|
|
prefs.begin(NVS_NAMESPACE, false);
|
|
prefs.putBool(KEY_AUTO_SELECT, false);
|
|
prefs.putBytes(KEY_NODE_HASH, node_hash.data(), node_hash.size());
|
|
prefs.putUChar(KEY_STAMP_COST, stamp_cost);
|
|
prefs.end();
|
|
DEBUG("Propagation node saved to NVS");
|
|
}
|
|
|
|
void UIManager::on_propagation_auto_select_changed(bool enabled) {
|
|
RouterLock router_lock(0);
|
|
if (!router_lock.acquired()) {
|
|
WARNING("Router busy; propagation mode not applied");
|
|
return;
|
|
}
|
|
std::string msg = "Propagation auto-select changed: ";
|
|
msg += enabled ? "enabled" : "disabled";
|
|
INFO(msg.c_str());
|
|
|
|
if (enabled) {
|
|
// Clear manual selection, router will use best node
|
|
_router.set_outbound_propagation_node(Bytes());
|
|
}
|
|
|
|
// Save to NVS
|
|
Preferences prefs;
|
|
prefs.begin(NVS_NAMESPACE, false);
|
|
prefs.putBool(KEY_AUTO_SELECT, enabled);
|
|
if (enabled) {
|
|
prefs.remove(KEY_NODE_HASH);
|
|
prefs.remove(KEY_STAMP_COST);
|
|
}
|
|
prefs.end();
|
|
DEBUG("Propagation auto-select saved to NVS");
|
|
}
|
|
|
|
void UIManager::on_propagation_sync() {
|
|
RouterLock router_lock(0);
|
|
if (!router_lock.acquired()) {
|
|
WARNING("Router busy; sync request not queued");
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
bool 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());
|
|
|
|
// Pre-graft: Identity::mark_persistent(dest_hash) — fork-only API for
|
|
// the 5s fast-flush semantics. Vanilla upstream relies on microStore's
|
|
// dirty-tracking + reticulum->should_persist_data() to decide what
|
|
// gets written. If we observe lost contacts after crashes, revisit
|
|
// microStore flush cadence rather than re-adding the fork API.
|
|
// (void)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");
|
|
}
|
|
|
|
// UI messages prefer single-packet opportunistic delivery on LoRa. The
|
|
// router automatically promotes messages that exceed the LoRa packet MDU
|
|
// to DIRECT, so this preserves large-message support without forcing every
|
|
// short message through the heavier link/resource path.
|
|
::LXMF::LXMessage message(
|
|
destination,
|
|
source,
|
|
content_bytes,
|
|
title,
|
|
::LXMF::Type::Message::OPPORTUNISTIC
|
|
);
|
|
|
|
// 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();
|
|
|
|
// Reject router contention or queue exhaustion before persistence. The
|
|
// admission guard commits the final packed/stamped message immediately
|
|
// before queue ownership transfer while RouterLock prevents a concurrent
|
|
// producer from consuming the checked capacity. This callback runs on
|
|
// LVGL's 8 KiB task, so MessageStore keeps its rollback snapshot
|
|
// object-owned rather than local to save_message().
|
|
RouterLock router_lock(0);
|
|
if (!router_lock.acquired()) {
|
|
WARNING("Router busy; outgoing message retained for retry");
|
|
return false;
|
|
}
|
|
|
|
OutboundPersistenceContext persistence_context{&_store, &message};
|
|
::LXMF::OutboundAdmissionResult admission;
|
|
try {
|
|
admission = _router.try_handle_outbound(
|
|
message, persistOutgoingMessage, &persistence_context);
|
|
} catch (const std::exception& error) {
|
|
WARNINGF("Outgoing message preparation failed: %s", error.what());
|
|
return false;
|
|
}
|
|
|
|
if (admission == ::LXMF::OutboundAdmissionResult::GUARD_REJECTED) {
|
|
ERROR("Outgoing message persistence failed; message not queued");
|
|
show_storage_error("Storage is unavailable. The message was not sent.");
|
|
return false;
|
|
}
|
|
if (admission != ::LXMF::OutboundAdmissionResult::ACCEPTED) {
|
|
WARNING("Outbound queue full; outgoing message retained for retry");
|
|
return false;
|
|
}
|
|
|
|
if (_navigation.current() == Route::CHAT && _current_peer_hash == dest_hash) {
|
|
_chat_screen->add_message(message, true);
|
|
}
|
|
|
|
INFO(" Message queued for delivery");
|
|
return true;
|
|
}
|
|
|
|
void UIManager::on_message_received(::LXMF::LXMessage& message) {
|
|
// Don't take LVGL_LOCK across the LittleFS write — under sustained
|
|
// LXMF receive load (eg propagation soak), LittleFS compaction can
|
|
// stall the save for several seconds. While the lock was held the
|
|
// Arduino loop's `UIManager::update()` would assert at the 5s
|
|
// LVGL_LOCK timeout (LVGLLock.h:45), tipping pyxis into a panic
|
|
// reset. Scope the lock to ONLY the UI mutations below.
|
|
std::string source_hex = message.source_hash().toHex().substr(0, 8);
|
|
std::string msg = "Message received from " + source_hex + "...";
|
|
INFO(msg.c_str());
|
|
|
|
#ifdef PYXIS_TEST_HOOKS
|
|
pyxis_test_hook_record_rx(message);
|
|
#endif
|
|
|
|
// Classify authenticated location fields before any conversation write.
|
|
// Raw field keys and values are MessagePack spans retained by LXMessage.
|
|
Telemetry::InboundLocationMessage inbound{};
|
|
const RNS::Bytes& source_hash = message.source_hash();
|
|
if (message.signature_validated() &&
|
|
source_hash.size() == Telemetry::PEER_ID_SIZE) {
|
|
std::memcpy(inbound.authenticated_sender.bytes,
|
|
source_hash.data(), Telemetry::PEER_ID_SIZE);
|
|
|
|
static const uint8_t telemetry_key_bytes[] = {
|
|
Telemetry::FIELD_TELEMETRY};
|
|
static const uint8_t custom_meta_key_bytes[] = {
|
|
0xccU, Telemetry::FIELD_CUSTOM_META};
|
|
const RNS::Bytes telemetry_key(
|
|
telemetry_key_bytes, sizeof(telemetry_key_bytes));
|
|
const RNS::Bytes custom_meta_key(
|
|
custom_meta_key_bytes, sizeof(custom_meta_key_bytes));
|
|
const RNS::Bytes* telemetry_value = message.fields_get(telemetry_key);
|
|
const RNS::Bytes* custom_meta_value = message.fields_get(custom_meta_key);
|
|
if (telemetry_value != nullptr) {
|
|
inbound.telemetry.present = true;
|
|
inbound.telemetry.raw_value = Telemetry::BinaryView{
|
|
telemetry_value->data(), telemetry_value->size()};
|
|
}
|
|
if (custom_meta_value != nullptr) {
|
|
inbound.custom_meta.present = true;
|
|
inbound.custom_meta.raw_value = Telemetry::BinaryView{
|
|
custom_meta_value->data(), custom_meta_value->size()};
|
|
}
|
|
const RNS::Bytes& title = message.title();
|
|
const RNS::Bytes& content = message.content();
|
|
inbound.title = Telemetry::TextField(
|
|
true, title.data(), title.size());
|
|
inbound.content = Telemetry::TextField(
|
|
true, content.data(), content.size());
|
|
inbound.received_at_millis = RNS::Utilities::OS::ltime();
|
|
|
|
const Telemetry::LocationMessageDecision location_decision =
|
|
Telemetry::classifyInboundLocationMessage(inbound);
|
|
if (location_decision.log_malformed) {
|
|
WARNING("Malformed inbound location field ignored");
|
|
}
|
|
if (location_decision.apply_location) {
|
|
const uint64_t location_wall_now =
|
|
static_cast<uint64_t>(RNS::Utilities::OS::ltime());
|
|
const uint64_t location_monotonic_now = monotonicMillis();
|
|
const Telemetry::LocationControllerState location_state =
|
|
_location_persistence_controller
|
|
? _location_persistence_controller->service(
|
|
location_wall_now, location_monotonic_now)
|
|
: Telemetry::LocationControllerState::BLOCKED;
|
|
if (location_state == Telemetry::LocationControllerState::READY) {
|
|
const Telemetry::PeerLocationResult location_result =
|
|
_peer_locations.apply(
|
|
location_decision.authenticated_sender,
|
|
location_decision.location,
|
|
location_decision.meta,
|
|
location_decision.received_at_millis);
|
|
if (location_result != Telemetry::PeerLocationResult::STALE &&
|
|
location_result != Telemetry::PeerLocationResult::NOT_FOUND &&
|
|
location_result != Telemetry::PeerLocationResult::INVALID_ARGUMENT) {
|
|
_location_persistence_controller->service(
|
|
location_wall_now, location_monotonic_now);
|
|
}
|
|
} else {
|
|
WARNING("Location state not restored; inbound update ignored");
|
|
}
|
|
}
|
|
if (!location_decision.persist) {
|
|
INFO(" Location telemetry processed without chat persistence");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Pre-graft: RNS::Identity::mark_persistent — fork-only. See note above.
|
|
// (void)RNS::Identity::mark_persistent(message.source_hash());
|
|
|
|
// Save to store — no LVGL lock; LittleFS GC is allowed to take its
|
|
// time without freezing the UI thread. Do not present an in-memory-only
|
|
// message as durable conversation history.
|
|
if (!_store.save_message(message)) {
|
|
ERROR("Incoming message persistence failed; message not added to history");
|
|
LVGL_LOCK();
|
|
show_storage_error("An incoming message could not be saved. Check device storage.");
|
|
return;
|
|
}
|
|
|
|
// Take the LVGL lock now for the UI-touching code below.
|
|
LVGL_LOCK();
|
|
|
|
// Update UI if we're viewing this conversation
|
|
bool viewing_this_chat = (_navigation.current() == Route::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
|
|
}
|
|
}
|
|
|
|
// Coalesce list refreshes — if 50 propagation messages land
|
|
// back-to-back we used to redraw the list 50 times and saturate
|
|
// the SPI flush + serial output. Just flag the pending refresh;
|
|
// call_update() / main loop drains it at most every COALESCE_MS
|
|
// and only when the user is actually on the conversation list.
|
|
_pending_conversation_refresh = true;
|
|
|
|
INFO(" Message processed");
|
|
}
|
|
|
|
void UIManager::on_message_delivered(::LXMF::LXMessage& message) {
|
|
std::string hash_hex = message.hash().toHex().substr(0, 8);
|
|
std::string msg = "Message delivered: " + hash_hex + "...";
|
|
INFO(msg.c_str());
|
|
|
|
LVGL_LOCK();
|
|
// Update UI if we're viewing this conversation
|
|
if (_navigation.current() == Route::CHAT && _current_peer_hash == message.destination_hash()) {
|
|
_chat_screen->update_message_status(message.hash(), true);
|
|
}
|
|
}
|
|
|
|
void UIManager::on_message_failed(::LXMF::LXMessage& message) {
|
|
std::string hash_hex = message.hash().toHex().substr(0, 8);
|
|
std::string msg = "Message delivery failed: " + hash_hex + "...";
|
|
WARNING(msg.c_str());
|
|
|
|
if (!_store.update_message_state(
|
|
message.hash(), ::LXMF::Type::Message::State::FAILED)) {
|
|
ERROR("Failed message state persistence failed");
|
|
LVGL_LOCK();
|
|
show_storage_error("Delivery failure status could not be saved. Check device storage.");
|
|
return;
|
|
}
|
|
|
|
LVGL_LOCK();
|
|
// Update UI if we're viewing this conversation
|
|
if (_navigation.current() == Route::CHAT && _current_peer_hash == message.destination_hash()) {
|
|
_chat_screen->update_message_status(message.hash(), false);
|
|
}
|
|
}
|
|
|
|
void UIManager::refresh_current_screen() {
|
|
LVGL_LOCK();
|
|
switch (_navigation.current()) {
|
|
case Route::MESSAGES:
|
|
_conversation_list_screen->refresh();
|
|
break;
|
|
case Route::CHAT:
|
|
_chat_screen->refresh();
|
|
break;
|
|
case Route::COMPOSE:
|
|
// No refresh needed
|
|
break;
|
|
case Route::ANNOUNCES:
|
|
_announce_list_screen->refresh();
|
|
break;
|
|
case Route::STATUS:
|
|
_status_screen->refresh();
|
|
break;
|
|
case Route::SETTINGS:
|
|
_settings_screen->refresh();
|
|
break;
|
|
case Route::PROPAGATION_NODES:
|
|
_propagation_nodes_screen->refresh();
|
|
break;
|
|
case Route::CALL:
|
|
break;
|
|
case Route::QR:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── NomadNet browser transport ──
|
|
|
|
bool UIManager::nomad_load_library() {
|
|
NomadNet::ExternalVector<uint8_t> bytes;
|
|
NomadNet::Library loaded;
|
|
if (read_nomad_library_file(NOMAD_LIBRARY_PATH, bytes) &&
|
|
loaded.decode(bytes.data(), bytes.size())) {
|
|
_nomad_library = std::move(loaded);
|
|
return true;
|
|
}
|
|
bytes.clear();
|
|
if (read_nomad_library_file(NOMAD_LIBRARY_BACKUP, bytes) &&
|
|
loaded.decode(bytes.data(), bytes.size())) {
|
|
_nomad_library = std::move(loaded);
|
|
WARNING("Recovered NomadNet library from backup");
|
|
return true;
|
|
}
|
|
bytes.clear();
|
|
if (read_nomad_library_file(NOMAD_LIBRARY_TMP, bytes) &&
|
|
loaded.decode(bytes.data(), bytes.size())) {
|
|
_nomad_library = std::move(loaded);
|
|
// Primary and backup were already rejected above. Promote the valid
|
|
// interrupted generation now so the next save never deletes its only
|
|
// durable copy before writing a replacement.
|
|
bool promoted = true;
|
|
if (LittleFS.exists(NOMAD_LIBRARY_PATH)) promoted = LittleFS.remove(NOMAD_LIBRARY_PATH);
|
|
if (promoted) promoted = LittleFS.rename(NOMAD_LIBRARY_TMP, NOMAD_LIBRARY_PATH);
|
|
_nomad_library_dirty = !promoted;
|
|
WARNING(promoted ? "Recovered interrupted NomadNet library write" :
|
|
"Using retained NomadNet temp generation; promotion pending");
|
|
return true;
|
|
}
|
|
bytes.clear();
|
|
if (read_nomad_library_file(NOMAD_LIBRARY_STAGE, bytes) &&
|
|
loaded.decode(bytes.data(), bytes.size())) {
|
|
_nomad_library = std::move(loaded);
|
|
bool promoted = true;
|
|
if (LittleFS.exists(NOMAD_LIBRARY_PATH)) promoted = LittleFS.remove(NOMAD_LIBRARY_PATH);
|
|
if (promoted) promoted = LittleFS.rename(NOMAD_LIBRARY_STAGE, NOMAD_LIBRARY_PATH);
|
|
_nomad_library_dirty = !promoted;
|
|
WARNING(promoted ? "Recovered staged NomadNet library write" :
|
|
"Using retained NomadNet stage generation; promotion pending");
|
|
return true;
|
|
}
|
|
bytes.clear();
|
|
if (read_nomad_library_file(NOMAD_LIBRARY_OLD, bytes) &&
|
|
loaded.decode(bytes.data(), bytes.size())) {
|
|
_nomad_library = std::move(loaded);
|
|
_nomad_library_dirty = true;
|
|
WARNING("Recovered NomadNet library from retained generation");
|
|
return true;
|
|
}
|
|
// No file is the normal first-boot state. Never format or remove unrelated
|
|
// LittleFS content when the index is absent or malformed.
|
|
return !LittleFS.exists(NOMAD_LIBRARY_PATH) && !LittleFS.exists(NOMAD_LIBRARY_BACKUP);
|
|
}
|
|
|
|
bool UIManager::nomad_save_library() {
|
|
const auto bytes = _nomad_library.encode();
|
|
if (bytes.empty()) return false;
|
|
if (!LittleFS.exists(NOMAD_LIBRARY_DIR) && !LittleFS.mkdir(NOMAD_LIBRARY_DIR)) return false;
|
|
const auto file_valid = [](const char* path) {
|
|
NomadNet::ExternalVector<uint8_t> stored;
|
|
NomadNet::Library parsed;
|
|
return read_nomad_library_file(path, stored) && parsed.decode(stored.data(), stored.size());
|
|
};
|
|
const bool had_primary = LittleFS.exists(NOMAD_LIBRARY_PATH);
|
|
const bool had_backup = LittleFS.exists(NOMAD_LIBRARY_BACKUP);
|
|
const bool primary_valid = file_valid(NOMAD_LIBRARY_PATH);
|
|
const bool backup_valid = file_valid(NOMAD_LIBRARY_BACKUP);
|
|
const bool old_valid = file_valid(NOMAD_LIBRARY_OLD);
|
|
const bool committed_valid = primary_valid || backup_valid || old_valid;
|
|
const bool temp_valid = file_valid(NOMAD_LIBRARY_TMP);
|
|
const bool stage_valid = file_valid(NOMAD_LIBRARY_STAGE);
|
|
// Never overwrite the sole valid recovery generation. Alternate staging
|
|
// names when an interrupted write is the only durable copy.
|
|
const char* stage_path = NOMAD_LIBRARY_TMP;
|
|
if (!committed_valid && temp_valid) stage_path = NOMAD_LIBRARY_STAGE;
|
|
else if (!committed_valid && stage_valid) stage_path = NOMAD_LIBRARY_TMP;
|
|
|
|
if (LittleFS.exists(stage_path) && !LittleFS.remove(stage_path)) return false;
|
|
File file = LittleFS.open(stage_path, "w");
|
|
if (!file) return false;
|
|
const std::size_t written = file.write(bytes.data(), bytes.size());
|
|
file.flush();
|
|
file.close();
|
|
if (written != bytes.size()) { LittleFS.remove(stage_path); return false; }
|
|
|
|
NomadNet::ExternalVector<uint8_t> verify_bytes;
|
|
NomadNet::Library verified;
|
|
if (!read_nomad_library_file(stage_path, verify_bytes) ||
|
|
verify_bytes.size() != bytes.size() ||
|
|
!std::equal(bytes.begin(), bytes.end(), verify_bytes.begin()) ||
|
|
!verified.decode(verify_bytes.data(), verify_bytes.size())) {
|
|
LittleFS.remove(stage_path);
|
|
return false;
|
|
}
|
|
|
|
bool backup_now_valid = backup_valid;
|
|
if (primary_valid) {
|
|
// Retain the previous backup as a third generation until the new
|
|
// primary passes exact read-back. Every interruption point retains at
|
|
// least one prior generation in primary, backup, or old.
|
|
if (LittleFS.exists(NOMAD_LIBRARY_OLD) && !LittleFS.remove(NOMAD_LIBRARY_OLD)) return false;
|
|
if (had_backup && !LittleFS.rename(NOMAD_LIBRARY_BACKUP, NOMAD_LIBRARY_OLD)) return false;
|
|
if (!LittleFS.rename(NOMAD_LIBRARY_PATH, NOMAD_LIBRARY_BACKUP)) {
|
|
if (had_backup) LittleFS.rename(NOMAD_LIBRARY_OLD, NOMAD_LIBRARY_BACKUP);
|
|
return false;
|
|
}
|
|
backup_now_valid = true;
|
|
} else if (had_primary) {
|
|
// Never rotate a corrupt primary over a potentially valid backup or
|
|
// retained generation. The validated temp is already durable enough
|
|
// to promote, so only the known-corrupt primary is discarded.
|
|
if (!LittleFS.remove(NOMAD_LIBRARY_PATH)) return false;
|
|
}
|
|
if (!LittleFS.rename(stage_path, NOMAD_LIBRARY_PATH)) return false;
|
|
verify_bytes.clear();
|
|
if (!read_nomad_library_file(NOMAD_LIBRARY_PATH, verify_bytes) ||
|
|
verify_bytes.size() != bytes.size() ||
|
|
!std::equal(bytes.begin(), bytes.end(), verify_bytes.begin()) ||
|
|
!verified.decode(verify_bytes.data(), verify_bytes.size())) {
|
|
LittleFS.remove(NOMAD_LIBRARY_PATH);
|
|
return false;
|
|
}
|
|
// If recovery came from OLD while backup was corrupt, retain that known
|
|
// good generation as the backup instead of deleting the last prior copy.
|
|
if (!backup_now_valid && old_valid && LittleFS.exists(NOMAD_LIBRARY_OLD)) {
|
|
if (LittleFS.exists(NOMAD_LIBRARY_BACKUP)) LittleFS.remove(NOMAD_LIBRARY_BACKUP);
|
|
backup_now_valid = LittleFS.rename(NOMAD_LIBRARY_OLD, NOMAD_LIBRARY_BACKUP);
|
|
}
|
|
if (backup_now_valid && LittleFS.exists(NOMAD_LIBRARY_OLD)) LittleFS.remove(NOMAD_LIBRARY_OLD);
|
|
const char* stale_stage = stage_path == NOMAD_LIBRARY_TMP ? NOMAD_LIBRARY_STAGE : NOMAD_LIBRARY_TMP;
|
|
if (LittleFS.exists(stale_stage)) LittleFS.remove(stale_stage);
|
|
return true;
|
|
}
|
|
|
|
void UIManager::nomad_hear_node(const Bytes& destination_hash, const Bytes& app_data) {
|
|
const std::string name = app_data
|
|
? NomadNet::sanitize_directory_name(app_data.data(), app_data.size()) : std::string{};
|
|
if (!_nomad_library.hear_node(destination_hash.toHex(), name,
|
|
static_cast<uint64_t>(RNS::Utilities::OS::time()),
|
|
Transport::hops_to(destination_hash))) return;
|
|
_nomad_library_dirty = true;
|
|
_nomad_directory_refresh_pending.store(true, std::memory_order_release);
|
|
}
|
|
|
|
void UIManager::nomad_refresh_nodes() {
|
|
nomad_heap_checkpoint("refresh-enter");
|
|
bool changed = false;
|
|
const auto& destinations = Transport::path_table();
|
|
for (auto it = destinations.begin(); it != destinations.end(); ++it) {
|
|
const Bytes& destination_hash = it->first;
|
|
// The enumerable mirror in pinned microReticulum is add-only, while
|
|
// has_path() consults the authoritative persistent routable store.
|
|
if (!Transport::has_path(destination_hash)) continue;
|
|
Identity identity = Identity::recall(destination_hash);
|
|
if (!identity) continue;
|
|
if (destination_hash != Destination::hash(identity, "nomadnetwork", "node")) continue;
|
|
const Bytes app_data = Identity::recall_app_data(destination_hash);
|
|
const std::string name = app_data
|
|
? NomadNet::sanitize_directory_name(app_data.data(), app_data.size()) : std::string{};
|
|
const auto& entry = it->second;
|
|
changed = _nomad_library.hear_node(destination_hash.toHex(), name,
|
|
static_cast<uint64_t>(entry._timestamp), static_cast<uint8_t>(entry._hops)) || changed;
|
|
}
|
|
nomad_heap_checkpoint("refresh-scanned");
|
|
if (!changed) return;
|
|
_nomad_library_dirty = true;
|
|
LVGL_LOCK();
|
|
nomad_heap_checkpoint("refresh-before-library-copy");
|
|
_nomadnet_screen->set_library(_nomad_library);
|
|
nomad_heap_checkpoint("refresh-after-library-copy");
|
|
}
|
|
|
|
void UIManager::nomad_update_library() {
|
|
// Filesystem persistence and directory scans are not required to complete
|
|
// an active Reticulum operation. Dirty/pending flags remain set and are
|
|
// serviced after terminal cleanup returns the state to IDLE.
|
|
if (nomad_operation_active()) return;
|
|
const uint32_t now = millis();
|
|
if (_navigation.current() == Route::NOMADNET && _nomadnet_screen->directory_visible() &&
|
|
now - _nomad_last_directory_refresh_ms >= 10000) {
|
|
_nomad_directory_refresh_pending.store(true, std::memory_order_release);
|
|
}
|
|
if (_nomad_directory_refresh_pending.exchange(false, std::memory_order_acq_rel)) {
|
|
_nomad_last_directory_refresh_ms = now;
|
|
nomad_refresh_nodes();
|
|
}
|
|
|
|
if (_nomad_library_dirty && now - _nomad_last_library_save_ms >= 5000) {
|
|
_nomad_last_library_save_ms = now;
|
|
if (nomad_save_library()) _nomad_library_dirty = false;
|
|
else WARNING("Could not persist NomadNet library; preserving existing file");
|
|
}
|
|
}
|
|
|
|
void UIManager::nomad_update_user_actions() {
|
|
class ScreenSubmissionSource final : public NomadNet::OwnerSubmissionSource {
|
|
public:
|
|
explicit ScreenSubmissionSource(NomadNetScreen& screen) : _screen(screen) {}
|
|
bool prepare_submission(uint16_t item_id, uint32_t generation,
|
|
std::string& target, NomadNet::ExternalVector<uint8_t>& bytes,
|
|
NomadNet::FormEncodeResult& result) override {
|
|
LVGL_LOCK();
|
|
return _screen.prepare_submission(item_id, generation, target, bytes, result);
|
|
}
|
|
private:
|
|
NomadNetScreen& _screen;
|
|
} source(*_nomadnet_screen);
|
|
// Back/Home is a terminal slot behind at most CAPACITY retained explicit
|
|
// Saves. Drain that bounded batch in one owner-loop pass so the terminal
|
|
// action cannot sit behind the synchronous library persistence below.
|
|
// Ordinary Open and standalone Save actions remain one-per-pass.
|
|
for (std::size_t serviced = 0; serviced < NomadNet::ActionMailbox::CAPACITY + 1; ++serviced) {
|
|
NomadNet::UserAction action;
|
|
if (!_nomad_actions.pop(action)) return;
|
|
nomad_heap_checkpoint("action-popped");
|
|
const std::string target = action.target();
|
|
nomad_heap_checkpoint("action-target-copied");
|
|
switch (action.kind) {
|
|
case NomadNet::UserActionKind::OPEN:
|
|
nomad_heap_checkpoint("action-before-open");
|
|
nomad_open(target);
|
|
break;
|
|
case NomadNet::UserActionKind::RELOAD:
|
|
case NomadNet::UserActionKind::BACK:
|
|
case NomadNet::UserActionKind::SUBMIT: {
|
|
int32_t current_scroll = 0;
|
|
{ LVGL_LOCK(); current_scroll = _nomadnet_screen->logical_scroll(); }
|
|
auto command = _nomad_owner.service(action, _nomad_history, source, current_scroll);
|
|
if (command.result != NomadNet::OwnerResult::REQUEST) {
|
|
if (action.kind == NomadNet::UserActionKind::BACK &&
|
|
command.result == NomadNet::OwnerResult::BACK_EMPTY) {
|
|
nomad_back_empty();
|
|
if (_navigation.current() != Route::NOMADNET) _nomad_actions.clear();
|
|
break;
|
|
}
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status(NomadNet::OwnerController::status(command.result));
|
|
break;
|
|
}
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_data.swap(command.request_data);
|
|
_nomad_submission_ready = !_nomad_submission_data.empty();
|
|
_nomad_pending_history = std::move(command.pending_history);
|
|
_nomad_cache_bypass_once = command.cache_bypass;
|
|
nomad_open(command.target, false, command.restore_scroll,
|
|
!_nomad_submission_data.empty(), true);
|
|
break;
|
|
}
|
|
case NomadNet::UserActionKind::SAVE: {
|
|
const bool save = !_nomad_library.page_saved(target);
|
|
if (!_nomad_library.set_page_saved(target, save)) break;
|
|
_nomad_library_dirty = true;
|
|
const bool current_page = !_nomad_url.destination_hex.empty() && _nomad_url.str() == target;
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_library(_nomad_library);
|
|
if (current_page) _nomadnet_screen->set_page_saved(save);
|
|
break;
|
|
}
|
|
case NomadNet::UserActionKind::IDENTIFY: {
|
|
if (_nomad_url.destination_hex.empty() || target != _nomad_url.str()) break;
|
|
const bool identified = action.item_id != 0;
|
|
if (!identified && _nomad_link_identified && !nomad_stop_transport()) {
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Could not switch active link to anonymous browsing");
|
|
break;
|
|
}
|
|
if (!_nomad_library.set_node_identified(_nomad_url.destination_hex, identified)) break;
|
|
_nomad_library_dirty = true;
|
|
if (identified && !_nomad_link_identified && _nomad_link &&
|
|
_nomad_link.status() == Type::Link::ACTIVE) {
|
|
RouterLock router_lock;
|
|
if (router_lock.acquired()) {
|
|
_nomad_link.identify(_router.identity());
|
|
_nomad_link_identified = true;
|
|
}
|
|
}
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_library(_nomad_library);
|
|
_nomadnet_screen->set_identify_enabled(identified);
|
|
_nomadnet_screen->set_status(identified
|
|
? "Identity enabled for this node" : "Anonymous browsing enabled for this node");
|
|
break;
|
|
}
|
|
|
|
case NomadNet::UserActionKind::HOME:
|
|
_nomad_actions.clear();
|
|
home();
|
|
break;
|
|
}
|
|
if ((action.kind != NomadNet::UserActionKind::SAVE &&
|
|
action.kind != NomadNet::UserActionKind::IDENTIFY) ||
|
|
!_nomad_actions.terminal_pending()) return;
|
|
}
|
|
}
|
|
|
|
void UIManager::service_nomad_user_action() {
|
|
nomad_update_user_actions();
|
|
}
|
|
|
|
bool UIManager::nomad_link_pending() const {
|
|
return _nomad_state == NomadState::LINK && _nomad_link &&
|
|
_nomad_link.status() != RNS::Type::Link::ACTIVE &&
|
|
_nomad_link.status() != RNS::Type::Link::CLOSED;
|
|
}
|
|
|
|
bool UIManager::nomad_operation_active() const {
|
|
return _nomad_state.load(std::memory_order_acquire) != NomadState::IDLE;
|
|
}
|
|
|
|
#if defined(PYXIS_TEST_HOOKS) || defined(PYXIS_NOMAD_LINK_DIAGNOSTIC)
|
|
bool UIManager::test_nomad_open(const std::string& address) {
|
|
return _nomad_actions.publish(NomadNet::UserActionKind::OPEN, address);
|
|
}
|
|
|
|
void UIManager::test_nomad_status() const {
|
|
const char* state = "IDLE";
|
|
switch (_nomad_state) {
|
|
case NomadState::CACHE: state = "CACHE"; break;
|
|
case NomadState::PATH: state = "PATH"; break;
|
|
case NomadState::LINK: state = "LINK"; break;
|
|
case NomadState::REQUEST: state = "REQUEST"; break;
|
|
case NomadState::IDLE: break;
|
|
}
|
|
const bool path = _nomad_destination_hash && Transport::has_path(_nomad_destination_hash);
|
|
const bool identity = _nomad_destination_hash && Identity::recall(_nomad_destination_hash);
|
|
const int link = _nomad_link ? static_cast<int>(_nomad_link.status()) : -1;
|
|
const int request = _nomad_request ? static_cast<int>(_nomad_request.get_status()) : -1;
|
|
Serial.printf(
|
|
"T:OK state=%s path=%d identity=%d link=%d request=%d response=%u "
|
|
"free=%u largest=%u minimum=%u\n",
|
|
state, path ? 1 : 0, identity ? 1 : 0, link, request,
|
|
static_cast<unsigned>(_nomad_response.size()),
|
|
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
|
|
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)),
|
|
static_cast<unsigned>(heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL)));
|
|
}
|
|
#endif
|
|
|
|
void UIManager::nomad_release_request() {
|
|
// request_timed_out() is the pinned dependency's only public pending-set
|
|
// erasure path, and it synchronously invokes the failed callback. Seal the
|
|
// callback mailbox first so successful terminal state cannot be replaced.
|
|
_nomad_mailbox.seal();
|
|
if (!_nomad_request) return;
|
|
RequestReceipt receipt = _nomad_request;
|
|
_nomad_request = RequestReceipt(Type::NONE);
|
|
// Pinned 6ac0d32 does not erase successful packet receipts or pending
|
|
// receipts on Link close. request_timed_out() is the only public erasure
|
|
// path. The sealed mailbox rejects the synthetic failed callback generated
|
|
// by this compatibility cleanup.
|
|
if (receipt.get_status() != Type::RequestReceipt::FAILED) {
|
|
receipt.request_timed_out(PacketReceipt(Type::NONE));
|
|
}
|
|
}
|
|
|
|
void UIManager::nomad_finish_request_keep_link() {
|
|
// A RESPONSE event arrives only after the dependency marks the receipt
|
|
// READY and removes it from Link::pending_requests(). Seal callback delivery
|
|
// before dropping our concluded receipt, but retain the ACTIVE Link so a
|
|
// same-destination page can issue its next anonymous request directly.
|
|
_nomad_mailbox.seal();
|
|
_nomad_request = RequestReceipt(Type::NONE);
|
|
_nomad_state = NomadState::IDLE;
|
|
_nomad_deadline_ms = 0;
|
|
nomad_heap_checkpoint("request-finished");
|
|
}
|
|
|
|
bool UIManager::nomad_stop_transport() {
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
_nomad_pending_history.clear();
|
|
// Link teardown can synchronously cancel an in-flight response Resource,
|
|
// update its shared RequestReceipt and invoke callbacks. Keep that mutation
|
|
// in the Reticulum serialization domain, and let teardown mark the receipt
|
|
// failed before the compatibility pending-set cleanup observes it.
|
|
RouterLock router_lock;
|
|
if (!router_lock.acquired()) return false;
|
|
nomad_heap_checkpoint("stop-enter");
|
|
_nomad_state = NomadState::IDLE;
|
|
_nomad_deadline_ms = 0;
|
|
_nomad_pending_scroll = -1;
|
|
_nomad_mailbox.seal();
|
|
if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown();
|
|
nomad_release_request();
|
|
nomad_heap_checkpoint("stop-request-released");
|
|
_nomad_link = Link(Type::NONE);
|
|
_nomad_link_identified = false;
|
|
nomad_heap_checkpoint("stop-link-released");
|
|
nomad_heap_checkpoint("stop-done");
|
|
return true;
|
|
}
|
|
|
|
bool UIManager::nomad_refresh_path_after_link_failure() {
|
|
if (_nomad_request_policy.on_link_timeout() !=
|
|
NomadNet::RequestPolicy::LinkTimeoutAction::REFRESH_PATH) return false;
|
|
nomad_heap_checkpoint("link-timeout-enter");
|
|
_nomad_mailbox.seal();
|
|
if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown();
|
|
nomad_release_request();
|
|
_nomad_link = Link(Type::NONE);
|
|
_nomad_link_identified = false;
|
|
nomad_heap_checkpoint("link-timeout-released");
|
|
Transport::expire_path(_nomad_destination_hash);
|
|
if (!NomadNet::RequestPolicy::path_invalidation_succeeded(
|
|
Transport::has_path(_nomad_destination_hash))) return false;
|
|
Transport::request_path(_nomad_destination_hash);
|
|
_nomad_state = NomadState::PATH;
|
|
_nomad_deadline_ms = millis() + NomadNet::RequestPolicy::PATH_WAIT_MS;
|
|
LVGL_LOCK();
|
|
if (_nomad_partial_controller.active())
|
|
_nomadnet_screen->set_partial_activity(true);
|
|
else
|
|
_nomadnet_screen->set_status("Refreshing stale path...");
|
|
return true;
|
|
}
|
|
|
|
uint32_t UIManager::nomad_advance_navigation_generation() {
|
|
_nomad_partial_scheduler.cancel(_nomad_navigation_generation);
|
|
_nomad_partial_controller.cancel();
|
|
_nomad_partial_request = NomadNet::PartialRequest{};
|
|
{
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_partial_activity(false);
|
|
}
|
|
++_nomad_navigation_generation;
|
|
if (_nomad_navigation_generation == 0) ++_nomad_navigation_generation;
|
|
return _nomad_navigation_generation;
|
|
}
|
|
|
|
bool UIManager::nomad_supersede_transport(const std::string& destination_hex) {
|
|
// Reconcile callback and Reticulum ownership before cache lookup. This does
|
|
// not touch the rendered model: a cache miss clears it only when live I/O
|
|
// actually begins, while a hit replaces it atomically at publication.
|
|
RouterLock router_lock;
|
|
if (!router_lock.acquired()) return false;
|
|
_nomad_state = NomadState::IDLE;
|
|
_nomad_deadline_ms = 0;
|
|
_nomad_mailbox.seal();
|
|
|
|
const bool retain_link = _nomad_link &&
|
|
_nomad_link.status() == Type::Link::ACTIVE &&
|
|
_nomad_destination_hash &&
|
|
bytes_equal_lower_hex(_nomad_destination_hash, destination_hex);
|
|
if (!retain_link && _nomad_link && _nomad_link.status() != Type::Link::CLOSED)
|
|
_nomad_link.teardown();
|
|
nomad_release_request();
|
|
_nomad_request = RequestReceipt(Type::NONE);
|
|
_nomad_response.release();
|
|
if (!retain_link) {
|
|
_nomad_link = Link(Type::NONE);
|
|
_nomad_link_identified = false;
|
|
_nomad_destination_hash = Bytes();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void UIManager::nomad_open(const std::string& address, bool add_history,
|
|
int32_t restore_logical_scroll, bool preserve_submission,
|
|
bool history_prepared) {
|
|
if (address.rfind("p:", 0) == 0) {
|
|
nomad_schedule_partial_ids(address, millis());
|
|
return;
|
|
}
|
|
if (!preserve_submission) {
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
}
|
|
nomad_heap_checkpoint("open-enter");
|
|
NomadNet::Url parsed;
|
|
std::string error;
|
|
bool parsed_ok = false;
|
|
try {
|
|
parsed_ok = NomadNet::Url::parse(address, parsed, error,
|
|
_nomad_url.destination_hex, _nomad_url.path, _nomad_url.fields);
|
|
} catch (const std::bad_alloc&) {
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
_nomad_pending_history.clear();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Address exceeds available memory");
|
|
return;
|
|
}
|
|
if (!parsed_ok) {
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
_nomad_pending_history.clear();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status(error.c_str());
|
|
return;
|
|
}
|
|
nomad_heap_checkpoint("open-parsed");
|
|
|
|
int32_t current_scroll=0;
|
|
bool page_loaded=false;
|
|
{
|
|
LVGL_LOCK();
|
|
current_scroll=_nomadnet_screen->logical_scroll();
|
|
page_loaded=_nomadnet_screen->page_loaded();
|
|
}
|
|
const bool restoring_history=restore_logical_scroll>=0;
|
|
if(!preserve_submission&&NomadNet::should_jump_locally(_nomad_url,parsed,page_loaded,restoring_history)){
|
|
NomadNet::LocalNavigationResult local_result =
|
|
NomadNet::LocalNavigationResult::STAGING_FAILED;
|
|
try {
|
|
// Materialize every fallible canonical owner value before transport,
|
|
// visible scroll/address, or history state is changed.
|
|
const std::string canonical_address = parsed.str();
|
|
NomadNet::Url next_url = parsed;
|
|
local_result = NomadNet::apply_local_navigation_transaction(
|
|
canonical_address, add_history, current_scroll,
|
|
restore_logical_scroll, history_prepared,
|
|
_nomad_history, _nomad_pending_history,
|
|
[&]() {
|
|
_nomad_cache_flow.cancel();
|
|
return nomad_supersede_transport(parsed.destination_hex);
|
|
},
|
|
[&](const std::string& published_address, int32_t restore_scroll) {
|
|
bool resolved = true;
|
|
LVGL_LOCK();
|
|
if (!_nomadnet_screen->set_local_address(published_address))
|
|
return false;
|
|
if (restore_scroll >= 0)
|
|
_nomadnet_screen->restore_logical_scroll(restore_scroll);
|
|
else
|
|
resolved = _nomadnet_screen->jump_to_anchor(parsed.fragment);
|
|
if (!resolved) return false;
|
|
_nomadnet_screen->set_status("Page loaded");
|
|
return true;
|
|
});
|
|
if (local_result == NomadNet::LocalNavigationResult::APPLIED) {
|
|
_nomad_url = std::move(next_url);
|
|
_nomad_pending_scroll = -1;
|
|
// A fragment jump keeps the same published page and partials.
|
|
return;
|
|
}
|
|
} catch (const std::bad_alloc&) {
|
|
_nomad_pending_history.clear();
|
|
local_result = NomadNet::LocalNavigationResult::STAGING_FAILED;
|
|
}
|
|
LVGL_LOCK();
|
|
if (local_result == NomadNet::LocalNavigationResult::PUBLICATION_FAILED &&
|
|
!parsed.fragment.empty()) {
|
|
const std::string status = "Unknown anchor: #" + parsed.fragment;
|
|
_nomadnet_screen->set_status(status.c_str());
|
|
} else if (local_result == NomadNet::LocalNavigationResult::PREPARATION_FAILED) {
|
|
_nomadnet_screen->set_status("Navigation is busy; try again");
|
|
} else {
|
|
_nomadnet_screen->set_status("Form history exceeds available memory");
|
|
}
|
|
return;
|
|
}
|
|
|
|
_nomad_pending_scroll=restore_logical_scroll;
|
|
nomad_heap_checkpoint("open-owner");
|
|
const uint8_t* history_request = _nomad_submission_ready
|
|
? _nomad_submission_data.data() : nullptr;
|
|
const std::size_t history_request_size = _nomad_submission_ready
|
|
? _nomad_submission_data.size() : 0;
|
|
if (!history_prepared && !_nomad_history.prepare_open(parsed.str(), add_history,
|
|
current_scroll, history_request, history_request_size,
|
|
_nomad_pending_history)) {
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Form history exceeds available memory");
|
|
return;
|
|
}
|
|
nomad_advance_navigation_generation();
|
|
_nomad_cache_flow.cancel();
|
|
if (!nomad_supersede_transport(parsed.destination_hex)) {
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
_nomad_pending_history.clear();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Navigation is busy; try again");
|
|
return;
|
|
}
|
|
_nomad_url = parsed;
|
|
_nomad_request_data_class = _nomad_submission_ready
|
|
? NomadNet::RequestDataClass::FORM
|
|
: parsed.fields.empty() ? NomadNet::RequestDataClass::NIL
|
|
: NomadNet::RequestDataClass::FIELDS;
|
|
const NomadNet::CacheKey cache_key{
|
|
parsed.destination_hex, parsed.path, _nomad_request_data_class};
|
|
const double wall_time = Utilities::OS::time();
|
|
const uint64_t cache_now = wall_time >= 1609459200.0
|
|
? static_cast<uint64_t>(wall_time) : 0;
|
|
const bool bypass = _nomad_cache_bypass_once;
|
|
_nomad_cache_bypass_once = false;
|
|
_nomad_cache_generation = _nomad_navigation_generation;
|
|
{
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->show_pending_navigation(address);
|
|
}
|
|
if (_nomad_cache_flow.begin(cache_key, cache_now, bypass) ==
|
|
NomadNet::CacheFlowState::LOOKUP) {
|
|
_nomad_state = NomadState::CACHE;
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Checking SD page cache...");
|
|
return;
|
|
}
|
|
nomad_begin_live_transport();
|
|
}
|
|
|
|
const NomadNet::Url& UIManager::nomad_transport_url() const {
|
|
return _nomad_partial_controller.active() ? _nomad_partial_url : _nomad_url;
|
|
}
|
|
|
|
bool UIManager::nomad_schedule_partial_ids(const std::string& address,
|
|
uint32_t now_ms) {
|
|
bool matched = false;
|
|
std::size_t start = 2;
|
|
while (start <= address.size()) {
|
|
const std::size_t end = address.find(':', start);
|
|
const std::size_t size = (end == std::string::npos ? address.size() : end) - start;
|
|
if (size != 0 && size <= NomadNet::DocumentParser::MAX_PARTIAL_ID_BYTES) {
|
|
LVGL_LOCK();
|
|
for (std::size_t index = 0;
|
|
index < _nomad_partial_scheduler.size(); ++index) {
|
|
if (_nomadnet_screen->partial_id_matches(
|
|
index, address.data() + start, size) &&
|
|
_nomad_partial_scheduler.request_now(
|
|
index, _nomad_navigation_generation, now_ms))
|
|
matched = true;
|
|
}
|
|
}
|
|
if (end == std::string::npos) break;
|
|
start = end + 1;
|
|
}
|
|
LVGL_LOCK();
|
|
if (!matched)
|
|
_nomadnet_screen->set_status("No matching dynamic content");
|
|
return matched;
|
|
}
|
|
|
|
void UIManager::nomad_poll_partials(uint32_t now_ms) {
|
|
if (_nomad_state != NomadState::IDLE ||
|
|
_nomad_partial_controller.active())
|
|
return;
|
|
NomadNet::PartialRequest request;
|
|
if (!_nomad_partial_scheduler.poll(
|
|
now_ms, _navigation.current() == Route::NOMADNET,
|
|
true, request))
|
|
return;
|
|
|
|
NomadNet::FormEncodeResult encode_result;
|
|
bool prepared = false;
|
|
{
|
|
LVGL_LOCK();
|
|
prepared = _nomadnet_screen->prepare_partial_request(
|
|
request, _nomad_partial_controller, encode_result);
|
|
}
|
|
if (!prepared) {
|
|
_nomad_partial_scheduler.defer(request);
|
|
_nomad_partial_controller.abandon_request();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status(
|
|
encode_result == NomadNet::FormEncodeResult::ALLOCATION_FAILED
|
|
? "Dynamic request exceeds available memory"
|
|
: "Dynamic request data exceeds device limit");
|
|
return;
|
|
}
|
|
|
|
NomadNet::Url target;
|
|
std::string error;
|
|
bool parsed = false;
|
|
bool allocation_failed = false;
|
|
try {
|
|
parsed = NomadNet::Url::parse(
|
|
std::string(_nomad_partial_controller.url_data(),
|
|
_nomad_partial_controller.url_size()),
|
|
target, error, _nomad_url.destination_hex, _nomad_url.path, {});
|
|
} catch (const std::bad_alloc&) {
|
|
allocation_failed = true;
|
|
}
|
|
if (!parsed || target.has_fragment) {
|
|
if (allocation_failed) _nomad_partial_scheduler.defer(request);
|
|
else _nomad_partial_scheduler.complete(request, false, now_ms);
|
|
_nomad_partial_controller.abandon_request();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status(
|
|
allocation_failed ? "Dynamic address exceeds available memory" :
|
|
parsed ? "Dynamic address cannot target a fragment" : error.c_str());
|
|
return;
|
|
}
|
|
if (!nomad_supersede_transport(target.destination_hex)) {
|
|
_nomad_partial_scheduler.defer(request);
|
|
_nomad_partial_controller.abandon_request();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Dynamic request owner is busy");
|
|
return;
|
|
}
|
|
_nomad_partial_request = request;
|
|
_nomad_partial_url = std::move(target);
|
|
_nomad_state = NomadState::PARTIAL_PENDING;
|
|
{
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_partial_activity(true);
|
|
}
|
|
nomad_begin_partial_transport();
|
|
}
|
|
|
|
void UIManager::nomad_begin_partial_transport() {
|
|
try {
|
|
if (!_nomad_partial_controller.active()) {
|
|
_nomad_state = NomadState::IDLE;
|
|
return;
|
|
}
|
|
RouterLock router_lock;
|
|
if (!router_lock.acquired()) {
|
|
_nomad_state = NomadState::PARTIAL_PENDING;
|
|
return;
|
|
}
|
|
const bool same_destination = _nomad_link &&
|
|
_nomad_link.status() == Type::Link::ACTIVE &&
|
|
_nomad_destination_hash && bytes_equal_lower_hex(
|
|
_nomad_destination_hash, _nomad_partial_url.destination_hex);
|
|
_nomad_mailbox.prepare(
|
|
_nomad_navigation_generation,
|
|
NomadNet::PartialController::MAX_RESPONSE_WIRE_BYTES);
|
|
if (same_destination) {
|
|
nomad_send_request();
|
|
return;
|
|
}
|
|
if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED)
|
|
_nomad_link.teardown();
|
|
nomad_release_request();
|
|
_nomad_link = Link(Type::NONE);
|
|
_nomad_link_identified = false;
|
|
_nomad_request = RequestReceipt(Type::NONE);
|
|
_nomad_response.clear();
|
|
_nomad_request_policy.reset();
|
|
_nomad_destination_hash = Bytes();
|
|
_nomad_destination_hash.assignHex(_nomad_partial_url.destination_hex.c_str());
|
|
if (Transport::has_path(_nomad_destination_hash)) nomad_start_link();
|
|
else {
|
|
Transport::request_path(_nomad_destination_hash);
|
|
_nomad_state = NomadState::PATH;
|
|
_nomad_deadline_ms = millis() + NomadNet::RequestPolicy::PATH_WAIT_MS;
|
|
}
|
|
} catch (const std::bad_alloc&) {
|
|
nomad_defer_partial(
|
|
"Dynamic transport exceeds available internal memory", false);
|
|
}
|
|
}
|
|
|
|
void UIManager::nomad_finish_partial(bool success, const char* status) {
|
|
nomad_release_partial(success, false, status, true);
|
|
}
|
|
|
|
void UIManager::nomad_defer_partial(const char* status, bool retain_link) {
|
|
nomad_release_partial(false, true, status, retain_link);
|
|
}
|
|
|
|
void UIManager::nomad_release_partial(bool success, bool deferred, const char* status,
|
|
bool allow_retain_link) {
|
|
if (!_nomad_partial_controller.active()) return;
|
|
const std::string& destination = _nomad_partial_url.destination_hex;
|
|
if (deferred) _nomad_partial_scheduler.defer(_nomad_partial_request);
|
|
else _nomad_partial_scheduler.complete(
|
|
_nomad_partial_request, success, millis());
|
|
_nomad_response.release();
|
|
const bool retain_link = allow_retain_link && _nomad_link &&
|
|
_nomad_link.status() == Type::Link::ACTIVE &&
|
|
_nomad_destination_hash &&
|
|
bytes_equal_lower_hex(_nomad_destination_hash, destination);
|
|
if (retain_link) {
|
|
if (success) nomad_finish_request_keep_link();
|
|
else {
|
|
nomad_release_request();
|
|
_nomad_state = NomadState::IDLE;
|
|
_nomad_deadline_ms = 0;
|
|
}
|
|
} else nomad_stop_transport();
|
|
_nomad_partial_controller.abandon_request();
|
|
_nomad_partial_request = NomadNet::PartialRequest{};
|
|
_nomad_partial_url = NomadNet::Url{};
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_partial_activity(false);
|
|
if (success) _nomadnet_screen->clear_status();
|
|
else if (status) _nomadnet_screen->set_status(status);
|
|
}
|
|
|
|
void UIManager::nomad_begin_live_transport() {
|
|
RouterLock router_lock;
|
|
if (!router_lock.acquired()) {
|
|
// Preserve the currently published page and retry from the deterministic
|
|
// owner loop. No mailbox or transport callback is armed in this state.
|
|
_nomad_state = NomadState::LIVE_PENDING;
|
|
return;
|
|
}
|
|
{
|
|
// Transport admission is non-destructive. The old model remains visible
|
|
// until the complete replacement has been published successfully.
|
|
LVGL_LOCK();
|
|
nomad_heap_checkpoint("action-before-navigation");
|
|
_nomadnet_screen->set_address(_nomad_url.str());
|
|
nomad_heap_checkpoint("action-after-navigation");
|
|
}
|
|
const bool same_destination = _nomad_link &&
|
|
_nomad_link.status() == Type::Link::ACTIVE &&
|
|
_nomad_destination_hash && bytes_equal_lower_hex(
|
|
_nomad_destination_hash, _nomad_url.destination_hex);
|
|
if (same_destination) {
|
|
_nomad_response.clear();
|
|
_nomad_request_policy.reset();
|
|
{
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_address(_nomad_url.str());
|
|
_nomadnet_screen->set_status("Requesting page...");
|
|
}
|
|
_nomad_mailbox.prepare(
|
|
_nomad_navigation_generation,
|
|
_nomad_partial_controller.active()
|
|
? NomadNet::PartialController::MAX_RESPONSE_WIRE_BYTES
|
|
: NomadNet::AsyncMailbox::MAX_WIRE_BYTES);
|
|
nomad_send_request();
|
|
return;
|
|
}
|
|
_nomad_mailbox.seal();
|
|
if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown();
|
|
nomad_release_request();
|
|
_nomad_link = Link(Type::NONE);
|
|
_nomad_link_identified = false;
|
|
_nomad_request = RequestReceipt(Type::NONE);
|
|
_nomad_response.clear();
|
|
_nomad_request_policy.reset();
|
|
_nomad_destination_hash = Bytes();
|
|
_nomad_destination_hash.assignHex(_nomad_url.destination_hex.c_str());
|
|
{
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_address(_nomad_url.str());
|
|
_nomadnet_screen->set_status("Discovering path...");
|
|
}
|
|
if (Transport::has_path(_nomad_destination_hash)) nomad_start_link();
|
|
else {
|
|
Transport::request_path(_nomad_destination_hash);
|
|
_nomad_state = NomadState::PATH;
|
|
_nomad_deadline_ms = millis() + NomadNet::RequestPolicy::PATH_WAIT_MS;
|
|
}
|
|
}
|
|
|
|
void UIManager::nomad_start_link() {
|
|
nomad_heap_checkpoint("link-enter");
|
|
Identity identity = Identity::recall(_nomad_destination_hash);
|
|
if (!identity) {
|
|
if (_nomad_partial_controller.active()) {
|
|
nomad_finish_partial(false, "Dynamic-content identity is not known");
|
|
return;
|
|
}
|
|
_nomad_state = NomadState::IDLE;
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Node identity is not known");
|
|
return;
|
|
}
|
|
Destination destination(identity, Type::Destination::OUT,
|
|
Type::Destination::SINGLE, "nomadnetwork", "node");
|
|
if (destination.hash() != _nomad_destination_hash) {
|
|
if (_nomad_partial_controller.active()) {
|
|
nomad_finish_partial(false, "Dynamic-content identity does not match address");
|
|
return;
|
|
}
|
|
_nomad_state = NomadState::IDLE;
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Identity does not match node address");
|
|
return;
|
|
}
|
|
_nomad_mailbox.prepare(
|
|
_nomad_navigation_generation,
|
|
_nomad_partial_controller.active()
|
|
? NomadNet::PartialController::MAX_RESPONSE_WIRE_BYTES
|
|
: NomadNet::AsyncMailbox::MAX_WIRE_BYTES);
|
|
nomad_heap_checkpoint("link-before-construct");
|
|
_nomad_link = Link(destination, on_nomad_link_established, on_nomad_link_closed);
|
|
nomad_heap_checkpoint("link-after-construct");
|
|
_nomad_mailbox.begin(token(_nomad_link.link_id()), _nomad_navigation_generation);
|
|
_nomad_state = NomadState::LINK;
|
|
_nomad_deadline_ms = millis() + NomadNet::RequestPolicy::LINK_WAIT_MS;
|
|
LVGL_LOCK();
|
|
if (_nomad_partial_controller.active())
|
|
_nomadnet_screen->set_partial_activity(true);
|
|
else
|
|
_nomadnet_screen->set_status("Establishing encrypted link...");
|
|
}
|
|
|
|
void UIManager::nomad_identify_link_if_configured() {
|
|
if (_nomad_link_identified) return;
|
|
if (_nomad_library.node_identified(nomad_transport_url().destination_hex)) {
|
|
_nomad_link.identify(_router.identity());
|
|
_nomad_link_identified = true;
|
|
}
|
|
}
|
|
|
|
void UIManager::nomad_send_request() {
|
|
if (!_nomad_link || _nomad_link.status() != Type::Link::ACTIVE) return;
|
|
nomad_identify_link_if_configured();
|
|
nomad_heap_checkpoint("request-enter");
|
|
_nomad_link.set_resource_started_callback(on_nomad_resource_started);
|
|
RNS::Bytes packed_request_data;
|
|
try {
|
|
if (_nomad_partial_controller.active()) {
|
|
packed_request_data = Bytes(_nomad_partial_controller.request_data(),
|
|
_nomad_partial_controller.request_size());
|
|
} else if (_nomad_submission_ready) {
|
|
packed_request_data = Bytes(_nomad_submission_data.data(), _nomad_submission_data.size());
|
|
} else {
|
|
const auto request_data = NomadNet::request_data(_nomad_url.fields);
|
|
if (request_data.size() > NomadNet::FormState::MAX_ENCODED_BYTES) {
|
|
nomad_stop_transport();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Request data exceeds device limit");
|
|
return;
|
|
}
|
|
packed_request_data = Bytes(request_data.data(), request_data.size());
|
|
}
|
|
if (_nomad_partial_controller.active()) {
|
|
_nomad_request = _nomad_link.request(
|
|
Bytes(reinterpret_cast<const uint8_t*>(_nomad_partial_url.path.data()),
|
|
_nomad_partial_url.path.size()),
|
|
packed_request_data, on_nomad_response, on_nomad_failed,
|
|
on_nomad_progress, 30.0,
|
|
NomadNet::PartialController::MAX_RESPONSE_WIRE_BYTES, true);
|
|
} else {
|
|
_nomad_request = _nomad_link.request(
|
|
Bytes(reinterpret_cast<const uint8_t*>(_nomad_url.path.data()),
|
|
_nomad_url.path.size()),
|
|
packed_request_data, on_nomad_response, on_nomad_failed,
|
|
on_nomad_progress, 30.0, NomadNet::AsyncMailbox::MAX_WIRE_BYTES, true);
|
|
}
|
|
} catch (const std::bad_alloc&) {
|
|
if (packed_request_data) {
|
|
volatile uint8_t* bytes = packed_request_data.writable(0);
|
|
for (std::size_t i = 0; i < packed_request_data.size(); ++i) bytes[i] = 0;
|
|
}
|
|
if (_nomad_partial_controller.active()) {
|
|
nomad_defer_partial(
|
|
"Dynamic request exceeds available internal memory",
|
|
false);
|
|
} else {
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
nomad_stop_transport();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Request exceeds available internal memory");
|
|
}
|
|
return;
|
|
}
|
|
if (packed_request_data) {
|
|
volatile uint8_t* bytes = packed_request_data.writable(0);
|
|
for (std::size_t i = 0; i < packed_request_data.size(); ++i) bytes[i] = 0;
|
|
}
|
|
if (!_nomad_partial_controller.active()) {
|
|
NomadNet::clear_encoded_form(_nomad_submission_data);
|
|
_nomad_submission_ready = false;
|
|
}
|
|
nomad_heap_checkpoint("request-created");
|
|
if (!_nomad_request) {
|
|
if (_nomad_partial_controller.active())
|
|
nomad_finish_partial(false, "Dynamic request could not be sent");
|
|
else {
|
|
nomad_stop_transport();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Request could not be sent");
|
|
}
|
|
return;
|
|
}
|
|
_nomad_mailbox.expect_request(token(_nomad_request.request_id()));
|
|
_nomad_state = NomadState::REQUEST;
|
|
_nomad_deadline_ms = millis() + 30000;
|
|
LVGL_LOCK();
|
|
if (_nomad_partial_controller.active())
|
|
_nomadnet_screen->set_partial_activity(true);
|
|
else _nomadnet_screen->set_status("Requesting page...");
|
|
}
|
|
|
|
bool UIManager::nomad_apply_page_bytes(const uint8_t* data, std::size_t size, bool cached) {
|
|
NomadNet::Document document;
|
|
const auto parse_status = _nomad_parser.parse_into(
|
|
reinterpret_cast<const char*>(data), size, document);
|
|
if (parse_status == NomadNet::ParseStatus::ALLOCATION_FAILED) {
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Page is too large for available memory");
|
|
return false;
|
|
}
|
|
if (parse_status == NomadNet::ParseStatus::INVALID_INPUT) {
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Page input is invalid");
|
|
return false;
|
|
}
|
|
if (document.malformed && document.blocks.empty()) {
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Page is not valid UTF-8/Micron");
|
|
return false;
|
|
}
|
|
return nomad_apply_page_document(document, cached) ==
|
|
NomadNet::PageApplyResult::APPLIED;
|
|
}
|
|
|
|
NomadNet::PageApplyResult UIManager::nomad_apply_page_document(
|
|
const NomadNet::Document& document, bool cached) {
|
|
bool library_changed = false;
|
|
const auto result = NomadNet::apply_page_transaction_for_url(
|
|
document, _nomad_url, static_cast<uint64_t>(Utilities::OS::time()),
|
|
_nomad_library,
|
|
[&](const NomadNet::PagePublication& publication) {
|
|
bool anchor_resolved = true;
|
|
LVGL_LOCK();
|
|
if (!_nomadnet_screen->set_page(document)) return false;
|
|
if (_nomad_pending_scroll >= 0)
|
|
_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll);
|
|
else if (_nomad_url.has_fragment)
|
|
anchor_resolved = _nomadnet_screen->jump_to_anchor(_nomad_url.fragment);
|
|
_nomadnet_screen->set_page_saved(publication.page_saved);
|
|
_nomadnet_screen->set_identify_enabled(publication.identify_enabled);
|
|
if (!anchor_resolved && !publication.unknown_anchor_status.empty())
|
|
_nomadnet_screen->set_status(publication.unknown_anchor_status.c_str());
|
|
else
|
|
_nomadnet_screen->set_status(cached
|
|
? "Cached page; current reachability not checked"
|
|
: "Page loaded (live)");
|
|
library_changed = publication.library_changed;
|
|
return true;
|
|
},
|
|
[&]() noexcept {
|
|
if (_nomad_pending_history.ready())
|
|
_nomad_history.commit(std::move(_nomad_pending_history));
|
|
});
|
|
if (result == NomadNet::PageApplyResult::ALLOCATION_FAILED) {
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Page is too large for available memory");
|
|
}
|
|
if (result != NomadNet::PageApplyResult::APPLIED) {
|
|
_nomad_pending_history.clear();
|
|
return result;
|
|
}
|
|
_nomad_partial_scheduler.configure(
|
|
document, _nomad_navigation_generation, millis());
|
|
_nomad_partial_controller.reset_page(document.source_bytes);
|
|
if (library_changed) _nomad_library_dirty = true;
|
|
_nomad_pending_scroll = -1;
|
|
return result;
|
|
}
|
|
void UIManager::nomad_update() {
|
|
// Filesystem chunks are owner-loop work and are always serviced before the
|
|
// Router serialization domain. LVGL is taken only later to apply a complete,
|
|
// validated immutable page.
|
|
// Start deferred mutations before RouterLock. The response callback only
|
|
// transfers immutable PSRAM ownership while the router is serialized.
|
|
if (_nomad_cache_pending_invalidate &&
|
|
_nomad_cache_pending_generation != _nomad_navigation_generation) {
|
|
_nomad_cache_pending_invalidate = false;
|
|
}
|
|
if (!_nomad_cache_pending_body.empty() &&
|
|
_nomad_cache_pending_generation != _nomad_navigation_generation) {
|
|
NomadNet::ExternalVector<uint8_t>().swap(_nomad_cache_pending_body);
|
|
_nomad_cache_pending_now = 0;
|
|
_nomad_cache_pending_ttl = 0;
|
|
}
|
|
if (!_nomad_cache.busy() && _nomad_cache_pending_invalidate) {
|
|
_nomad_cache.invalidate(_nomad_cache_pending_key);
|
|
_nomad_cache_pending_invalidate = false;
|
|
}
|
|
if (!_nomad_cache.busy() && !_nomad_cache_pending_body.empty()) {
|
|
_nomad_cache.beginCommit(_nomad_cache_pending_key,
|
|
std::move(_nomad_cache_pending_body), _nomad_cache_pending_now,
|
|
_nomad_cache_pending_ttl);
|
|
_nomad_cache_pending_now = 0;
|
|
_nomad_cache_pending_ttl = 0;
|
|
}
|
|
_nomad_cache_flow.service();
|
|
if (_nomad_state == NomadState::CACHE) {
|
|
if (_nomad_cache_generation != _nomad_navigation_generation) {
|
|
_nomad_cache_flow.cancel();
|
|
_nomad_state = NomadState::IDLE;
|
|
return;
|
|
}
|
|
if (_nomad_cache_flow.state() == NomadNet::CacheFlowState::LOOKUP) return;
|
|
if (_nomad_cache_flow.state() == NomadNet::CacheFlowState::READY) {
|
|
NomadNet::ExternalVector<uint8_t> cached;
|
|
if (_nomad_cache_flow.takePage(cached) &&
|
|
nomad_apply_page_bytes(cached.data(), cached.size(), true)) {
|
|
_nomad_state = NomadState::IDLE;
|
|
return;
|
|
}
|
|
}
|
|
_nomad_state = NomadState::IDLE;
|
|
nomad_begin_live_transport();
|
|
return;
|
|
}
|
|
if (_nomad_state == NomadState::LIVE_PENDING) {
|
|
nomad_begin_live_transport();
|
|
return;
|
|
}
|
|
if (_nomad_state == NomadState::PARTIAL_PENDING) {
|
|
nomad_begin_partial_transport();
|
|
return;
|
|
}
|
|
if (_nomad_state == NomadState::IDLE) {
|
|
nomad_poll_partials(millis());
|
|
if (_nomad_state != NomadState::IDLE) return;
|
|
}
|
|
RouterLock router_lock;
|
|
if (!router_lock.acquired()) return;
|
|
const uint32_t now = millis();
|
|
if (_nomad_state == NomadState::PATH && Transport::has_path(_nomad_destination_hash)) {
|
|
nomad_start_link();
|
|
} else if (_nomad_state != NomadState::IDLE && _nomad_deadline_ms != 0 &&
|
|
static_cast<int32_t>(now - _nomad_deadline_ms) >= 0) {
|
|
if (_nomad_state == NomadState::LINK && nomad_refresh_path_after_link_failure()) return;
|
|
if (_nomad_partial_controller.active())
|
|
nomad_finish_partial(false, "Dynamic-content request timed out");
|
|
else {
|
|
nomad_stop_transport();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("NomadNet operation timed out");
|
|
}
|
|
return;
|
|
}
|
|
|
|
NomadNet::AsyncMailbox::Event event;
|
|
if (!_nomad_mailbox.take(event)) return;
|
|
if (event.generation != _nomad_navigation_generation) return;
|
|
if (event.kind == NomadNet::AsyncMailbox::Kind::RESPONSE)
|
|
nomad_heap_checkpoint("response-taken");
|
|
switch (event.kind) {
|
|
case NomadNet::AsyncMailbox::Kind::LINK_ESTABLISHED:
|
|
if (_nomad_state == NomadState::LINK) {
|
|
nomad_send_request();
|
|
}
|
|
break;
|
|
case NomadNet::AsyncMailbox::Kind::LINK_CLOSED:
|
|
if (_nomad_state == NomadState::LINK && nomad_refresh_path_after_link_failure()) break;
|
|
if (_nomad_partial_controller.active())
|
|
nomad_finish_partial(false, "Dynamic-content link closed");
|
|
else {
|
|
nomad_stop_transport();
|
|
{ LVGL_LOCK(); _nomadnet_screen->set_status("NomadNet link closed"); }
|
|
}
|
|
break;
|
|
case NomadNet::AsyncMailbox::Kind::FAILED:
|
|
if (_nomad_partial_controller.active())
|
|
nomad_finish_partial(false, "Dynamic-content request failed");
|
|
else {
|
|
nomad_stop_transport();
|
|
{ LVGL_LOCK(); _nomadnet_screen->set_status("Page request failed"); }
|
|
}
|
|
break;
|
|
case NomadNet::AsyncMailbox::Kind::PROGRESS:
|
|
if (_nomad_state == NomadState::REQUEST &&
|
|
!_nomad_partial_controller.active()) {
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Receiving page...");
|
|
}
|
|
break;
|
|
case NomadNet::AsyncMailbox::Kind::OVERSIZED:
|
|
_nomad_response.clear();
|
|
if (_nomad_partial_controller.active())
|
|
nomad_finish_partial(false, "Dynamic content exceeds 16 KiB limit");
|
|
else {
|
|
nomad_stop_transport();
|
|
{ LVGL_LOCK(); _nomadnet_screen->set_status("Page exceeds 64 KiB limit"); }
|
|
}
|
|
break;
|
|
case NomadNet::AsyncMailbox::Kind::RESPONSE: {
|
|
// The dependency has already removed this successful request from
|
|
// Link::pending_requests(). Validate it before deciding whether the
|
|
// Link is safe to retain for same-destination navigation.
|
|
if (_nomad_partial_controller.active()) {
|
|
bool lease_matches = false;
|
|
{
|
|
LVGL_LOCK();
|
|
lease_matches = _nomadnet_screen->partial_request_matches(
|
|
_nomad_partial_request, _nomad_partial_controller);
|
|
}
|
|
if (!lease_matches) {
|
|
_nomad_partial_scheduler.cancel(_nomad_navigation_generation);
|
|
_nomad_partial_controller.cancel();
|
|
nomad_stop_transport();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_partial_activity(false);
|
|
_nomadnet_screen->set_status("Discarded stale dynamic content");
|
|
break;
|
|
}
|
|
}
|
|
if (!NomadNet::normalize_response(event.data.data(), event.data.size(), _nomad_response)) {
|
|
if (_nomad_partial_controller.active())
|
|
nomad_finish_partial(false, "Malformed dynamic-content response");
|
|
else {
|
|
nomad_stop_transport();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Malformed NomadNet response");
|
|
}
|
|
break;
|
|
}
|
|
nomad_heap_checkpoint("response-normalized");
|
|
const auto& bytes = _nomad_response.bytes();
|
|
if (_nomad_partial_controller.active()) {
|
|
if (bytes.size() > NomadNet::PartialController::MAX_RESPONSE_BYTES ||
|
|
!_nomad_partial_controller.can_accept_fragment(
|
|
_nomad_partial_request.partial_index, bytes.size())) {
|
|
nomad_finish_partial(false,
|
|
"Dynamic content exceeds expanded-page limit");
|
|
break;
|
|
}
|
|
NomadNet::Document fragment;
|
|
const auto parse_status = _nomad_parser.parse_into(
|
|
reinterpret_cast<const char*>(bytes.data()), bytes.size(), fragment);
|
|
if (parse_status != NomadNet::ParseStatus::OK ||
|
|
fragment.malformed || fragment.truncated) {
|
|
nomad_finish_partial(false,
|
|
parse_status == NomadNet::ParseStatus::ALLOCATION_FAILED
|
|
? "Dynamic content exceeds available memory"
|
|
: "Dynamic content is not valid UTF-8/Micron");
|
|
break;
|
|
}
|
|
NomadNet::PartialReplaceResult applied;
|
|
{
|
|
LVGL_LOCK();
|
|
applied = _nomadnet_screen->apply_partial_fragment(
|
|
_nomad_partial_request, fragment,
|
|
_nomad_partial_controller);
|
|
}
|
|
if (applied != NomadNet::PartialReplaceResult::APPLIED ||
|
|
!_nomad_partial_controller.commit_fragment(
|
|
_nomad_partial_request.partial_index, bytes.size())) {
|
|
nomad_finish_partial(false,
|
|
applied == NomadNet::PartialReplaceResult::ALLOCATION_FAILED
|
|
? "Dynamic content exceeds available memory"
|
|
: "Dynamic content exceeds page limits");
|
|
break;
|
|
}
|
|
nomad_finish_partial(true, nullptr);
|
|
break;
|
|
}
|
|
NomadNet::Document document;
|
|
try {
|
|
document = _nomad_parser.parse(
|
|
reinterpret_cast<const char*>(bytes.data()), bytes.size());
|
|
} catch (const std::bad_alloc&) {
|
|
_nomad_response.release();
|
|
nomad_stop_transport();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Page is too large for available memory");
|
|
break;
|
|
}
|
|
nomad_heap_checkpoint("response-parsed");
|
|
const bool valid_document = !document.malformed && !document.truncated &&
|
|
document.cache_directive_valid && !document.blocks.empty();
|
|
if (document.malformed && document.blocks.empty()) {
|
|
_nomad_response.release();
|
|
nomad_stop_transport();
|
|
LVGL_LOCK();
|
|
_nomadnet_screen->set_status("Page is not valid UTF-8/Micron");
|
|
break;
|
|
}
|
|
const bool page_applied = nomad_apply_page_document(document, false) ==
|
|
NomadNet::PageApplyResult::APPLIED;
|
|
nomad_heap_checkpoint("response-page-applied");
|
|
if (!page_applied) {
|
|
_nomad_response.release();
|
|
nomad_stop_transport();
|
|
break;
|
|
}
|
|
const bool ordinary_nil =
|
|
_nomad_request_data_class == NomadNet::RequestDataClass::NIL;
|
|
const auto directive = NomadNet::parse_cache_directive(bytes.data(), bytes.size());
|
|
const double wall_time = Utilities::OS::time();
|
|
const uint64_t cache_now = wall_time >= 1609459200.0
|
|
? static_cast<uint64_t>(wall_time) : 0;
|
|
const NomadNet::CacheKey cache_key{
|
|
_nomad_url.destination_hex, _nomad_url.path,
|
|
_nomad_request_data_class};
|
|
if (ordinary_nil && valid_document && cache_now &&
|
|
directive.valid && directive.ttl) {
|
|
_nomad_cache_pending_key = cache_key;
|
|
_nomad_cache_pending_body = _nomad_response.take();
|
|
_nomad_cache_pending_now = cache_now;
|
|
_nomad_cache_pending_ttl = directive.ttl;
|
|
_nomad_cache_pending_generation = event.generation;
|
|
} else {
|
|
_nomad_response.release();
|
|
if (ordinary_nil && (!directive.valid || directive.ttl == 0))
|
|
{ _nomad_cache_pending_key = cache_key;
|
|
_nomad_cache_pending_generation = event.generation;
|
|
_nomad_cache_pending_invalidate = true; }
|
|
}
|
|
// The response wins a racing close callback. Retain only a currently
|
|
// active Link whose independent owner hash still matches this URL.
|
|
if (_nomad_link && _nomad_link.status() == Type::Link::ACTIVE &&
|
|
_nomad_destination_hash &&
|
|
bytes_equal_lower_hex(
|
|
_nomad_destination_hash, _nomad_url.destination_hex)) {
|
|
nomad_finish_request_keep_link();
|
|
} else {
|
|
nomad_stop_transport();
|
|
}
|
|
break;
|
|
}
|
|
case NomadNet::AsyncMailbox::Kind::NONE:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void UIManager::on_nomad_link_established(Link& link) {
|
|
if (s_nomad_instance) s_nomad_instance->_nomad_mailbox.publish_link(token(link.link_id()), true);
|
|
}
|
|
|
|
void UIManager::on_nomad_link_closed(Link& link) {
|
|
if (s_nomad_instance) s_nomad_instance->_nomad_mailbox.publish_link(token(link.link_id()), false);
|
|
}
|
|
|
|
void UIManager::on_nomad_response(const RequestReceipt& receipt) {
|
|
if (!s_nomad_instance) return;
|
|
const std::size_t transfer = receipt.response_transfer_size();
|
|
const Bytes response = receipt.get_response();
|
|
s_nomad_instance->_nomad_mailbox.publish_response(
|
|
token(receipt.request_id()), response ? response.data() : nullptr,
|
|
response ? response.size() : 0, transfer);
|
|
}
|
|
|
|
void UIManager::on_nomad_failed(const RequestReceipt& receipt) {
|
|
if (!s_nomad_instance) return;
|
|
s_nomad_instance->_nomad_mailbox.publish_failed(
|
|
token(receipt.request_id()), receipt.response_size());
|
|
}
|
|
|
|
void UIManager::on_nomad_progress(const RequestReceipt& receipt) {
|
|
if (!s_nomad_instance) return;
|
|
const std::size_t transfer = receipt.response_transfer_size();
|
|
s_nomad_instance->_nomad_mailbox.publish_progress(token(receipt.request_id()), transfer);
|
|
}
|
|
|
|
void UIManager::on_nomad_resource_started(const Resource& resource) {
|
|
if (!s_nomad_instance || !resource.is_response() || !s_nomad_instance->_nomad_request) return;
|
|
const Bytes request_id = resource.request_id();
|
|
if (!request_id || token(request_id) != token(s_nomad_instance->_nomad_request.request_id())) return;
|
|
const_cast<Resource&>(resource).set_progress_callback(on_nomad_resource_progress);
|
|
}
|
|
|
|
void UIManager::on_nomad_resource_progress(const Resource& resource) {
|
|
if (!s_nomad_instance || !resource.is_response()) return;
|
|
const std::size_t transferred = static_cast<std::size_t>(
|
|
resource.get_progress() * static_cast<float>(resource.get_transfer_size()));
|
|
s_nomad_instance->_nomad_mailbox.publish_progress(
|
|
token(resource.request_id()), transferred);
|
|
}
|
|
|
|
// ── 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) {
|
|
#ifdef PYXIS_TEST_HOOKS
|
|
_test_call_initiate_result = TestCallInitiateResult::FAILED;
|
|
#endif
|
|
{
|
|
std::string h = peer_hash.toHex().substr(0, 16);
|
|
INFO(("LXST: Initiating call to " + h + "...").c_str());
|
|
}
|
|
lxst_breadcrumb(1, ESP.getFreeHeap());
|
|
|
|
// Link establishment needs roughly 10 KiB for crypto. The former 40 KiB
|
|
// threshold rejected valid calls on the complete UI build, whose steady
|
|
// state is about 30 KiB internal free. Audio allocations are now directed
|
|
// to PSRAM and its task stacks are bounded, so retain a 24 KiB floor.
|
|
size_t free_heap = ESP.getFreeHeap();
|
|
if (free_heap < 24000) {
|
|
char buf[64];
|
|
snprintf(buf, sizeof(buf), "LXST: Insufficient heap (%u bytes), aborting call", (unsigned)free_heap);
|
|
WARNING(buf);
|
|
return;
|
|
}
|
|
|
|
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");
|
|
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());
|
|
|
|
// Reserve a generation only after the outgoing call has passed its
|
|
// acceptance checks. update() holds the recursive LVGL mutex across this
|
|
// entire initiation, and call-lifecycle callbacks take the same mutex even
|
|
// when an interface task dispatches them. The atomic reservation remains
|
|
// the definitive ownership check.
|
|
const uint32_t generation = call_begin_generation();
|
|
if (generation == 0) {
|
|
#ifdef PYXIS_TEST_HOOKS
|
|
_test_call_initiate_result = TestCallInitiateResult::BUSY;
|
|
#endif
|
|
WARNING("LXST: Another call was accepted concurrently");
|
|
return;
|
|
}
|
|
_call_peer_hash = peer_hash;
|
|
_call_muted = false;
|
|
|
|
// Show call screen
|
|
_call_screen->set_peer(peer_dest.hash());
|
|
_call_screen->set_state(CallScreen::CallState::CONNECTING);
|
|
_call_screen->set_muted(false);
|
|
navigate(Route::CALL);
|
|
|
|
lxst_breadcrumb(5, ESP.getFreeHeap());
|
|
|
|
_call_dest_hash = peer_dest.hash();
|
|
_call_answer_pending.store(false, std::memory_order_release);
|
|
_call_audio_rx_count = 0;
|
|
_call_audio_tx_count = 0;
|
|
_call_signal_write = 0;
|
|
_call_signal_read = 0;
|
|
_call_commands.take();
|
|
|
|
{
|
|
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())) {
|
|
// Register lifecycle callbacks in the constructor before the link
|
|
// request is sent. Publish the exact ID as soon as it returns.
|
|
INFO("LXST: Creating link...");
|
|
_call_link = Link(peer_dest, on_call_link_established, on_call_link_closed);
|
|
if (!call_publish_link(_call_link, generation)) {
|
|
WARNING("LXST: Link has invalid ID, aborting call");
|
|
call_ended();
|
|
return;
|
|
}
|
|
_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;
|
|
}
|
|
|
|
#ifdef PYXIS_TEST_HOOKS
|
|
_test_call_initiate_result = TestCallInitiateResult::STARTED;
|
|
#endif
|
|
|
|
lxst_breadcrumb(7, ESP.getFreeHeap());
|
|
}
|
|
|
|
#ifdef PYXIS_TEST_HOOKS
|
|
uint32_t UIManager::test_call_decode_ok() const {
|
|
return _lxst_audio ? _lxst_audio->playbackDecodeOk() : 0;
|
|
}
|
|
|
|
uint32_t UIManager::test_call_decode_fail() const {
|
|
return _lxst_audio ? _lxst_audio->playbackDecodeFail() : 0;
|
|
}
|
|
|
|
uint32_t UIManager::test_call_pcm_sample_count() const {
|
|
return _lxst_audio ? _lxst_audio->playbackPcmSampleCount() : 0;
|
|
}
|
|
|
|
uint64_t UIManager::test_call_pcm_sum_squares() const {
|
|
return _lxst_audio ? _lxst_audio->playbackPcmSumSquares() : 0;
|
|
}
|
|
|
|
void UIManager::test_call_set_inject_sine(bool enabled, int freq, float amp) {
|
|
if (_lxst_audio) _lxst_audio->captureSetInjectSine(enabled, freq, amp);
|
|
}
|
|
|
|
bool UIManager::test_call_set_profile(int profile) {
|
|
if (profile_to_codec2_mode(profile) < 0) return false;
|
|
_preferred_profile = profile;
|
|
return true;
|
|
}
|
|
|
|
bool UIManager::test_call_answer() {
|
|
if (_call_state != CallState::INCOMING_RINGING) return false;
|
|
_call_answer_pending.store(true, std::memory_order_release);
|
|
return true;
|
|
}
|
|
|
|
std::string UIManager::test_lxst_dest_hex() const {
|
|
if (!_lxst_destination) return std::string();
|
|
return _lxst_destination.hash().toHex();
|
|
}
|
|
|
|
const char* UIManager::test_call_state_name() const {
|
|
switch (_call_state) {
|
|
case CallState::IDLE: return "IDLE";
|
|
case CallState::PATH_REQUESTING: return "PATH_REQUESTING";
|
|
case CallState::LINK_ESTABLISHING: return "LINK_ESTABLISHING";
|
|
case CallState::WAIT_AVAILABLE: return "WAIT_AVAILABLE";
|
|
case CallState::WAIT_RINGING: return "WAIT_RINGING";
|
|
case CallState::RINGING: return "RINGING";
|
|
case CallState::INCOMING_IDENTIFYING: return "INCOMING_IDENTIFYING";
|
|
case CallState::INCOMING_RINGING: return "INCOMING_RINGING";
|
|
case CallState::CONNECTING: return "CONNECTING";
|
|
case CallState::ACTIVE: return "ACTIVE";
|
|
}
|
|
return "UNKNOWN";
|
|
}
|
|
#endif
|
|
|
|
void UIManager::call_hangup() {
|
|
INFO("LXST: Hanging up");
|
|
|
|
const uint32_t generation =
|
|
call_current_generation();
|
|
|
|
// Normal call termination gets an application-level terminal signal before
|
|
// the one-shot, unacknowledged Reticulum LINKCLOSE. Repetition and the
|
|
// bounded drain interval reduce packet-loss races without blocking the
|
|
// owner task indefinitely. Older peers ignore unknown status 0x07.
|
|
call_send_terminal_burst();
|
|
|
|
// Detach callback ownership before touching the shared Link. Keep call
|
|
// admission reserved until all owner-side teardown completes.
|
|
_call_link_ownership.clear(generation);
|
|
|
|
// call_hangup() is an owner operation: production UI paths reach it only
|
|
// through update() on loopTask, which also owns pump_call_tx(). Keep the
|
|
// generation reserved until teardown is complete so a concurrent incoming
|
|
// callback cannot install a newer call over the old audio pointer.
|
|
call_teardown_audio();
|
|
|
|
// Teardown link
|
|
if (_call_link) {
|
|
_call_link.teardown();
|
|
_call_link = Link(Type::NONE);
|
|
}
|
|
|
|
_call_peer_hash = Bytes();
|
|
_call_dest_hash = Bytes();
|
|
_call_start_ms = 0;
|
|
_call_timeout_ms = 0;
|
|
_call_muted = false;
|
|
_call_answer_pending.store(false, std::memory_order_release);
|
|
_call_signal_write = 0;
|
|
_call_signal_read = 0;
|
|
_call_audio_rx_count = 0;
|
|
_call_audio_tx_count = 0;
|
|
_call_commands.take();
|
|
_call_liveness.disarm();
|
|
_call_state = CallState::IDLE;
|
|
call_clear_generation(generation);
|
|
|
|
// Return to chat screen
|
|
if (_call_screen) {
|
|
_call_screen->set_state(CallScreen::CallState::ENDED);
|
|
}
|
|
|
|
// Restore the screen that owned the call rather than pushing CALL behind a
|
|
// new Messages route. If teardown happened before the call UI appeared,
|
|
// leave the current route untouched.
|
|
if (_navigation.current() == Route::CALL) back();
|
|
}
|
|
|
|
void UIManager::call_set_mute(bool muted) {
|
|
_call_muted = muted;
|
|
if (_lxst_audio) {
|
|
_lxst_audio->setCaptureMute(muted);
|
|
}
|
|
if (_call_screen) {
|
|
_call_screen->set_muted(muted);
|
|
}
|
|
INFO(muted ? "LXST: Mic muted" : "LXST: Mic unmuted");
|
|
}
|
|
|
|
void UIManager::call_request_hangup() {
|
|
_call_commands.requestHangup(
|
|
call_current_generation());
|
|
}
|
|
|
|
void UIManager::call_request_mute(bool muted) {
|
|
_call_commands.requestMute(
|
|
call_current_generation(), muted);
|
|
}
|
|
|
|
uint32_t UIManager::call_begin_generation() {
|
|
return _call_generation_guard.tryReserve();
|
|
}
|
|
|
|
void UIManager::call_clear_generation(uint32_t expected_generation) {
|
|
// Callback ownership was detached before Link teardown/reset. Release call
|
|
// admission last so a new owner cannot overlap old owner-side cleanup.
|
|
_call_generation_guard.release(expected_generation);
|
|
}
|
|
|
|
uint32_t UIManager::call_current_generation() const {
|
|
return _call_generation_guard.current();
|
|
}
|
|
|
|
bool UIManager::call_extract_link_id(
|
|
const Link& link, CallLinkOwnership::LinkId& id) {
|
|
// link_id() asserts on a default/invalid Link in pinned microReticulum.
|
|
if (!link) return false;
|
|
const Bytes& link_id = link.link_id();
|
|
if (link_id.size() != id.size()) return false;
|
|
for (size_t i = 0; i < id.size(); ++i) id[i] = link_id[i];
|
|
return true;
|
|
}
|
|
|
|
bool UIManager::call_publish_link(const Link& link, uint32_t generation) {
|
|
CallLinkOwnership::LinkId id{};
|
|
return call_extract_link_id(link, id) &&
|
|
_call_link_ownership.publish(generation, id);
|
|
}
|
|
|
|
bool UIManager::call_owns_link(
|
|
const CallLinkOwnership::LinkId& id, uint32_t generation) const {
|
|
return generation != 0 &&
|
|
_call_generation_guard.owns(generation) &&
|
|
_call_link_ownership.owns(generation, id);
|
|
}
|
|
|
|
void UIManager::call_teardown_audio() {
|
|
if (!_lxst_audio) return;
|
|
_lxst_audio->stopCapture();
|
|
_lxst_audio->stopPlayback();
|
|
_lxst_audio->deinit();
|
|
delete _lxst_audio;
|
|
_lxst_audio = nullptr;
|
|
}
|
|
|
|
void UIManager::call_send_signal(int signal) {
|
|
call_send_signal_on_link(_call_link, signal);
|
|
}
|
|
|
|
void UIManager::call_send_terminal_burst() {
|
|
if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return;
|
|
|
|
for (int attempt = 0; attempt < TERMINAL_SIGNAL_SEND_COUNT; ++attempt) {
|
|
call_send_signal(LXST_STATUS_TERMINATED);
|
|
if (attempt + 1 < TERMINAL_SIGNAL_SEND_COUNT) {
|
|
delay(TERMINAL_SIGNAL_DRAIN_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UIManager::call_send_signal_on_link(const Link& link, int signal) {
|
|
if (!link || link.status() != Type::Link::ACTIVE) return;
|
|
|
|
// Msgpack: {0x00: [signal]}
|
|
// fixmap(1) + key(0) + fixarray(1) + msgpack-encoded integer
|
|
uint8_t msgpack_buf[7];
|
|
int len;
|
|
|
|
msgpack_buf[0] = 0x81; // fixmap(1)
|
|
msgpack_buf[1] = 0x00; // key: FIELD_SIGNAL
|
|
msgpack_buf[2] = 0x91; // fixarray(1)
|
|
|
|
if (signal <= 0x7F) {
|
|
// fixint: single byte
|
|
msgpack_buf[3] = (uint8_t)signal;
|
|
len = 4;
|
|
} else if (signal <= 0xFF) {
|
|
// uint8: 0xCC + byte
|
|
msgpack_buf[3] = 0xCC;
|
|
msgpack_buf[4] = (uint8_t)signal;
|
|
len = 5;
|
|
} else {
|
|
// uint16: 0xCD + big-endian 2 bytes
|
|
msgpack_buf[3] = 0xCD;
|
|
msgpack_buf[4] = (uint8_t)(signal >> 8);
|
|
msgpack_buf[5] = (uint8_t)(signal & 0xFF);
|
|
len = 6;
|
|
}
|
|
|
|
try {
|
|
Bytes signal_data(msgpack_buf, len);
|
|
Packet packet(link, signal_data);
|
|
packet.send();
|
|
|
|
char buf[48];
|
|
snprintf(buf, sizeof(buf), "LXST: Sent signal 0x%03X", signal);
|
|
DEBUG(buf);
|
|
} catch (const std::exception& e) {
|
|
char dbg[128];
|
|
snprintf(dbg, sizeof(dbg), "LXST: Signal send exception: %s", e.what());
|
|
WARNING(dbg);
|
|
}
|
|
}
|
|
|
|
void UIManager::call_send_audio_batch(const uint8_t* batch_data, int batch_len,
|
|
int batch_count, int total_frames) {
|
|
// Loopback test mode has no link — skip the link guard and route the
|
|
// built wire packet back through the RX parser instead of sending it.
|
|
if (!_call_loopback && (!_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] + [N * raw_codec2].
|
|
// Production Pyxis sends the ULBW 400 ms quantum: 10 Codec2-700C frames.
|
|
// batch_len is the EXACT byte length of one batch, computed by the caller from
|
|
// the real encoded size (codec_type + mode_header + N*raw_codec2). It VARIES by
|
|
// ULBW batch length is 42 bytes: codec type + mode header + 10*4 bytes.
|
|
// (Previously hardcoded to 82, which shipped 40 bytes of uninitialized stack and
|
|
// a lying bin8 length on every 700C packet -- the voice-quality root cause.)
|
|
|
|
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_len;
|
|
memcpy(packet_buf + pos, batch_data, batch_len);
|
|
pos += batch_len;
|
|
} else {
|
|
// Multiple batches: fixarray(N) of bin8 entries, each batch_len bytes (all
|
|
// share one Codec2 mode). NOTE: pyxis only ever sends batch_count==1 today;
|
|
// true variable-length multi-batch would need per-batch lengths passed in.
|
|
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_len;
|
|
memcpy(packet_buf + pos, batch_data + b * batch_len, batch_len);
|
|
pos += batch_len;
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
if (_call_loopback) {
|
|
// Loopback: feed the just-built wire packet straight back through the
|
|
// RX parser so the real framing + parse + decode path runs locally.
|
|
// call_on_packet() does NOT re-enter pump_call_tx(), so this is a
|
|
// bounded synchronous chain (no infinite loop, all on core 1).
|
|
call_on_packet(Bytes(packet_buf, pos));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
Bytes audio_data(packet_buf, pos);
|
|
Packet packet(_call_link, audio_data);
|
|
packet.send();
|
|
} catch (const std::exception& e) {
|
|
char dbg[128];
|
|
snprintf(dbg, sizeof(dbg), "LXST: TX send exception: %s", e.what());
|
|
WARNING(dbg);
|
|
}
|
|
}
|
|
|
|
void UIManager::call_rx_audio_frame(const uint8_t* frame, size_t frame_len) {
|
|
// Guard: packets can arrive after hangup from the network pipeline.
|
|
// In loopback mode _call_state stays IDLE, so bypass the IDLE guard.
|
|
if (!_lxst_audio || (!_call_loopback && _call_state == CallState::IDLE)) return;
|
|
if (!frame || frame_len < 2) {
|
|
WARNING("LXST: RX audio dropped (truncated frame)");
|
|
return;
|
|
}
|
|
|
|
// Wire format: [codec_type_byte] + [mode_header + codec2_subframes...]
|
|
// codec_type: 0x00=Raw, 0x01=Opus, 0x02=Codec2 (matches LXST Codecs/__init__.py)
|
|
// For Codec2: mode_header (0x00-0x06) + raw sub-frames
|
|
uint8_t codec_type = frame[0];
|
|
const uint8_t* codec_data = frame + 1;
|
|
size_t codec_data_len = frame_len - 1;
|
|
|
|
if (codec_type != LXST_CODEC_CODEC2) {
|
|
if (_call_audio_rx_count == 0) {
|
|
char dbg[64];
|
|
snprintf(dbg, sizeof(dbg), "LXST: RX codec=0x%02X (need 0x02=Codec2), dropping",
|
|
codec_type);
|
|
WARNING(dbg);
|
|
}
|
|
return; // Can't decode Opus (0x01) or Raw (0x00) — only Codec2
|
|
}
|
|
|
|
// The encoder and decoder deliberately share one Codec2 state object. Never
|
|
// pass a wider-mode header into decode(), since its dynamic mode switch would
|
|
// also mutate the transmitter away from the ULBW-only production contract.
|
|
if (!ULBWVoiceProfilePolicy::acceptsCodec2ModeHeader(codec_data[0])) {
|
|
char dbg[72];
|
|
snprintf(dbg, sizeof(dbg),
|
|
"LXST: RX audio dropped (non-ULBW mode 0x%02X)", codec_data[0]);
|
|
WARNING(dbg);
|
|
return;
|
|
}
|
|
|
|
if (_lxst_audio->isPlaying()) {
|
|
if (_lxst_audio->writeEncodedPacket(codec_data, codec_data_len)) {
|
|
_call_audio_rx_count++;
|
|
if (!_call_loopback && _call_state == CallState::ACTIVE) {
|
|
_call_liveness.observe(millis());
|
|
}
|
|
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 {
|
|
WARNING("LXST: RX audio dropped (invalid Codec2 frame)");
|
|
}
|
|
} 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, ...]}. LXST 0.5.1 combines the
|
|
// preferred profile and duplex mode in one two-element array; older
|
|
// peers send one signal per packet.
|
|
int signals[LXSTSignalParser::MAX_SIGNALS] = {};
|
|
const size_t signal_count = LXSTSignalParser::parse(
|
|
buf, data.size(), signals, LXSTSignalParser::MAX_SIGNALS);
|
|
if (signal_count == 0) {
|
|
WARNING("LXST: Unparseable signalling packet");
|
|
return;
|
|
}
|
|
|
|
for (size_t i = 0; i < signal_count; ++i) {
|
|
const int signal = signals[i];
|
|
|
|
// Remote sends PREFERRED_PROFILE + profile_id to request a
|
|
// transmit profile. Pyxis is ULBW-only for LoRa: never adopt the
|
|
// request, and respond with ULBW so the peer switches to 700C.
|
|
if (signal >= LXST_PREFERRED_PROFILE) {
|
|
const int remote_profile = signal - LXST_PREFERRED_PROFILE;
|
|
char dbg[80];
|
|
snprintf(dbg, sizeof(dbg),
|
|
"LXST: Remote prefers profile 0x%02X, responding 0x%02X",
|
|
remote_profile, _preferred_profile);
|
|
INFO(dbg);
|
|
call_send_signal(ULBWVoiceProfilePolicy::preferredProfileSignal());
|
|
continue;
|
|
}
|
|
|
|
// Pyxis voice is always full duplex. Accept FDX and explicitly
|
|
// counter an HDX request without treating mode as call status.
|
|
if (signal >= LXST_PREFERRED_MODE) {
|
|
const int remote_mode = signal - LXST_PREFERRED_MODE;
|
|
if (remote_mode != LXST_MODE_FULL_DUPLEX) {
|
|
INFO("LXST: Remote requested non-FDX mode, responding FDX");
|
|
call_send_signal(LXST_PREFERRED_MODE + LXST_MODE_FULL_DUPLEX);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
{
|
|
char dbg[48];
|
|
snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal);
|
|
INFO(dbg);
|
|
}
|
|
|
|
// Enqueue for processing in call_update() under LVGL lock.
|
|
uint8_t w = _call_signal_write;
|
|
uint8_t next_w = (w + 1) % SIGNAL_QUEUE_SIZE;
|
|
if (next_w != _call_signal_read) { // Not full
|
|
_call_signal_queue[w] = static_cast<uint8_t>(signal);
|
|
_call_signal_write = next_w;
|
|
} else {
|
|
WARNING("LXST: Signal queue full, dropping signal!");
|
|
}
|
|
}
|
|
|
|
} else if (field == 0x01) {
|
|
// Audio: {0x01: value} where value is either:
|
|
// - bin8/bin16: single frame (codec_header + frame_data)
|
|
// - fixarray: batched frames [bin8(...), bin8(...), ...]
|
|
// Audio buffer writes don't touch LVGL — safe to process here
|
|
|
|
// Loopback test mode routes audio here with _call_state == IDLE.
|
|
if ((!_call_loopback && _call_state != CallState::ACTIVE && _call_state != CallState::CONNECTING)
|
|
|| !_lxst_audio) {
|
|
return;
|
|
}
|
|
|
|
uint8_t fmt = buf[2];
|
|
|
|
if ((fmt & 0xF0) == 0x90) {
|
|
// fixarray: batched frames — Columba sends up to 3 per packet
|
|
int array_len = fmt & 0x0F;
|
|
size_t pos = 3; // start after fixarray byte
|
|
|
|
for (int i = 0; i < array_len; i++) {
|
|
if (pos >= data.size()) break;
|
|
|
|
size_t frame_len;
|
|
size_t frame_start;
|
|
|
|
if (buf[pos] == 0xC4) {
|
|
// bin8
|
|
if (pos + 1 >= data.size()) break;
|
|
frame_len = buf[pos + 1];
|
|
frame_start = pos + 2;
|
|
} else if (buf[pos] == 0xC5) {
|
|
// bin16
|
|
if (pos + 2 >= data.size()) break;
|
|
frame_len = ((size_t)buf[pos + 1] << 8) | buf[pos + 2];
|
|
frame_start = pos + 3;
|
|
} else {
|
|
// Unknown format in array — skip rest
|
|
break;
|
|
}
|
|
|
|
if (frame_start + frame_len > data.size() || frame_len < 2) break;
|
|
|
|
call_rx_audio_frame(buf + frame_start, frame_len);
|
|
pos = frame_start + frame_len;
|
|
}
|
|
} else if (fmt == 0xC4) {
|
|
// bin8: single frame
|
|
if (data.size() < 5) return;
|
|
size_t frame_len = buf[3];
|
|
if (data.size() < 4 + frame_len || frame_len < 2) return;
|
|
call_rx_audio_frame(buf + 4, frame_len);
|
|
} else if (fmt == 0xC5) {
|
|
// bin16: single frame
|
|
if (data.size() < 6) return;
|
|
size_t frame_len = ((size_t)buf[3] << 8) | buf[4];
|
|
if (data.size() < 5 + frame_len || frame_len < 2) return;
|
|
call_rx_audio_frame(buf + 5, frame_len);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process received signal — runs under LVGL lock from call_update()
|
|
void UIManager::call_process_signal(uint8_t signal) {
|
|
{
|
|
char dbg[48];
|
|
snprintf(dbg, sizeof(dbg), "LXST: Processing signal 0x%02X (state=%d)", signal, (int)_call_state);
|
|
INFO(dbg);
|
|
}
|
|
|
|
// Terminal is generation-scoped and valid in every owned non-idle call
|
|
// state. call_ended() is idempotent with the subsequent Link-close callback
|
|
// because it clears link ownership before teardown.
|
|
if (signal == LXST_STATUS_TERMINATED) {
|
|
if (_call_state != CallState::IDLE ||
|
|
call_current_generation() != 0) {
|
|
INFO("LXST: Remote terminated call");
|
|
call_ended();
|
|
}
|
|
return;
|
|
}
|
|
|
|
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 our preferred profile (default ULBW = Codec2-700C)
|
|
call_send_signal(ULBWVoiceProfilePolicy::preferredProfileSignal());
|
|
_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());
|
|
|
|
int codec_mode = profile_to_codec2_mode(_preferred_profile);
|
|
if (codec_mode < 0) codec_mode = CODEC2_MODE_700C;
|
|
if (!_lxst_audio) {
|
|
_lxst_audio = new LXSTAudio();
|
|
}
|
|
lxst_breadcrumb(21, ESP.getFreeHeap());
|
|
if (!_lxst_audio->init(codec_mode)) {
|
|
WARNING("LXST: Audio init failed");
|
|
call_ended();
|
|
return;
|
|
}
|
|
INFOF("LXST: Audio initialized (internal=%u largest=%u)",
|
|
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
|
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
|
|
lxst_breadcrumb(22, ESP.getFreeHeap());
|
|
// Start full-duplex audio (mic + speaker)
|
|
if (!_lxst_audio->startFullDuplex()) {
|
|
WARNING("LXST: Full-duplex start failed");
|
|
call_ended();
|
|
return;
|
|
}
|
|
_lxst_audio->setCaptureMute(_call_muted);
|
|
lxst_breadcrumb(23, ESP.getFreeHeap());
|
|
|
|
} else if (signal == LXST_STATUS_ESTABLISHED) {
|
|
INFO("LXST: Call established!");
|
|
_call_state = CallState::ACTIVE;
|
|
_call_start_ms = millis();
|
|
_call_liveness.arm(_call_start_ms);
|
|
_call_screen->set_state(CallScreen::CallState::ACTIVE);
|
|
lxst_breadcrumb(24, ESP.getFreeHeap());
|
|
|
|
int codec_mode = profile_to_codec2_mode(_preferred_profile);
|
|
if (codec_mode < 0) codec_mode = CODEC2_MODE_700C;
|
|
if (!_lxst_audio) {
|
|
_lxst_audio = new LXSTAudio();
|
|
if (!_lxst_audio->init(codec_mode)) {
|
|
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");
|
|
call_ended();
|
|
return;
|
|
}
|
|
_lxst_audio->setCaptureMute(_call_muted);
|
|
}
|
|
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_liveness.arm(_call_start_ms);
|
|
_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");
|
|
call_ended();
|
|
return;
|
|
}
|
|
_lxst_audio->setCaptureMute(_call_muted);
|
|
}
|
|
INFO("LXST: Call active (full-duplex)");
|
|
}
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void UIManager::call_ended() {
|
|
INFO("LXST: Call ended");
|
|
|
|
const uint32_t generation =
|
|
call_current_generation();
|
|
|
|
// Reject all callbacks before shared Link teardown/reset. Keep the call
|
|
// generation reserved until owner-side cleanup is complete.
|
|
_call_link_ownership.clear(generation);
|
|
|
|
call_teardown_audio();
|
|
|
|
// Teardown link
|
|
if (_call_link) {
|
|
_call_link.teardown();
|
|
_call_link = Link(Type::NONE);
|
|
}
|
|
|
|
_call_peer_hash = Bytes();
|
|
_call_dest_hash = Bytes();
|
|
_call_start_ms = 0;
|
|
_call_timeout_ms = 0;
|
|
_call_muted = false;
|
|
_call_answer_pending.store(false, std::memory_order_release);
|
|
_call_signal_write = 0;
|
|
_call_signal_read = 0;
|
|
_call_audio_rx_count = 0;
|
|
_call_audio_tx_count = 0;
|
|
_call_commands.take();
|
|
_call_liveness.disarm();
|
|
_call_state = CallState::IDLE;
|
|
call_clear_generation(generation);
|
|
|
|
_call_screen->set_state(CallScreen::CallState::ENDED);
|
|
|
|
if (_navigation.current() == Route::CALL) back();
|
|
}
|
|
|
|
void UIManager::pump_call_tx() {
|
|
// In loopback test mode _call_state is IDLE and there is no link, but mic
|
|
// capture is running — drain the encoded packets and feed the local
|
|
// loopback (see call_send_audio_batch). All bypasses gate on _call_loopback.
|
|
if (!_call_loopback && _call_state == CallState::IDLE) return;
|
|
if (!_lxst_audio || !_lxst_audio->isCapturing()) return;
|
|
if (!_call_loopback && (!_call_link || _call_link.status() != Type::Link::ACTIVE)) return;
|
|
|
|
int available = _lxst_audio->capturePacketsAvailable();
|
|
|
|
// Drain all available batches — this runs on loopTask (core 1)
|
|
// and doesn't touch LVGL, so no lock needed.
|
|
while (available > 0) {
|
|
uint8_t encoded_buf[128];
|
|
int encoded_len = 0;
|
|
if (!_lxst_audio->readEncodedPacket(encoded_buf, sizeof(encoded_buf), &encoded_len)) {
|
|
break;
|
|
}
|
|
if (encoded_len < 2) { available--; continue; }
|
|
|
|
// Prepend codec type byte: [0x02] + [mode_header + N raw Codec2 frames].
|
|
uint8_t batch_data[128];
|
|
batch_data[0] = LXST_CODEC_CODEC2;
|
|
memcpy(batch_data + 1, encoded_buf, encoded_len);
|
|
int batch_len = 1 + encoded_len;
|
|
|
|
const int framesPerPacket = ULBWVoiceProfilePolicy::framesPerPacket(320);
|
|
call_send_audio_batch(batch_data, batch_len, 1, framesPerPacket);
|
|
_call_audio_tx_count++;
|
|
available--;
|
|
|
|
if (_call_audio_tx_count <= 10 || (_call_audio_tx_count % 100 == 0)) {
|
|
char dbg[96];
|
|
snprintf(dbg, sizeof(dbg), "LXST: TX batch #%lu (%d bytes, avail=%d)",
|
|
(unsigned long)_call_audio_tx_count, batch_len, available);
|
|
INFO(dbg);
|
|
}
|
|
}
|
|
}
|
|
|
|
void UIManager::start_loopback() {
|
|
// Don't stomp a live real call. (The harness never overlaps the two, but
|
|
// be defensive: a real call owns _lxst_audio and must not be torn down.)
|
|
if (_call_state != CallState::IDLE ||
|
|
call_current_generation() != 0) {
|
|
WARNING("LXST: Loopback refused — call in progress");
|
|
return;
|
|
}
|
|
|
|
// Always (re)create the pipeline so it picks up the currently selected
|
|
// profile/codec mode (driven by T:CALL_PROFILE). Mirrors call_answer().
|
|
call_teardown_audio();
|
|
_call_audio_rx_count = 0;
|
|
_call_audio_tx_count = 0;
|
|
|
|
_lxst_audio = new LXSTAudio();
|
|
int codec_mode = profile_to_codec2_mode(_preferred_profile);
|
|
if (codec_mode < 0) codec_mode = CODEC2_MODE_700C;
|
|
if (!_lxst_audio->init(codec_mode)) {
|
|
WARNING("LXST: Loopback audio init failed");
|
|
call_teardown_audio();
|
|
return;
|
|
}
|
|
// Same start path a real call uses: mic + speaker simultaneously, so
|
|
// isCapturing() (pump_call_tx) and isPlaying() (writeEncodedPacket) hold.
|
|
if (!_lxst_audio->startFullDuplex()) {
|
|
WARNING("LXST: Loopback full-duplex start failed");
|
|
call_teardown_audio();
|
|
return;
|
|
}
|
|
|
|
_call_loopback = true; // enable bypass branches BEFORE arming the dump
|
|
pyxis_audio_dump_arm(true); // resets the running PCM byte offset to 0
|
|
INFO("LXST: Loopback started (codec mode set by profile)");
|
|
}
|
|
|
|
void UIManager::stop_loopback() {
|
|
// Disarm + clear the flag FIRST so pump_call_tx()/writeEncodedPacket()
|
|
// stop touching _lxst_audio before we tear it down.
|
|
_call_loopback = false;
|
|
pyxis_audio_dump_arm(false);
|
|
|
|
call_teardown_audio();
|
|
INFO("LXST: Loopback stopped");
|
|
}
|
|
|
|
void UIManager::call_update() {
|
|
uint32_t now = millis();
|
|
|
|
// Consume only the current owner's atomically generation-bound close. No
|
|
// shared Link read is needed on this callback-to-loopTask handoff.
|
|
if (_call_link_ownership.takeClosed() != 0) {
|
|
call_ended();
|
|
return;
|
|
}
|
|
|
|
// Process all queued signals (set by Reticulum packet callback, consumed here under LVGL lock)
|
|
while (_call_signal_read != _call_signal_write) {
|
|
uint8_t sig = _call_signal_queue[_call_signal_read];
|
|
_call_signal_read = (_call_signal_read + 1) % SIGNAL_QUEUE_SIZE;
|
|
call_process_signal(sig);
|
|
if (_call_state == CallState::IDLE) return; // Signal caused call to end
|
|
}
|
|
|
|
// Process deferred answer (set by LVGL task, consumed here on main thread)
|
|
if (_call_answer_pending.exchange(false, std::memory_order_acq_rel)) {
|
|
call_answer();
|
|
}
|
|
|
|
// Show incoming call UI (deferred from link callback to LVGL-safe context)
|
|
if (_call_state == CallState::INCOMING_RINGING && _navigation.current() != Route::CALL) {
|
|
_call_screen->set_peer(_call_peer_hash);
|
|
_call_screen->set_state(CallScreen::CallState::INCOMING_RINGING);
|
|
_call_screen->set_muted(false);
|
|
navigate(Route::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");
|
|
const uint32_t generation = call_current_generation();
|
|
// Register callbacks before the constructor sends the link request,
|
|
// then publish its exact ID immediately after construction.
|
|
_call_link = Link(peer_dest, on_call_link_established, on_call_link_closed);
|
|
if (!call_publish_link(_call_link, generation)) {
|
|
WARNING("LXST: Link has invalid ID, aborting call");
|
|
call_ended();
|
|
return;
|
|
}
|
|
_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_IDENTIFYING:
|
|
WARNING("LXST: Incoming caller identification timed out");
|
|
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;
|
|
}
|
|
|
|
if (_call_state == CallState::ACTIVE &&
|
|
_call_liveness.expired(now, CALL_MEDIA_LIVENESS_TIMEOUT_MS)) {
|
|
WARNING("LXST: Call media liveness timed out");
|
|
call_ended();
|
|
return;
|
|
}
|
|
|
|
// Update duration display (ACTIVE only)
|
|
if (_call_state == CallState::ACTIVE) {
|
|
uint32_t duration_secs = (now - _call_start_ms) / 1000;
|
|
_call_screen->set_duration(duration_secs);
|
|
|
|
// Periodic audio stats (every 2 seconds)
|
|
if (duration_secs > 0 && duration_secs % 2 == 0 &&
|
|
now - _call_start_ms > duration_secs * 1000 - 500) {
|
|
static uint32_t last_stats_sec = 0;
|
|
if (duration_secs != last_stats_sec) {
|
|
last_stats_sec = duration_secs;
|
|
char dbg[128];
|
|
snprintf(dbg, sizeof(dbg), "LXST: Audio stats: TX=%lu RX=%lu playBuf=%d capAvail=%d state=%d link=%d",
|
|
(unsigned long)_call_audio_tx_count,
|
|
(unsigned long)_call_audio_rx_count,
|
|
_lxst_audio ? _lxst_audio->playbackFramesBuffered() : -1,
|
|
_lxst_audio ? _lxst_audio->capturePacketsAvailable() : -1,
|
|
_lxst_audio ? (int)_lxst_audio->state() : -1,
|
|
_call_link ? (int)_call_link.status() : -99);
|
|
INFO(dbg);
|
|
}
|
|
}
|
|
}
|
|
|
|
// TX pump — also called from main loop without LVGL lock for low latency
|
|
pump_call_tx();
|
|
}
|
|
}
|
|
|
|
// ── Static Link Callbacks ──
|
|
|
|
void UIManager::on_call_link_established(Link& link) {
|
|
if (!s_call_instance) return;
|
|
LVGL_LOCK();
|
|
auto* self = s_call_instance;
|
|
|
|
CallLinkOwnership::LinkId link_id{};
|
|
if (!call_extract_link_id(link, link_id)) {
|
|
WARNING("LXST: Established link has invalid ID (ignoring)");
|
|
return;
|
|
}
|
|
const uint32_t generation =
|
|
self->_call_link_ownership.generationFor(link_id);
|
|
if (!self->call_owns_link(link_id, generation) ||
|
|
self->_call_state != CallState::LINK_ESTABLISHING) {
|
|
WARNING("LXST: Stale outgoing link established (ignoring)");
|
|
return;
|
|
}
|
|
|
|
char buf[80];
|
|
snprintf(buf, sizeof(buf), "LXST: Outgoing link established (status=%d)", (int)link.status());
|
|
INFO(buf);
|
|
|
|
// Register through the validated callback argument; never read the
|
|
// manager's shared Link object from a transport-thread callback.
|
|
link.set_packet_callback(on_call_link_packet);
|
|
link.set_link_closed_callback(on_call_link_closed);
|
|
INFO("LXST: Packet callback registered on outgoing link");
|
|
|
|
// Transition to waiting for STATUS_AVAILABLE
|
|
self->_call_state = CallState::WAIT_AVAILABLE;
|
|
self->_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;
|
|
LVGL_LOCK();
|
|
auto* self = s_call_instance;
|
|
|
|
CallLinkOwnership::LinkId link_id{};
|
|
if (!call_extract_link_id(link, link_id)) {
|
|
WARNING("LXST: Closed link has invalid ID (ignoring)");
|
|
return;
|
|
}
|
|
const uint32_t generation =
|
|
self->_call_link_ownership.generationFor(link_id);
|
|
if (!self->call_owns_link(link_id, generation)) {
|
|
WARNING("LXST: Stale link closed (ignoring)");
|
|
return;
|
|
}
|
|
|
|
WARNING("LXST: Link closed (deferred)");
|
|
|
|
// Bind pending and generation in one CAS. If ownership changed after the
|
|
// validation above, this exact old state cannot overwrite the new slot.
|
|
if (!self->_call_link_ownership.markClosed(generation, link_id)) {
|
|
WARNING("LXST: Link owner changed before close deferral (ignoring)");
|
|
}
|
|
}
|
|
|
|
void UIManager::on_call_link_packet(const Bytes& plaintext, const Packet& packet) {
|
|
if (!s_call_instance) return;
|
|
LVGL_LOCK();
|
|
auto* self = s_call_instance;
|
|
const Link& callback_link = packet.link();
|
|
CallLinkOwnership::LinkId link_id{};
|
|
if (!call_extract_link_id(callback_link, link_id)) {
|
|
WARNING("LXST: Packet link has invalid ID (ignoring)");
|
|
return;
|
|
}
|
|
const uint32_t generation =
|
|
self->_call_link_ownership.generationFor(link_id);
|
|
if (!self->call_owns_link(link_id, generation)) {
|
|
WARNING("LXST: Stale link packet (ignoring)");
|
|
return;
|
|
}
|
|
self->call_on_packet(plaintext);
|
|
}
|
|
|
|
// ── LXST Incoming Call Callbacks ──
|
|
|
|
void UIManager::on_lxst_link_established(Link& link) {
|
|
if (!s_call_instance) {
|
|
link.teardown();
|
|
return;
|
|
}
|
|
LVGL_LOCK();
|
|
auto* self = s_call_instance;
|
|
|
|
CallLinkOwnership::LinkId link_id{};
|
|
if (!call_extract_link_id(link, link_id)) {
|
|
WARNING("LXST: Incoming link has invalid ID (rejecting)");
|
|
link.teardown();
|
|
return;
|
|
}
|
|
|
|
// Incoming and outgoing admissions are ordered by the recursive LVGL
|
|
// mutex, including when an interface worker dispatches this callback. The
|
|
// atomic generation reservation remains the definitive ownership decision.
|
|
const uint32_t generation = self->call_begin_generation();
|
|
lxst_breadcrumb(10, ESP.getFreeHeap());
|
|
INFO("LXST: Incoming link established");
|
|
if (generation == 0) {
|
|
// Another call owns the manager. Reject using only this callback's link;
|
|
// never touch the stored owner link or any owner-side call state.
|
|
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;
|
|
}
|
|
|
|
// Store the accepted Link before publishing its exact 128-bit ID. No
|
|
// callbacks are installed on the stored owner until publication and call
|
|
// state initialization are complete.
|
|
lxst_breadcrumb(11, ESP.getFreeHeap());
|
|
self->_call_link = link;
|
|
if (!self->_call_link_ownership.publish(generation, link_id)) {
|
|
WARNING("LXST: Incoming link has invalid ID (rejecting)");
|
|
self->_call_link.teardown();
|
|
self->_call_link = Link(Type::NONE);
|
|
self->_call_generation_guard.release(generation);
|
|
return;
|
|
}
|
|
self->_call_state = CallState::INCOMING_IDENTIFYING;
|
|
self->_call_peer_hash = Bytes();
|
|
self->_call_dest_hash = Bytes();
|
|
self->_call_muted = false;
|
|
self->_call_answer_pending.store(false, std::memory_order_release);
|
|
self->_call_signal_write = 0;
|
|
self->_call_signal_read = 0;
|
|
self->_call_audio_rx_count = 0;
|
|
self->_call_audio_tx_count = 0;
|
|
self->_call_commands.take();
|
|
self->_call_timeout_ms = millis() + INCOMING_IDENTIFY_TIMEOUT_MS;
|
|
|
|
// Wait for the accepted caller to identify and observe closure. Install on
|
|
// the validated callback argument, not the manager's shared Link handle.
|
|
link.set_remote_identified_callback(on_lxst_caller_identified);
|
|
link.set_link_closed_callback(on_call_link_closed);
|
|
|
|
// Send STATUS_AVAILABLE
|
|
lxst_breadcrumb(12, ESP.getFreeHeap());
|
|
call_send_signal_on_link(link, LXST_STATUS_AVAILABLE);
|
|
|
|
lxst_breadcrumb(13, ESP.getFreeHeap());
|
|
INFO("LXST: Waiting for caller identity (15s timeout)");
|
|
lxst_breadcrumb(14, ESP.getFreeHeap());
|
|
}
|
|
|
|
void UIManager::on_lxst_caller_identified(const Link& link, const Identity& identity) {
|
|
if (!s_call_instance) return;
|
|
LVGL_LOCK();
|
|
auto* self = s_call_instance;
|
|
lxst_breadcrumb(15, ESP.getFreeHeap());
|
|
|
|
CallLinkOwnership::LinkId link_id{};
|
|
if (!call_extract_link_id(link, link_id)) {
|
|
WARNING("LXST: Identified link has invalid ID (ignoring)");
|
|
return;
|
|
}
|
|
// Bind identity to the exact current owner ID and generation. Validation
|
|
// never reads the manager's shared Link object.
|
|
const uint32_t generation =
|
|
self->_call_link_ownership.generationFor(link_id);
|
|
if (!self->call_owns_link(link_id, generation) ||
|
|
self->_call_state != CallState::INCOMING_IDENTIFYING) {
|
|
WARNING("LXST: Stale caller identified (ignoring)");
|
|
return;
|
|
}
|
|
|
|
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 callbacks and send through a callback-local shared-object copy, not
|
|
// through the manager's concurrently resettable Link handle.
|
|
Link callback_link(link);
|
|
callback_link.set_packet_callback(on_call_link_packet);
|
|
|
|
// Identification completed for the reserved owner; ringing can now become
|
|
// visible and actionable on the next call_update().
|
|
self->_call_state = CallState::INCOMING_RINGING;
|
|
|
|
// Send STATUS_RINGING
|
|
call_send_signal_on_link(callback_link, LXST_STATUS_RINGING);
|
|
|
|
// Incoming ringing UI will be shown in call_update().
|
|
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());
|
|
{
|
|
int codec_mode = profile_to_codec2_mode(_preferred_profile);
|
|
if (codec_mode < 0) codec_mode = CODEC2_MODE_700C;
|
|
if (!_lxst_audio->init(codec_mode)) {
|
|
WARNING("LXST: Audio init failed");
|
|
call_ended();
|
|
return;
|
|
}
|
|
}
|
|
INFOF("LXST: Audio initialized (internal=%u largest=%u)",
|
|
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
|
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
|
|
lxst_breadcrumb(32, ESP.getFreeHeap());
|
|
|
|
// Start full-duplex audio (mic + speaker)
|
|
if (!_lxst_audio->startFullDuplex()) {
|
|
WARNING("LXST: Full-duplex start failed");
|
|
call_ended();
|
|
return;
|
|
}
|
|
_lxst_audio->setCaptureMute(_call_muted);
|
|
INFOF("LXST: Full-duplex ready (internal=%u largest=%u)",
|
|
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
|
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
|
|
lxst_breadcrumb(33, ESP.getFreeHeap());
|
|
|
|
// Send profile preference (default ULBW = Codec2-700C). Answerer
|
|
// sends last and wins.
|
|
call_send_signal(ULBWVoiceProfilePolicy::preferredProfileSignal());
|
|
|
|
// Send STATUS_ESTABLISHED
|
|
call_send_signal(LXST_STATUS_ESTABLISHED);
|
|
|
|
// Transition to active call
|
|
_call_state = CallState::ACTIVE;
|
|
_call_start_ms = millis();
|
|
_call_liveness.arm(_call_start_ms);
|
|
INFO("LXST: Call active (answerer, full-duplex)");
|
|
}
|
|
|
|
void UIManager::announce_lxst() {
|
|
if (_lxst_destination) {
|
|
std::string h = _lxst_destination.hash().toHex();
|
|
INFO(("Announcing LXST telephony destination: " + h).c_str());
|
|
_lxst_destination.announce();
|
|
INFO("LXST announce sent");
|
|
} else {
|
|
WARNING("announce_lxst skipped: _lxst_destination not constructed");
|
|
}
|
|
}
|
|
|
|
} // namespace LXMF
|
|
} // namespace UI
|
|
|
|
#endif // ARDUINO
|