diff --git a/.github/workflows/cardputer-zero-linux.yml b/.github/workflows/cardputer-zero-linux.yml index 827d3483..1cea56bf 100644 --- a/.github/workflows/cardputer-zero-linux.yml +++ b/.github/workflows/cardputer-zero-linux.yml @@ -5,12 +5,14 @@ on: paths: - "apps/linux_sim/**" - "apps/linux_rpi/**" + - "apps/linux_uconsole/**" - "platform/linux/**" - ".github/workflows/cardputer-zero-linux.yml" pull_request: paths: - "apps/linux_sim/**" - "apps/linux_rpi/**" + - "apps/linux_uconsole/**" - "platform/linux/**" - ".github/workflows/cardputer-zero-linux.yml" @@ -28,7 +30,7 @@ jobs: - name: Install Linux simulator dependencies run: | sudo apt-get update - sudo apt-get install -y ninja-build xorg-dev libasound2-dev libegl1-mesa-dev libwayland-dev libxkbcommon-dev wayland-protocols + sudo apt-get install -y ninja-build dpkg-dev xorg-dev libasound2-dev libegl1-mesa-dev libwayland-dev libxkbcommon-dev wayland-protocols libgtk-4-dev libsqlite3-dev libcurl4-openssl-dev - name: Configure simulator working-directory: apps/linux_sim @@ -58,6 +60,29 @@ jobs: working-directory: apps/linux_rpi run: cmake --build --preset linux-device-debug-build + - name: Configure uConsole Linux shell + working-directory: apps/linux_uconsole + run: cmake --preset linux-uconsole-debug + + - name: Build uConsole Linux shell + working-directory: apps/linux_uconsole + run: cmake --build --preset linux-uconsole-debug-build + + - name: Configure uConsole Debian package + working-directory: apps/linux_uconsole + run: cmake --preset linux-uconsole-release + + - name: Build uConsole Debian package + working-directory: apps/linux_uconsole + run: cmake --build --preset linux-uconsole-deb + + - name: Upload uConsole Debian package + uses: actions/upload-artifact@v4 + with: + name: trailmate-uconsole-deb + path: apps/linux_uconsole/build/uconsole-release/*.deb + if-no-files-found: error + - name: Verify split Linux workflow from WSL-style entrypoint working-directory: apps/linux_sim run: bash scripts/wsl-validate.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 63155777..ea8fc48e 100644 Binary files a/CHANGELOG.md and b/CHANGELOG.md differ diff --git a/README.md b/README.md index 857341fa..f8aa96cd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > A low-power, offline-first handheld device for outdoor navigation and communication -[English](README.md) | [中文](README_CN.md) | [Join Discord](https://discord.gg/87PVMVUP) +[English](README.md) | [中文](README_CN.md) | [Join Discord](https://discord.gg/UpDsAz9H3) --- diff --git a/README_CN.md b/README_CN.md index e87d8821..89d4909e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,7 +4,7 @@ > 面向户外导航与通信的低功耗、离线优先手持设备 -[English](README.md) | [中文](README_CN.md) | [加入 Discord 社区](https://discord.gg/87PVMVUP) +[English](README.md) | [中文](README_CN.md) | [加入 Discord 社区](https://discord.gg/UpDsAz9H3) --- diff --git a/apps/README.md b/apps/README.md index 46c015b1..226eb1c3 100644 --- a/apps/README.md +++ b/apps/README.md @@ -16,3 +16,5 @@ For Linux bring-up: - `apps/linux_sim` is the simulator and developer-tooling shell - `apps/linux_rpi` is the real Pi OS device shell for Cardputer Zero +- `apps/linux_uconsole` is the desktop-class Linux handheld shell for + uConsole/AIO2-class targets diff --git a/apps/esp_idf/include/apps/esp_idf/meshtastic_radio_adapter.h b/apps/esp_idf/include/apps/esp_idf/meshtastic_radio_adapter.h index 0a425ca8..c4793d89 100644 --- a/apps/esp_idf/include/apps/esp_idf/meshtastic_radio_adapter.h +++ b/apps/esp_idf/include/apps/esp_idf/meshtastic_radio_adapter.h @@ -2,6 +2,7 @@ #include "board/LoraBoard.h" #include "chat/domain/chat_types.h" +#include "chat/domain/contact_types.h" #include "chat/infra/meshtastic/mt_codec_pb.h" #include "chat/infra/meshtastic/mt_dedup.h" #include "chat/ports/i_mesh_adapter.h" @@ -59,11 +60,12 @@ class MeshtasticRadioAdapter final : public chat::IMeshAdapter void updateChannelKeys(); void initNodeIdentity(); void ensureReceiveStarted(); - bool decodeUserPayload(const uint8_t* payload, size_t len, - const chat::RxMeta& rx_meta, - chat::NodeId from_node, - uint8_t channel_index); - void publishPositionEvent(chat::NodeId node_id, const meshtastic_Position& pos); + bool publishNodePayload(const meshtastic_Data& data, + const chat::RxMeta& rx_meta, + chat::NodeId from_node, + uint8_t channel_index); + void publishPositionEvent(chat::NodeId node_id, + const chat::contacts::NodePosition& pos); uint8_t channelHashFor(chat::ChannelId channel) const; const uint8_t* channelKeyFor(chat::ChannelId channel, size_t* out_len) const; diff --git a/apps/esp_idf/src/gps_service_api.cpp b/apps/esp_idf/src/gps_service_api.cpp index d12a33e7..e6e68d62 100644 --- a/apps/esp_idf/src/gps_service_api.cpp +++ b/apps/esp_idf/src/gps_service_api.cpp @@ -17,6 +17,11 @@ bool gps_get_gnss_snapshot(gps::GnssSatInfo* out, size_t max, size_t* out_count, return platform::esp::idf_common::gps_runtime::get_gnss_snapshot(out, max, out_count, status); } +GpsDiagnosticsSnapshot gps_get_diagnostics() +{ + return platform::esp::idf_common::gps_runtime::diagnostics(); +} + uint32_t gps_get_last_motion_ms() { return platform::esp::idf_common::gps_runtime::last_motion_ms(); @@ -57,6 +62,11 @@ void gps_set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) platform::esp::idf_common::gps_runtime::set_external_nmea_config(output_hz, sentence_mask); } +void gps_set_receiver_init_config(const GpsReceiverInitConfig& config) +{ + platform::esp::idf_common::gps_runtime::set_receiver_init_config(config); +} + void gps_set_motion_idle_timeout(uint32_t timeout_ms) { platform::esp::idf_common::gps_runtime::set_motion_idle_timeout(timeout_ms); diff --git a/apps/esp_idf/src/meshtastic_radio_adapter.cpp b/apps/esp_idf/src/meshtastic_radio_adapter.cpp index 8d87ce62..357042fa 100644 --- a/apps/esp_idf/src/meshtastic_radio_adapter.cpp +++ b/apps/esp_idf/src/meshtastic_radio_adapter.cpp @@ -1,9 +1,10 @@ #include "apps/esp_idf/meshtastic_radio_adapter.h" #include "chat/domain/contact_types.h" +#include "chat/infra/meshtastic/mt_node_payload.h" #include "chat/infra/meshtastic/mt_packet_wire.h" #include "chat/infra/meshtastic/mt_protocol_helpers.h" -#include "chat/infra/meshtastic/mt_region.h" +#include "chat/infra/meshtastic/mt_radio_config.h" #include "chat/time_utils.h" #include "esp_log.h" #include "esp_mac.h" @@ -25,8 +26,6 @@ namespace constexpr const char* kTag = "idf-mt"; constexpr uint8_t kDefaultPskIndex = 1; -constexpr uint8_t kLoraSyncWord = 0x2B; -constexpr uint16_t kLoraPreambleLen = 16; constexpr uint16_t kIrqRxDone = 0x0002; constexpr uint16_t kIrqHeaderErr = 0x0020; constexpr uint16_t kIrqCrcErr = 0x0040; @@ -40,19 +39,6 @@ uint32_t now_millis() return static_cast(esp_timer_get_time() / 1000ULL); } -const char* primary_channel_name(const chat::MeshConfig& config) -{ - if (!config.use_preset) - { - return "Custom"; - } - - const auto preset = - static_cast(config.modem_preset); - const char* name = chat::meshtastic::presetDisplayName(preset); - return (name && name[0] != '\0') ? name : "Custom"; -} - uint8_t to_channel_index(chat::ChannelId channel) { return (channel == chat::ChannelId::SECONDARY) ? 1U : 0U; @@ -489,11 +475,10 @@ void MeshtasticRadioAdapter::processReceivedPacket(const uint8_t* data, size_t s { if (decoded.payload.size > 0) { - (void)decodeUserPayload(decoded.payload.bytes, - decoded.payload.size, - rx_meta, - header.from, - to_channel_index(channel)); + (void)publishNodePayload(decoded, + rx_meta, + header.from, + to_channel_index(channel)); } if (want_response && (to_us || is_broadcast)) { @@ -504,11 +489,14 @@ void MeshtasticRadioAdapter::processReceivedPacket(const uint8_t* data, size_t s if (decoded.portnum == meshtastic_PortNum_POSITION_APP && decoded.payload.size > 0) { - meshtastic_Position pos = meshtastic_Position_init_zero; - pb_istream_t pos_stream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); - if (pb_decode(&pos_stream, meshtastic_Position_fields, &pos)) + chat::meshtastic::DecodedPositionPayload position{}; + if (chat::meshtastic::decodePositionPayload( + decoded, + header.from, + rx_meta.rx_timestamp_s, + &position)) { - publishPositionEvent(header.from, pos); + publishPositionEvent(position.node_id, position.position); } } @@ -605,66 +593,39 @@ void MeshtasticRadioAdapter::configureRadio() return; } - auto region_code = static_cast(config_.region); - if (region_code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) - { - region_code = meshtastic_Config_LoRaConfig_RegionCode_CN; - } - const chat::meshtastic::RegionInfo* region = chat::meshtastic::findRegion(region_code); + const chat::meshtastic::RadioConfig radio = + chat::meshtastic::deriveRadioConfig(config_); - float bw_khz = 250.0f; - uint8_t sf = 11; - uint8_t cr_denom = 5; - if (config_.use_preset) - { - const auto preset = - static_cast(config_.modem_preset); - chat::meshtastic::modemPresetToParams(preset, region->wide_lora, bw_khz, sf, cr_denom); - } - else - { - bw_khz = config_.bandwidth_khz; - sf = config_.spread_factor; - cr_denom = config_.coding_rate; - } - - float freq_mhz = chat::meshtastic::computeFrequencyMhz(region, bw_khz, primary_channel_name(config_)); - if (config_.override_frequency_mhz > 0.0f) - { - freq_mhz = config_.override_frequency_mhz; - } - freq_mhz += config_.frequency_offset_mhz; - - int8_t tx_power = config_.tx_power; - if (region->power_limit_dbm > 0) - { - tx_power = std::min(tx_power == 0 ? static_cast(region->power_limit_dbm) : tx_power, - static_cast(region->power_limit_dbm)); - } - if (tx_power == 0) - { - tx_power = 17; - } - - board_.configureLoraRadio(freq_mhz, bw_khz, sf, cr_denom, tx_power, - kLoraPreambleLen, kLoraSyncWord, 2); - radio_freq_hz_ = static_cast(std::lround(freq_mhz * 1000000.0f)); - radio_bw_hz_ = static_cast(std::lround(bw_khz * 1000.0f)); - radio_sf_ = sf; - radio_cr_ = cr_denom; + board_.configureLoraRadio(radio.freq_mhz, + radio.bw_khz, + radio.sf, + radio.cr_denom, + radio.tx_power_dbm, + radio.preamble_len, + radio.sync_word, + radio.crc_len); + radio_freq_hz_ = static_cast(std::lround(radio.freq_mhz * 1000000.0f)); + radio_bw_hz_ = static_cast(std::lround(radio.bw_khz * 1000.0f)); + radio_sf_ = radio.sf; + radio_cr_ = radio.cr_denom; ready_ = true; rx_started_ = false; ensureReceiveStarted(); ESP_LOGI(kTag, - "radio ready node=%08lX region=%u freq=%.3f bw=%.1f sf=%u cr=4/%u tx=%d hash=(%02X,%02X)", + "radio ready node=%08lX region=%u preset=%u use_preset=%u freq=%.3f bw=%.1f sf=%u cr=4/%u tx=%d ch=%lu sync=0x%02X preamble=%u hash=(%02X,%02X)", static_cast(node_id_), - static_cast(region_code), - freq_mhz, - bw_khz, - static_cast(sf), - static_cast(cr_denom), - static_cast(tx_power), + static_cast(radio.region_code), + static_cast(radio.modem_preset), + radio.using_preset ? 1U : 0U, + radio.freq_mhz, + radio.bw_khz, + static_cast(radio.sf), + static_cast(radio.cr_denom), + static_cast(radio.tx_power_dbm), + static_cast(radio.channel_slot), + static_cast(radio.sync_word), + static_cast(radio.preamble_len), static_cast(primary_channel_hash_), static_cast(secondary_channel_hash_)); } @@ -692,9 +653,10 @@ void MeshtasticRadioAdapter::updateChannelKeys() secondary_psk_len_ = sizeof(secondary_psk_); } - primary_channel_hash_ = chat::meshtastic::computeChannelHash(primary_channel_name(config_), - primary_psk_, - primary_psk_len_); + primary_channel_hash_ = + chat::meshtastic::computeChannelHash(chat::meshtastic::primaryChannelName(config_), + primary_psk_, + primary_psk_len_); secondary_channel_hash_ = chat::meshtastic::computeChannelHash(kSecondaryChannelName, secondary_psk_len_ > 0 ? secondary_psk_ : nullptr, secondary_psk_len_); @@ -718,71 +680,79 @@ void MeshtasticRadioAdapter::ensureReceiveStarted() } } -bool MeshtasticRadioAdapter::decodeUserPayload(const uint8_t* payload, - size_t len, - const chat::RxMeta& rx_meta, - chat::NodeId from_node, - uint8_t channel_index) +bool MeshtasticRadioAdapter::publishNodePayload(const meshtastic_Data& data, + const chat::RxMeta& rx_meta, + chat::NodeId from_node, + uint8_t channel_index) { - if (!payload || len == 0) + chat::meshtastic::NodePayloadDecodeContext context{}; + context.fallback_node_id = from_node; + context.snr = static_cast(rx_meta.snr_db_x10) / 10.0f; + context.rssi = static_cast(rx_meta.rssi_dbm_x10) / 10.0f; + context.timestamp = rx_meta.rx_timestamp_s != 0 + ? rx_meta.rx_timestamp_s + : chat::now_message_timestamp(); + context.hops_away = rx_meta.hop_count; + context.channel = channel_index; + context.via_mqtt = + (rx_meta.wire_flags & + chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0; + + chat::meshtastic::DecodedNodePayload node{}; + if (!chat::meshtastic::decodeNodeInfoPayload(data, context, &node)) { return false; } - meshtastic_User user = meshtastic_User_init_default; - pb_istream_t user_stream = pb_istream_from_buffer(payload, len); - if (!pb_decode(&user_stream, meshtastic_User_fields, &user)) + sys::EventBus::publish( + new sys::NodeInfoUpdateEvent( + node.node_id, + node.short_name.c_str(), + node.long_name.c_str(), + node.snr, + node.rssi, + node.timestamp, + node.protocol, + node.role, + node.hops_away, + node.hw_model, + node.channel, + node.has_macaddr, + node.has_macaddr ? node.macaddr.data() : nullptr, + node.via_mqtt, + node.is_ignored, + node.has_public_key, + node.key_manually_verified, + node.has_device_metrics, + node.has_device_metrics ? &node.device_metrics : nullptr), + 0); + if (node.has_position) { - return false; + publishPositionEvent(node.node_id, node.position); } - - char short_name[10] = {}; - char long_name[32] = {}; - const size_t short_len = strnlen(user.short_name, sizeof(user.short_name)); - const size_t long_len = strnlen(user.long_name, sizeof(user.long_name)); - std::memcpy(short_name, user.short_name, std::min(short_len, sizeof(short_name) - 1)); - std::memcpy(long_name, user.long_name, std::min(long_len, sizeof(long_name) - 1)); - - auto* event = new sys::NodeInfoUpdateEvent(from_node, - "", - "", - static_cast(rx_meta.snr_db_x10) / 10.0f, - static_cast(rx_meta.rssi_dbm_x10) / 10.0f, - chat::now_message_timestamp(), - static_cast(chat::contacts::NodeProtocolType::Meshtastic), - static_cast(user.role), - rx_meta.hop_count, - static_cast(user.hw_model), - channel_index); - std::memcpy(event->short_name, short_name, sizeof(short_name)); - std::memcpy(event->long_name, long_name, sizeof(long_name)); - sys::EventBus::publish(event, 0); return true; } void MeshtasticRadioAdapter::publishPositionEvent(chat::NodeId node_id, - const meshtastic_Position& pos) + const chat::contacts::NodePosition& pos) { - if (node_id == 0 || !chat::meshtastic::hasValidPosition(pos)) + if (node_id == 0 || !pos.valid) { return; } - const bool has_altitude = pos.has_altitude || pos.has_altitude_hae; - const int32_t altitude = pos.has_altitude ? pos.altitude : pos.altitude_hae; - const uint32_t ts = pos.timestamp ? pos.timestamp : pos.time; sys::EventBus::publish( new sys::NodePositionUpdateEvent(node_id, pos.latitude_i, pos.longitude_i, - has_altitude, - altitude, - ts, + pos.has_altitude, + pos.altitude, + pos.timestamp, pos.precision_bits, - pos.PDOP, - pos.HDOP, - pos.VDOP, - pos.gps_accuracy), + pos.pdop, + pos.hdop, + pos.vdop, + pos.gps_accuracy_mm), 0); } diff --git a/apps/linux_sim/scripts/wsl-validate.sh b/apps/linux_sim/scripts/wsl-validate.sh index b4714708..1c95d5e6 100644 --- a/apps/linux_sim/scripts/wsl-validate.sh +++ b/apps/linux_sim/scripts/wsl-validate.sh @@ -8,6 +8,8 @@ TEST_BUILD_DIR="${WSL_TEST_BUILD_DIR:-$ROOT_DIR/build/wsl/test}" SIM_BUILD_DIR="${WSL_SIM_BUILD_DIR:-$ROOT_DIR/build/wsl/simulator}" RPI_ROOT_DIR="$(cd "$ROOT_DIR/../linux_rpi" && pwd)" DEVICE_BUILD_DIR="${WSL_DEVICE_BUILD_DIR:-$RPI_ROOT_DIR/build/wsl/device}" +UCONSOLE_ROOT_DIR="$(cd "$ROOT_DIR/../linux_uconsole" && pwd)" +UCONSOLE_BUILD_DIR="${WSL_UCONSOLE_BUILD_DIR:-$UCONSOLE_ROOT_DIR/build/wsl/uconsole}" require_command() { local command_name="$1" @@ -39,8 +41,11 @@ require_command python3 if command -v pkg-config >/dev/null 2>&1; then REQUIRED_PKGCONFIG_MODULES=( alsa + libcurl + sqlite3 wayland-client xkbcommon + gtk4 x11 xrandr xrender @@ -100,9 +105,17 @@ cmake -S "$RPI_ROOT_DIR" -B "$DEVICE_BUILD_DIR" "${GENERATOR_ARGS[@]}" \ print_step "Building Linux framebuffer device shell" cmake --build "$DEVICE_BUILD_DIR" --target trailmate_cardputer_zero_device --config "$BUILD_TYPE" +print_step "Configuring uConsole Linux shell into $UCONSOLE_BUILD_DIR" +cmake -S "$UCONSOLE_ROOT_DIR" -B "$UCONSOLE_BUILD_DIR" "${GENERATOR_ARGS[@]}" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" + +print_step "Building uConsole Linux shell" +cmake --build "$UCONSOLE_BUILD_DIR" --target trailmate_uconsole --config "$BUILD_TYPE" + printf '\nWSL validation completed successfully.\n' printf ' Tests build dir: %s\n' "$TEST_BUILD_DIR" printf ' Simulator build dir: %s\n' "$SIM_BUILD_DIR" printf ' Device build dir: %s\n' "$DEVICE_BUILD_DIR" +printf ' uConsole build dir: %s\n' "$UCONSOLE_BUILD_DIR" printf ' To launch the Linux simulator from WSL later:\n' printf ' %s\n' "\"$SIM_BUILD_DIR/trailmate_cardputer_zero_simulator\" --scale 1" diff --git a/apps/linux_sim/tests/linux_runtime_smoke.cpp b/apps/linux_sim/tests/linux_runtime_smoke.cpp index 4bc8dc5a..ce9afdcf 100644 --- a/apps/linux_sim/tests/linux_runtime_smoke.cpp +++ b/apps/linux_sim/tests/linux_runtime_smoke.cpp @@ -1,6 +1,9 @@ #include "app/linux_app_facade.h" +#include "app/linux_app_services.h" #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" +#include "platform/linux/map_tile_cache.h" +#include "platform/linux/runtime_paths.h" #include "platform/ui/device_runtime.h" #include "platform/ui/firmware_update_runtime.h" #include "platform/ui/gps_runtime.h" @@ -63,9 +66,40 @@ int main() set_env_var("TRAIL_MATE_HOSTLINK_PORT", "44192"); set_env_var("TRAIL_MATE_GPS_SUPPORTED", "1"); set_env_var("TRAIL_MATE_GPS_READY", "1"); + set_env_var("TRAIL_MATE_LORA_SIMULATED", "1"); settings_store::clear_namespace("settings"); settings_store::clear_namespace("runtime_smoke"); + settings_store::clear_namespace("linux_contact_nodes"); + settings_store::clear_namespace("linux_contact_names"); + settings_store::clear_namespace("linux_app_facade"); + + set_env_var("TRAIL_MATE_RUNTIME_MODE", "local"); + { + trailmate::linux_app::LinuxAppServices local_services; + assert(local_services.initialize()); + local_services.dispatchPendingEvents(); + assert(!local_services.isBleEnabled()); + local_services.setBleEnabled(true); + assert(!local_services.isBleEnabled()); + + const auto contacts = local_services.contacts().getContacts(); + const auto nearby = local_services.contacts().getNearby(); + const auto ignored = local_services.contacts().getIgnoredNodes(); + assert(contacts.empty()); + assert(nearby.empty()); + assert(ignored.size() <= 1U); + + std::size_t total_conversations = 0; + const auto conversations = + local_services.chat().getConversations(0, 8, &total_conversations); + assert(total_conversations == 0U); + assert(conversations.empty()); + local_services.shutdown(); + } + settings_store::clear_namespace("linux_contact_nodes"); + settings_store::clear_namespace("linux_contact_names"); + settings_store::clear_namespace("linux_app_facade"); settings_store::put_int("runtime_smoke", "answer", 42); settings_store::put_bool("runtime_smoke", "enabled", true); @@ -85,6 +119,12 @@ int main() assert(greeting_out == greeting); assert(settings_store::get_blob("runtime_smoke", "blob", blob_out)); assert(blob_out == blob_in); + assert(std::filesystem::exists(platform::linux_runtime::sqlite_database_path())); + + platform::linux_runtime::MapTileCache tile_cache; + const auto tile_stats = tile_cache.stats(); + assert(tile_stats.database == platform::linux_runtime::sqlite_database_path()); + assert(!tile_stats.root.empty()); screen::set_timeout_ms(45000U); assert(screen::clamp_timeout_ms(45000U) == 45000U); @@ -292,6 +332,7 @@ int main() lora::release(); assert(!lora::is_online()); + set_env_var("TRAIL_MATE_RUNTIME_MODE", "demo"); MinimalLinuxAppFacade facade; assert(facade.initialize()); facade.dispatchPendingEvents(); diff --git a/apps/linux_uconsole/CMakeLists.txt b/apps/linux_uconsole/CMakeLists.txt new file mode 100644 index 00000000..6fa39e1e --- /dev/null +++ b/apps/linux_uconsole/CMakeLists.txt @@ -0,0 +1,307 @@ +cmake_minimum_required(VERSION 3.24) + +project(TrailMateUConsoleLinux VERSION 0.1.0 LANGUAGES C CXX) + +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR + "The uConsole Linux shell must be configured on Linux.") +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +include(CTest) +set(TRAIL_MATE_UCONSOLE_PACKAGE_VERSION "${PROJECT_VERSION}" + CACHE STRING "Debian package version for trailmate-uconsole") +find_package(Threads REQUIRED) +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK4 REQUIRED IMPORTED_TARGET gtk4) +include(GNUInstallDirs) +set(TRAIL_MATE_UCONSOLE_DOCDIR + "${CMAKE_INSTALL_DATAROOTDIR}/doc/trailmate-uconsole") +set(CMAKE_INSTALL_DOCDIR "${TRAIL_MATE_UCONSOLE_DOCDIR}") +set(CMAKE_INSTALL_DOCDIR "${TRAIL_MATE_UCONSOLE_DOCDIR}" + CACHE PATH "Documentation install directory" FORCE) +option(TRAIL_MATE_UCONSOLE_ENABLE_LEGACY_SURFACE + "Build the LVGL framebuffer fallback backend." OFF) +option(TRAIL_MATE_UCONSOLE_ENABLE_SDL + "Build the SDL desktop fallback backend." OFF) + +if(TRAIL_MATE_UCONSOLE_ENABLE_SDL) + set(TRAIL_MATE_UCONSOLE_ENABLE_LEGACY_SURFACE ON CACHE BOOL + "Build the LVGL framebuffer fallback backend." FORCE) +endif() + +# --------------------------------------------------------------------------- +# Shared Linux services and source roots +# --------------------------------------------------------------------------- + +include("${PROJECT_SOURCE_DIR}/../../cmake/TrailMateLinuxSources.cmake") + +trailmate_add_linux_common(trailmate_uconsole_linux_common) + +if(TRAIL_MATE_UCONSOLE_ENABLE_LEGACY_SURFACE) + include(FetchContent) + + # ----------------------------------------------------------------------- + # LVGL - only needed for the legacy framebuffer shell. + # ----------------------------------------------------------------------- + + set(LV_BUILD_CONF_PATH + "${PROJECT_SOURCE_DIR}/../../platform/linux/common/include/lv_conf.h" + CACHE PATH "" FORCE) + set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) + set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + + FetchContent_Declare( + lvgl + GIT_REPOSITORY https://github.com/lvgl/lvgl.git + GIT_TAG v9.4.0 + GIT_SHALLOW TRUE + ) + + FetchContent_GetProperties(lvgl) + if(NOT lvgl_POPULATED) + if(POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(lvgl) + add_subdirectory("${lvgl_SOURCE_DIR}" "${lvgl_BINARY_DIR}" EXCLUDE_FROM_ALL) + endif() + + if(TRAIL_MATE_UCONSOLE_ENABLE_SDL) + # ------------------------------------------------------------------- + # SDL3 - optional LVGL desktop fallback window. + # ------------------------------------------------------------------- + + set(SDL_SHARED OFF CACHE BOOL "" FORCE) + set(SDL_STATIC ON CACHE BOOL "" FORCE) + set(SDL_TESTS OFF CACHE BOOL "" FORCE) + set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE) + set(SDL_DISABLE_INSTALL ON CACHE BOOL "" FORCE) + + FetchContent_Declare( + sdl3 + GIT_REPOSITORY https://github.com/libsdl-org/SDL.git + GIT_TAG release-3.4.4 + GIT_SHALLOW TRUE + ) + + FetchContent_GetProperties(sdl3) + if(NOT sdl3_POPULATED) + if(POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(sdl3) + add_subdirectory("${sdl3_SOURCE_DIR}" "${sdl3_BINARY_DIR}" EXCLUDE_FROM_ALL) + endif() + endif() +endif() + +# --------------------------------------------------------------------------- +# uConsole GTK app support models +# --------------------------------------------------------------------------- + +add_library(trailmate_uconsole_shell + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_dashboard_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_hardware_probe.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_map_workspace_model.cpp" +) +if(TRAIL_MATE_UCONSOLE_ENABLE_LEGACY_SURFACE) + target_sources(trailmate_uconsole_shell + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/src/uconsole_desktop_shell.cpp" + ) +endif() +target_include_directories(trailmate_uconsole_shell + PUBLIC + "${TRAIL_MATE_REPO_ROOT}/platform/linux/uconsole/include" + "${TRAIL_MATE_LINUX_COMMON_INCLUDE_ROOT}" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}" +) +target_compile_features(trailmate_uconsole_shell PUBLIC cxx_std_20) +target_compile_definitions(trailmate_uconsole_shell + PRIVATE + TRAIL_MATE_UCONSOLE_LINUX=1 +) +target_link_libraries(trailmate_uconsole_shell + PUBLIC + trailmate_uconsole_linux_common +) +if(TRAIL_MATE_UCONSOLE_ENABLE_LEGACY_SURFACE) + target_link_libraries(trailmate_uconsole_shell PUBLIC lvgl) + target_compile_definitions(trailmate_uconsole_shell + PUBLIC TRAIL_MATE_UCONSOLE_HAS_LEGACY_SURFACE=1) +endif() +trailmate_apply_linux_common_warnings(trailmate_uconsole_shell) + +# --------------------------------------------------------------------------- +# Smoke tests +# --------------------------------------------------------------------------- + +if(BUILD_TESTING) + add_executable(trailmate_uconsole_map_workspace_smoke + tests/uconsole_map_workspace_smoke.cpp + ) + target_link_libraries(trailmate_uconsole_map_workspace_smoke + PRIVATE trailmate_uconsole_shell + ) + add_test(NAME trailmate_uconsole_map_workspace_smoke + COMMAND trailmate_uconsole_map_workspace_smoke) + + add_executable(trailmate_uconsole_chat_dedup_smoke + tests/uconsole_chat_dedup_smoke.cpp + ) + target_link_libraries(trailmate_uconsole_chat_dedup_smoke + PRIVATE trailmate_uconsole_linux_common + ) + add_test(NAME trailmate_uconsole_chat_dedup_smoke + COMMAND trailmate_uconsole_chat_dedup_smoke) + + add_executable(trailmate_uconsole_chat_workspace_smoke + tests/uconsole_chat_workspace_smoke.cpp + ) + target_link_libraries(trailmate_uconsole_chat_workspace_smoke + PRIVATE trailmate_uconsole_shell + ) + add_test(NAME trailmate_uconsole_chat_workspace_smoke + COMMAND trailmate_uconsole_chat_workspace_smoke) + + add_executable(trailmate_uconsole_meshtastic_node_payload_smoke + tests/uconsole_meshtastic_node_payload_smoke.cpp + ) + target_link_libraries(trailmate_uconsole_meshtastic_node_payload_smoke + PRIVATE trailmate_uconsole_linux_common + ) + add_test(NAME trailmate_uconsole_meshtastic_node_payload_smoke + COMMAND trailmate_uconsole_meshtastic_node_payload_smoke) + + add_executable(trailmate_uconsole_chat_sqlite_store_smoke + tests/uconsole_chat_sqlite_store_smoke.cpp + ) + target_link_libraries(trailmate_uconsole_chat_sqlite_store_smoke + PRIVATE trailmate_uconsole_linux_common + ) + add_test(NAME trailmate_uconsole_chat_sqlite_store_smoke + COMMAND trailmate_uconsole_chat_sqlite_store_smoke) +endif() + +# --------------------------------------------------------------------------- +# Device executable - uConsole desktop window with framebuffer fallback +# --------------------------------------------------------------------------- + +set(TRAIL_MATE_UCONSOLE_APP_SOURCES + src/platform/gtk/gtk_uconsole_app.cpp + src/platform/gtk/gtk_uconsole_chat_layout.cpp + src/platform/gtk/gtk_uconsole_chat_logic.cpp + src/platform/gtk/gtk_uconsole_data_layout.cpp + src/platform/gtk/gtk_uconsole_data_logic.cpp + src/platform/gtk/gtk_uconsole_hardware_layout.cpp + src/platform/gtk/gtk_uconsole_hardware_logic.cpp + src/platform/gtk/gtk_uconsole_logs_layout.cpp + src/platform/gtk/gtk_uconsole_logs_logic.cpp + src/platform/gtk/gtk_uconsole_map_layout.cpp + src/platform/gtk/gtk_uconsole_map_logic.cpp + src/platform/gtk/gtk_uconsole_mqtt_settings.cpp + src/platform/gtk/gtk_uconsole_overview_layout.cpp + src/platform/gtk/gtk_uconsole_overview_logic.cpp + src/platform/gtk/gtk_uconsole_pages.cpp + src/platform/gtk/gtk_uconsole_settings_layout.cpp + src/platform/gtk/gtk_uconsole_settings_logic.cpp + src/platform/gtk/gtk_uconsole_shell.cpp + src/platform/gtk/gtk_uconsole_style.cpp + src/platform/gtk/gtk_uconsole_widgets.cpp + "${TRAIL_MATE_LINUX_RPI_SRC_ROOT}/platform/device/evdev_input.cpp" + src/targets/uconsole_main.cpp +) +if(TRAIL_MATE_UCONSOLE_ENABLE_LEGACY_SURFACE) + list(APPEND TRAIL_MATE_UCONSOLE_APP_SOURCES + "${TRAIL_MATE_LINUX_RPI_SRC_ROOT}/platform/device/linux_framebuffer_platform.cpp" + ) +endif() +if(TRAIL_MATE_UCONSOLE_ENABLE_SDL) + list(APPEND TRAIL_MATE_UCONSOLE_APP_SOURCES + src/platform/desktop/sdl_window_presenter.cpp + ) +endif() + +add_executable(trailmate_uconsole ${TRAIL_MATE_UCONSOLE_APP_SOURCES}) +target_include_directories(trailmate_uconsole + PRIVATE + "${PROJECT_SOURCE_DIR}/src" + "${TRAIL_MATE_LINUX_RPI_SRC_ROOT}" +) +target_link_libraries(trailmate_uconsole + PRIVATE + PkgConfig::GTK4 + trailmate_uconsole_shell +) +if(TRAIL_MATE_UCONSOLE_ENABLE_LEGACY_SURFACE) + target_compile_definitions(trailmate_uconsole + PRIVATE TRAIL_MATE_UCONSOLE_HAS_LEGACY_SURFACE=1) +endif() +if(TRAIL_MATE_UCONSOLE_ENABLE_SDL) + target_compile_definitions(trailmate_uconsole + PRIVATE TRAIL_MATE_UCONSOLE_HAS_SDL=1) + target_link_libraries(trailmate_uconsole PRIVATE SDL3::SDL3) +endif() +set_target_properties(trailmate_uconsole + PROPERTIES + OUTPUT_NAME "trailmate-uconsole" +) + +add_executable(trailmate_sx1262_probe + tools/sx1262_probe.cpp +) +target_link_libraries(trailmate_sx1262_probe + PRIVATE + trailmate_uconsole_linux_common +) +set_target_properties(trailmate_sx1262_probe + PROPERTIES + OUTPUT_NAME "trailmate-sx1262-probe" +) + +# --------------------------------------------------------------------------- +# Install and Debian package +# --------------------------------------------------------------------------- + +install(TARGETS trailmate_uconsole + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" +) +install(TARGETS trailmate_sx1262_probe + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" +) +install(FILES README.md + DESTINATION "${CMAKE_INSTALL_DOCDIR}" +) +install(FILES packaging/trailmate-uconsole.desktop + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications" +) +install(FILES packaging/trailmate-uconsole.png + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/pixmaps" +) +install(FILES packaging/trailmate-uconsole.png + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/256x256/apps" +) + +set(CPACK_GENERATOR "DEB") +set(CPACK_PACKAGE_NAME "trailmate-uconsole") +set(CPACK_PACKAGE_VENDOR "Trail Mate") +set(CPACK_PACKAGE_CONTACT "Trail Mate Maintainers") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY + "Trail Mate uConsole Linux desktop shell") +set(CPACK_PACKAGE_VERSION "${TRAIL_MATE_UCONSOLE_PACKAGE_VERSION}") +set(CPACK_PACKAGING_INSTALL_PREFIX "/usr") +set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT) +set(CPACK_DEBIAN_PACKAGE_MAINTAINER "Trail Mate Maintainers") +set(CPACK_DEBIAN_PACKAGE_SECTION "utils") +set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional") +set(CPACK_DEBIAN_PACKAGE_DEPENDS + "libc6, libstdc++6, libgcc-s1, libssl3, gdal-bin, unzip, ca-certificates") +set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) +set(CPACK_DEBIAN_PACKAGE_DESCRIPTION + "Trail Mate uConsole GTK desktop shell with raw framebuffer fallback.") + +include(CPack) diff --git a/apps/linux_uconsole/CMakePresets.json b/apps/linux_uconsole/CMakePresets.json new file mode 100644 index 00000000..2c92b52b --- /dev/null +++ b/apps/linux_uconsole/CMakePresets.json @@ -0,0 +1,67 @@ +{ + "version": 4, + "cmakeMinimumRequired": { + "major": 3, + "minor": 24, + "patch": 0 + }, + "configurePresets": [ + { + "name": "linux-uconsole-debug", + "displayName": "Linux Debug uConsole", + "description": "Configure the uConsole/AIO2 Linux desktop shell.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/uconsole", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "linux-uconsole-release", + "displayName": "Linux Release uConsole", + "description": "Configure the uConsole/AIO2 Linux desktop shell for packaging.", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/uconsole-release", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "TRAIL_MATE_UCONSOLE_PACKAGE_VERSION": "0.1.25~alpha" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + } + ], + "buildPresets": [ + { + "name": "linux-uconsole-debug-build", + "displayName": "Build uConsole Linux Shell", + "configurePreset": "linux-uconsole-debug", + "targets": [ + "trailmate_uconsole" + ] + }, + { + "name": "linux-uconsole-release-build", + "displayName": "Build uConsole Linux Shell Release", + "configurePreset": "linux-uconsole-release", + "targets": [ + "trailmate_uconsole" + ] + }, + { + "name": "linux-uconsole-deb", + "displayName": "Build uConsole Debian Package", + "configurePreset": "linux-uconsole-release", + "targets": [ + "package" + ] + } + ] +} diff --git a/apps/linux_uconsole/README.md b/apps/linux_uconsole/README.md new file mode 100644 index 00000000..0ac5511d --- /dev/null +++ b/apps/linux_uconsole/README.md @@ -0,0 +1,168 @@ +# Trail Mate uConsole Linux + +This app shell is the desktop-class Linux handheld target for ClockworkPi +uConsole-style devices with an AIO2 capability module. + +It intentionally does not launch the compact Cardputer Zero app grid. The +default shell is a GTK desktop window backed by Linux app services and +UI-independent presentation models. The older LVGL path remains available +through explicit SDL or raw Linux framebuffer fallbacks, but it is no longer the +primary uConsole interface. + +- compact menu bar for primary workspaces +- bottom status bar for AIO2/LoRa/GPS/storage state +- central operational workspace +- Overview workspace with location/map context, hardware state, message status, + team activity timeline, and runtime details +- Hardware and Data workspaces are clickable runtime inspection surfaces; + Settings is a writable GTK control plane for identity, radio, GPS, map, + chat policy, network, and privacy configuration +- SQLite-backed Linux local state +- online XYZ map tile fetch with local file cache + +The uConsole UI consumes Linux app services through `LinuxAppServices` and +UI-independent presentation models. GTK is the production Linux UI direction; +LVGL is retained for bring-up/fallback. AIO2 support belongs below +platform/runtime adapters and must be reported through honest capability state; +it should not become a UI layout concept. Overview panels must show real stored +or runtime state only; empty hardware, message, location, and team states should +remain explicit instead of being filled with demo data. Linux does not expose +BLE as a product capability. + +## Build + +Install Linux build dependencies first: + +```sh +sudo apt-get install -y build-essential cmake ninja-build pkg-config \ + libgtk-4-dev libsqlite3-dev libcurl4-openssl-dev gdal-bin unzip \ + ca-certificates +``` + +```sh +cmake --preset linux-uconsole-debug +cmake --build --preset linux-uconsole-debug-build +``` + +## Package + +Build the standard Debian package with: + +```sh +cmake --preset linux-uconsole-release +cmake --build --preset linux-uconsole-deb +``` + +The release preset stamps the package as `0.1.25~alpha`. Override it for another +release with: + +```sh +cmake --preset linux-uconsole-release -DTRAIL_MATE_UCONSOLE_PACKAGE_VERSION=0.1.25~alpha +``` + +The package installs the app as: + +```text +/usr/bin/trailmate-uconsole +/usr/share/applications/trailmate-uconsole.desktop +/usr/share/pixmaps/trailmate-uconsole.png +/usr/share/icons/hicolor/256x256/apps/trailmate-uconsole.png +``` + +## Run + +```sh +trailmate-uconsole +``` + +The default GTK desktop window is `1180x600`, leaving room for the window +decorations and desktop panel on the uConsole `1280x720` landscape session. +Override it with: + +```sh +TRAIL_MATE_UCONSOLE_WIDTH=960 TRAIL_MATE_UCONSOLE_HEIGHT=540 trailmate-uconsole +``` + +Local state is stored in SQLite: + +```text +$TRAIL_MATE_SETTINGS_ROOT/trailmate.sqlite3 +``` + +If `TRAIL_MATE_SETTINGS_ROOT` is not set, the default root is +`$HOME/.trailmate_cardputer_zero`. + +The GTK map page uses real GPS/NMEA input when available. Configure a serial GPS +or an NMEA file with: + +```sh +TRAIL_MATE_GPS_DEVICE=/dev/ttyUSB0 trailmate-uconsole +TRAIL_MATE_GPS_NMEA_FILE=/path/to/feed.nmea trailmate-uconsole +``` + +On uConsole, the GTK shell also auto-detects the ClockworkPI uConsole CDC ACM +serial endpoint under `/dev/serial/by-id/usb-ClockworkPI_uConsole_*` and treats +it as the default GPS/NMEA candidate. If that endpoint exists but no valid NMEA +fix has arrived yet, the UI reports the endpoint instead of claiming that GPS is +missing. + +For bench testing without a GPS receiver, provide an explicit map center: + +```sh +TRAIL_MATE_MAP_LAT=31.2304 TRAIL_MATE_MAP_LNG=121.4737 trailmate-uconsole +``` + +Downloaded base tiles are cached in the same XYZ layout used by the SD-card map +runtime. The GTK map renders those tiles as one continuous landscape map surface; +individual tile cache entries are not exposed as bordered UI cards: + +```text +$TRAIL_MATE_SD_ROOT/maps/base/osm/{z}/{x}/{y}.png +$TRAIL_MATE_SD_ROOT/maps/base/terrain/{z}/{x}/{y}.png +$TRAIL_MATE_SD_ROOT/maps/base/satellite/{z}/{x}/{y}.jpg +``` + +If `TRAIL_MATE_SD_ROOT` is not set, it defaults to +`$TRAIL_MATE_SETTINGS_ROOT/sdcard`. + +Contour overlays are transparent PNG tiles above the active base map. Enable +or hide them from the map toolbar or Settings -> Map -> Contour overlay. The +map toolbar also has **Fill visible**, which fills the current visible viewport +by querying NASA CMR for `NASADEM_HGT`, downloading missing DEM archives with +the stored Earthdata token, extracting HGT/TIF files, and generating contour PNG +tiles through GDAL. Earthdata tokens are stored in the same SQLite settings +database as Linux local state. Generated contour tiles are written to: + +```text +$TRAIL_MATE_SD_ROOT/maps/contour/{major|minor}-{interval}/{z}/{x}/{y}.png +``` + +For compatibility with Trail Mate Center's working cache, the GTK map also +checks: + +```text +$TRAIL_MATE_SD_ROOT/contours/tiles/{major|minor}-{interval}/{z}/{x}/{y}.png +``` + +DEM source files and temporary contour work files are kept under: + +```text +$TRAIL_MATE_SD_ROOT/maps/dem +$TRAIL_MATE_CACHE_ROOT/contour-work +``` + +To run the LVGL/SDL fallback window explicitly: + +```sh +trailmate-uconsole --sdl +``` + +For raw framebuffer bring-up without the desktop session: + +```sh +sudo trailmate-uconsole --fbdev /dev/fb0 +``` + +The raw framebuffer path defaults to the physical `/dev/fb0` geometry currently +reported as `720x1280`; the desktop session rotates that panel into a `1280x720` +workspace. diff --git a/apps/linux_uconsole/packaging/trailmate-uconsole.desktop b/apps/linux_uconsole/packaging/trailmate-uconsole.desktop new file mode 100644 index 00000000..c333a08b --- /dev/null +++ b/apps/linux_uconsole/packaging/trailmate-uconsole.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Trail Mate +GenericName=Mesh handheld console +Comment=Trail Mate uConsole GTK shell +Exec=env TRAIL_MATE_RUNTIME_MODE=mesh trailmate-uconsole +TryExec=trailmate-uconsole +Icon=trailmate-uconsole +Terminal=false +Categories=Network; +Keywords=Trail;Mate;uConsole;Mesh;LoRa;AIO2; +StartupNotify=true diff --git a/apps/linux_uconsole/packaging/trailmate-uconsole.png b/apps/linux_uconsole/packaging/trailmate-uconsole.png new file mode 100644 index 00000000..33ec00f3 Binary files /dev/null and b/apps/linux_uconsole/packaging/trailmate-uconsole.png differ diff --git a/apps/linux_uconsole/src/platform/desktop/sdl_window_presenter.cpp b/apps/linux_uconsole/src/platform/desktop/sdl_window_presenter.cpp new file mode 100644 index 00000000..524fe66a --- /dev/null +++ b/apps/linux_uconsole/src/platform/desktop/sdl_window_presenter.cpp @@ -0,0 +1,311 @@ +#include "platform/desktop/sdl_window_presenter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "app/input_event.h" +#include "core/canvas.h" + +namespace trailmate::uconsole::desktop +{ +namespace +{ + +namespace app = cardputer_zero::app; +namespace core = cardputer_zero::core; + +void requireSdl(bool condition, std::string_view step) +{ + if (!condition) + { + throw std::runtime_error(std::string(step) + ": " + SDL_GetError()); + } +} + +template +T* requireSdl(T* pointer, std::string_view step) +{ + if (!pointer) + { + throw std::runtime_error(std::string(step) + ": " + SDL_GetError()); + } + return pointer; +} + +[[nodiscard]] std::uint32_t packPixel(core::Color color) noexcept +{ + return (static_cast(color.a) << 24U) | + (static_cast(color.b) << 16U) | + (static_cast(color.g) << 8U) | + static_cast(color.r); +} + +void enqueueSpecial(std::vector& queue, + app::InputKey key, + std::string label) +{ + queue.push_back(app::InputEvent{key, std::move(label), '\0'}); +} + +void handleKeyboardEvent(std::vector& queue, + const SDL_KeyboardEvent& event) +{ + if (event.repeat) + { + return; + } + + switch (event.key) + { + case SDLK_BACKSPACE: + enqueueSpecial(queue, app::InputKey::Backspace, "DEL"); + break; + case SDLK_RETURN: + enqueueSpecial(queue, app::InputKey::Enter, "OK"); + break; + case SDLK_TAB: + enqueueSpecial(queue, app::InputKey::Tab, "TAB"); + break; + case SDLK_HOME: + enqueueSpecial(queue, app::InputKey::Home, "HOME"); + break; + case SDLK_END: + enqueueSpecial(queue, app::InputKey::Next, "NEXT"); + break; + case SDLK_ESCAPE: + enqueueSpecial(queue, app::InputKey::Power, "POWER"); + break; + case SDLK_LSHIFT: + case SDLK_RSHIFT: + enqueueSpecial(queue, app::InputKey::Shift, "SHIFT"); + break; + case SDLK_LCTRL: + case SDLK_RCTRL: + enqueueSpecial(queue, app::InputKey::Ctrl, "CTRL"); + break; + case SDLK_LALT: + case SDLK_RALT: + enqueueSpecial(queue, app::InputKey::Alt, "ALT"); + break; + case SDLK_LEFT: + enqueueSpecial(queue, app::InputKey::Left, "LEFT"); + break; + case SDLK_RIGHT: + enqueueSpecial(queue, app::InputKey::Right, "RIGHT"); + break; + case SDLK_UP: + enqueueSpecial(queue, app::InputKey::Up, "UP"); + break; + case SDLK_DOWN: + enqueueSpecial(queue, app::InputKey::Down, "DOWN"); + break; + default: + break; + } +} + +void handleTextInput(std::vector& queue, + const SDL_TextInputEvent& event) +{ + if (event.text == nullptr) + { + return; + } + + for (const char* current = event.text; *current != '\0'; ++current) + { + const unsigned char value = static_cast(*current); + if (value > 127U || !std::isprint(value)) + { + continue; + } + + const char ch = static_cast(value); + std::string label{}; + if (ch == ' ') + { + label = "SPACE"; + } + else + { + label.push_back(static_cast(std::toupper(value))); + } + queue.push_back(app::makeCharacterInput(ch, std::move(label))); + } +} + +} // namespace + +struct SdlWindowPresenter::Impl +{ + SdlWindowOptions options{}; + SDL_Window* window = nullptr; + SDL_Renderer* renderer = nullptr; + SDL_Texture* texture = nullptr; + bool running = true; + int texture_width = 0; + int texture_height = 0; + int window_width = 0; + int window_height = 0; + std::vector staging{}; + std::vector input_queue{}; +}; + +SdlWindowPresenter::SdlWindowPresenter(SdlWindowOptions options) + : impl_(std::make_unique()) +{ + impl_->options = std::move(options); + impl_->options.width = std::max(1, impl_->options.width); + impl_->options.height = std::max(1, impl_->options.height); + impl_->options.scale = std::max(1, impl_->options.scale); + impl_->window_width = impl_->options.width * impl_->options.scale; + impl_->window_height = impl_->options.height * impl_->options.scale; + + requireSdl(SDL_Init(SDL_INIT_VIDEO), "SDL_Init"); + + const SDL_WindowFlags flags = + impl_->options.fullscreen ? SDL_WINDOW_FULLSCREEN : 0; + impl_->window = + requireSdl(SDL_CreateWindow(impl_->options.title.c_str(), + impl_->window_width, + impl_->window_height, + flags), + "SDL_CreateWindow"); + impl_->renderer = + requireSdl(SDL_CreateRenderer(impl_->window, nullptr), + "SDL_CreateRenderer"); + requireSdl(SDL_StartTextInput(impl_->window), "SDL_StartTextInput"); +} + +SdlWindowPresenter::~SdlWindowPresenter() +{ + if (impl_->texture != nullptr) + { + SDL_DestroyTexture(impl_->texture); + } + if (impl_->renderer != nullptr) + { + SDL_DestroyRenderer(impl_->renderer); + } + if (impl_->window != nullptr) + { + SDL_DestroyWindow(impl_->window); + } + SDL_Quit(); +} + +bool SdlWindowPresenter::pump() +{ + SDL_Event event{}; + while (SDL_PollEvent(&event)) + { + if (event.type == SDL_EVENT_QUIT) + { + impl_->running = false; + continue; + } + if (event.type == SDL_EVENT_KEY_DOWN) + { + handleKeyboardEvent(impl_->input_queue, event.key); + continue; + } + if (event.type == SDL_EVENT_TEXT_INPUT) + { + handleTextInput(impl_->input_queue, event.text); + continue; + } + if (event.type == SDL_EVENT_WINDOW_RESIZED) + { + impl_->window_width = event.window.data1; + impl_->window_height = event.window.data2; + } + } + + return impl_->running; +} + +std::vector SdlWindowPresenter::drainInput() +{ + auto drained = std::move(impl_->input_queue); + impl_->input_queue.clear(); + return drained; +} + +void SdlWindowPresenter::present(const core::Canvas& canvas) +{ + if (canvas.width() != impl_->texture_width || + canvas.height() != impl_->texture_height || impl_->texture == nullptr) + { + if (impl_->texture != nullptr) + { + SDL_DestroyTexture(impl_->texture); + } + impl_->texture_width = canvas.width(); + impl_->texture_height = canvas.height(); + impl_->staging.resize( + static_cast(impl_->texture_width) * + static_cast(impl_->texture_height)); + impl_->texture = requireSdl( + SDL_CreateTexture(impl_->renderer, + SDL_PIXELFORMAT_RGBA32, + SDL_TEXTUREACCESS_STREAMING, + impl_->texture_width, + impl_->texture_height), + "SDL_CreateTexture"); + requireSdl(SDL_SetTextureScaleMode(impl_->texture, + SDL_SCALEMODE_LINEAR), + "SDL_SetTextureScaleMode"); + } + + const auto& pixels = canvas.pixels(); + for (std::size_t index = 0; index < pixels.size(); ++index) + { + impl_->staging[index] = packPixel(pixels[index]); + } + + requireSdl(SDL_UpdateTexture( + impl_->texture, + nullptr, + impl_->staging.data(), + impl_->texture_width * + static_cast(sizeof(std::uint32_t))), + "SDL_UpdateTexture"); + requireSdl(SDL_SetRenderDrawColor(impl_->renderer, 0, 0, 0, 255), + "SDL_SetRenderDrawColor"); + requireSdl(SDL_RenderClear(impl_->renderer), "SDL_RenderClear"); + + if (!impl_->options.fullscreen) + { + SDL_GetWindowSize(impl_->window, &impl_->window_width, + &impl_->window_height); + } + const float scale_x = static_cast(impl_->window_width) / + static_cast(impl_->texture_width); + const float scale_y = static_cast(impl_->window_height) / + static_cast(impl_->texture_height); + const float scale = std::min(scale_x, scale_y); + const float render_width = static_cast(impl_->texture_width) * scale; + const float render_height = + static_cast(impl_->texture_height) * scale; + const SDL_FRect destination{ + (static_cast(impl_->window_width) - render_width) / 2.0F, + (static_cast(impl_->window_height) - render_height) / 2.0F, + render_width, + render_height, + }; + + requireSdl(SDL_RenderTexture(impl_->renderer, impl_->texture, nullptr, + &destination), + "SDL_RenderTexture"); + requireSdl(SDL_RenderPresent(impl_->renderer), "SDL_RenderPresent"); +} + +} // namespace trailmate::uconsole::desktop diff --git a/apps/linux_uconsole/src/platform/desktop/sdl_window_presenter.h b/apps/linux_uconsole/src/platform/desktop/sdl_window_presenter.h new file mode 100644 index 00000000..3ac514cb --- /dev/null +++ b/apps/linux_uconsole/src/platform/desktop/sdl_window_presenter.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include "platform/surface_presenter.h" + +namespace trailmate::uconsole::desktop +{ + +struct SdlWindowOptions +{ + int width = 1280; + int height = 720; + int scale = 1; + bool fullscreen = false; + std::string title = "Trail Mate uConsole"; +}; + +class SdlWindowPresenter : public cardputer_zero::platform::SurfacePresenter +{ + public: + explicit SdlWindowPresenter(SdlWindowOptions options = {}); + ~SdlWindowPresenter() override; + + [[nodiscard]] bool pump() override; + [[nodiscard]] std::vector drainInput() override; + void present(const cardputer_zero::core::Canvas& canvas) override; + + private: + struct Impl; + + std::unique_ptr impl_{}; +}; + +} // namespace trailmate::uconsole::desktop diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app.cpp new file mode 100644 index 00000000..99c46fdd --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app.cpp @@ -0,0 +1,67 @@ +#include "platform/gtk/gtk_uconsole_app.h" + +#include +#include +#include + +#include + +#include "platform/gtk/gtk_uconsole_app_state.h" +#include "platform/gtk/gtk_uconsole_shell.h" +#include "platform/gtk/gtk_uconsole_style.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +namespace trailmate::uconsole::gtk +{ +namespace +{ + +void onActivate(GtkApplication* app, gpointer data) +{ + auto& state = *static_cast(data); + installCss(); + + state.window = gtk_application_window_new(app); + gtk_window_set_title(GTK_WINDOW(state.window), state.options.title.c_str()); + gtk_window_set_resizable(GTK_WINDOW(state.window), TRUE); + gtk_window_set_default_size(GTK_WINDOW(state.window), + std::max(320, state.options.width), + std::max(240, state.options.height)); + g_signal_connect(state.window, "destroy", G_CALLBACK(onWindowDestroy), + &state); + + if (!state.services.initialize()) + { + GtkWidget* error = makeLabel("Startup failed.", "empty-state"); + gtk_window_set_child(GTK_WINDOW(state.window), error); + } + else + { + gtk_window_set_child(GTK_WINDOW(state.window), buildRoot(state)); + state.refresh_source = g_timeout_add(500, onRefresh, &state); + } + + if (state.options.fullscreen) + { + gtk_window_fullscreen(GTK_WINDOW(state.window)); + } + gtk_window_present(GTK_WINDOW(state.window)); +} + +} // namespace + +int runGtkUConsoleApp(GtkUConsoleOptions options) +{ + auto state = std::make_unique(std::move(options)); + GtkApplication* app = + gtk_application_new("dev.trailmate.uconsole", + G_APPLICATION_DEFAULT_FLAGS); + g_signal_connect(app, "activate", G_CALLBACK(onActivate), state.get()); + + const int status = g_application_run(G_APPLICATION(app), 0, nullptr); + shutdownGtkUConsoleApp(*state); + g_object_unref(app); + return status; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app.h b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app.h new file mode 100644 index 00000000..262835d2 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace trailmate::uconsole::gtk +{ + +struct GtkUConsoleOptions +{ + int width = 1180; + int height = 600; + bool fullscreen = false; + std::string title = "Trail Mate uConsole"; +}; + +int runGtkUConsoleApp(GtkUConsoleOptions options = {}); + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app_state.h b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app_state.h new file mode 100644 index 00000000..2c55f199 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_app_state.h @@ -0,0 +1,290 @@ +#pragma once + +#include "platform/gtk/gtk_uconsole_app.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "app/linux_app_services.h" +#include "gps/usecase/gnss_skyplot_presenter.h" +#include "platform/linux/runtime_packet_log.h" +#include "uconsole/uconsole_chat_workspace_model.h" +#include "uconsole/uconsole_dashboard_model.h" +#include "uconsole/uconsole_map_workspace_model.h" + +namespace trailmate::uconsole::gtk +{ + +constexpr std::size_t kConversationLimit = 12; +constexpr std::size_t kMessageLimit = 28; +constexpr std::size_t kMaxConcurrentMapFetches = 6; +constexpr int kMapFetchRetryBaseSeconds = 2; +constexpr int kMapFetchRetryMaxSeconds = 60; + +struct GtkUConsoleAppState; + +struct GtkUConsoleRefreshSnapshot +{ + UConsoleDashboardSnapshot dashboard{}; + MapWorkspaceSnapshot map{}; +}; + +using GtkUConsoleLaunchFn = GtkWidget* (*)(GtkUConsoleAppState&); +using GtkUConsoleShowFn = void (*)(GtkUConsoleAppState&); +using GtkUConsoleHideFn = void (*)(GtkUConsoleAppState&); +using GtkUConsoleRefreshFn = void (*)(GtkUConsoleAppState&, + const GtkUConsoleRefreshSnapshot&); +using GtkUConsoleDestroyFn = void (*)(GtkUConsoleAppState&); + +struct GtkUConsolePageLifecycle +{ + const char* name = ""; + const char* title = ""; + GtkUConsoleLaunchFn onLaunch = nullptr; + GtkUConsoleShowFn onShow = nullptr; + GtkUConsoleHideFn onHide = nullptr; + GtkUConsoleRefreshFn onRefresh = nullptr; + GtkUConsoleDestroyFn onDestroy = nullptr; +}; + +struct GtkUConsoleAppState +{ + explicit GtkUConsoleAppState(GtkUConsoleOptions options_in) + : options(std::move(options_in)), + services({.default_node_name = "Trail Mate uConsole", + .default_short_name = "TU", + .demo_broadcast_text = + "Broadcast: Trail Mate uConsole local mesh online."}), + dashboard_model(services), + chat_model(services), + map_model(services) + { + } + + GtkUConsoleOptions options{}; + linux_app::LinuxAppServices services; + UConsoleDashboardModel dashboard_model; + UConsoleChatWorkspaceModel chat_model; + UConsoleMapWorkspaceModel map_model; + + GtkWidget* window = nullptr; + GtkWidget* stack = nullptr; + GtkWidget* nav_overview = nullptr; + GtkWidget* nav_chat = nullptr; + GtkWidget* nav_chat_badge = nullptr; + GtkWidget* nav_map = nullptr; + GtkWidget* nav_hardware = nullptr; + GtkWidget* nav_data = nullptr; + GtkWidget* nav_logs = nullptr; + GtkWidget* nav_settings = nullptr; + + GtkWidget* status_aio2 = nullptr; + GtkWidget* status_lora = nullptr; + GtkWidget* status_gps = nullptr; + GtkWidget* status_node = nullptr; + GtkWidget* status_unread = nullptr; + + GtkWidget* overview_location_panel = nullptr; + GtkWidget* overview_location_state = nullptr; + GtkWidget* overview_location_coordinates = nullptr; + GtkWidget* overview_location_detail = nullptr; + GtkWidget* overview_location_map_meta = nullptr; + GtkWidget* overview_location_map = nullptr; + GtkWidget* overview_gnss_skyplot = nullptr; + GtkWidget* overview_satellite_list = nullptr; + GtkWidget* overview_messages_panel = nullptr; + GtkWidget* overview_messages_title = nullptr; + GtkWidget* overview_messages_detail = nullptr; + GtkWidget* overview_messages_latest = nullptr; + GtkWidget* hardware_box = nullptr; + GtkWidget* overview_conversations = nullptr; + GtkWidget* team_summary = nullptr; + GtkWidget* team_timeline_box = nullptr; + GtkWidget* overview_timeline_filter = nullptr; + GtkWidget* overview_timeline_scroll = nullptr; + GtkWidget* overview_timeline_box = nullptr; + GtkWidget* capability_box = nullptr; + GtkWidget* hardware_page_box = nullptr; + GtkWidget* data_page_box = nullptr; + GtkWidget* logs_page_box = nullptr; + GtkWidget* logs_source_gps = nullptr; + GtkWidget* logs_source_lora = nullptr; + GtkWidget* logs_source_mqtt = nullptr; + GtkWidget* settings_page_box = nullptr; + GtkWidget* settings_group_stack = nullptr; + GtkWidget* settings_meshtastic_page = nullptr; + GtkWidget* settings_link_page = nullptr; + GtkWidget* settings_meshcore_page = nullptr; + GtkWidget* settings_node_name = nullptr; + GtkWidget* settings_short_name = nullptr; + GtkWidget* settings_protocol = nullptr; + GtkWidget* settings_lora_region = nullptr; + GtkWidget* settings_lora_use_preset = nullptr; + GtkWidget* settings_lora_modem_preset = nullptr; + GtkWidget* settings_lora_freq = nullptr; + GtkWidget* settings_lora_bw = nullptr; + GtkWidget* settings_lora_sf = nullptr; + GtkWidget* settings_lora_cr = nullptr; + GtkWidget* settings_lora_tx = nullptr; + GtkWidget* settings_hop_limit = nullptr; + GtkWidget* settings_tx_enabled = nullptr; + GtkWidget* settings_lora_channel_num = nullptr; + GtkWidget* settings_lora_freq_offset = nullptr; + GtkWidget* settings_lora_override_duty = nullptr; + GtkWidget* settings_lora_ignore_mqtt = nullptr; + GtkWidget* settings_lora_ok_to_mqtt = nullptr; + GtkWidget* settings_meshcore_region_preset = nullptr; + GtkWidget* settings_meshcore_channel_slot = nullptr; + GtkWidget* settings_meshcore_channel_name = nullptr; + GtkWidget* settings_meshcore_client_repeat = nullptr; + GtkWidget* settings_meshcore_rx_delay = nullptr; + GtkWidget* settings_meshcore_airtime = nullptr; + GtkWidget* settings_meshcore_flood_max = nullptr; + GtkWidget* settings_meshcore_multi_acks = nullptr; + GtkWidget* settings_primary_enabled = nullptr; + GtkWidget* settings_secondary_enabled = nullptr; + GtkWidget* settings_primary_uplink = nullptr; + GtkWidget* settings_primary_downlink = nullptr; + GtkWidget* settings_secondary_uplink = nullptr; + GtkWidget* settings_secondary_downlink = nullptr; + GtkWidget* settings_chat_channel = nullptr; + GtkWidget* settings_relay_enabled = nullptr; + GtkWidget* settings_ack_broadcast = nullptr; + GtkWidget* settings_ack_squad = nullptr; + GtkWidget* settings_tx_retries = nullptr; + GtkWidget* settings_max_channels = nullptr; + GtkWidget* settings_gps_enabled = nullptr; + GtkWidget* settings_gps_interval = nullptr; + GtkWidget* settings_gps_mode = nullptr; + GtkWidget* settings_gps_strategy = nullptr; + GtkWidget* settings_external_nmea_hz = nullptr; + GtkWidget* settings_external_nmea_mask = nullptr; + GtkWidget* settings_map_source = nullptr; + GtkWidget* settings_map_zoom = nullptr; + GtkWidget* settings_map_contour = nullptr; + GtkWidget* settings_map_contour_ultra_fine = nullptr; + GtkWidget* settings_map_earthdata_token = nullptr; + GtkWidget* settings_map_track = nullptr; + GtkWidget* settings_map_mqtt_nodes = nullptr; + GtkWidget* settings_map_track_interval = nullptr; + GtkWidget* settings_map_track_format = nullptr; + GtkWidget* settings_mqtt_enabled = nullptr; + GtkWidget* settings_mqtt_name = nullptr; + GtkWidget* settings_mqtt_host = nullptr; + GtkWidget* settings_mqtt_port = nullptr; + GtkWidget* settings_mqtt_username = nullptr; + GtkWidget* settings_mqtt_password = nullptr; + GtkWidget* settings_mqtt_topic = nullptr; + GtkWidget* settings_mqtt_tls = nullptr; + GtkWidget* settings_mqtt_client_id = nullptr; + GtkWidget* settings_mqtt_clean_session = nullptr; + GtkWidget* settings_mqtt_qos = nullptr; + GtkWidget* settings_net_duty_cycle = nullptr; + GtkWidget* settings_net_channel_util = nullptr; + GtkWidget* settings_privacy_encrypt_mode = nullptr; + GtkWidget* settings_status = nullptr; + ::platform::linux_runtime::PacketLogSource logs_source = + ::platform::linux_runtime::PacketLogSource::Lora; + + GtkWidget* chat_conversation_list = nullptr; + GtkWidget* chat_sort_combo = nullptr; + GtkWidget* chat_message_scroll = nullptr; + GtkWidget* chat_message_list = nullptr; + GtkWidget* chat_title = nullptr; + GtkWidget* chat_meta = nullptr; + GtkWidget* chat_node_box = nullptr; + GtkWidget* chat_status = nullptr; + GtkWidget* chat_entry = nullptr; + GtkWidget* chat_send_button = nullptr; + GtkWidget* chat_add_contact_button = nullptr; + GtkWidget* chat_request_nodeinfo_button = nullptr; + GtkWidget* chat_send_position_button = nullptr; + GtkWidget* chat_send_poi_button = nullptr; + ChatThreadSortMode chat_sort_mode = ChatThreadSortMode::Recent; + std::map chat_group_expanded{}; + std::string chat_message_signature{}; + + GtkWidget* map_title = nullptr; + GtkWidget* map_meta = nullptr; + GtkWidget* map_canvas = nullptr; + GtkWidget* map_marker_layer = nullptr; + GtkWidget* map_grid = nullptr; + GtkWidget* map_contour_grid = nullptr; + GtkWidget* map_status = nullptr; + GtkWidget* map_cache_status = nullptr; + GtkWidget* map_source_osm = nullptr; + GtkWidget* map_source_terrain = nullptr; + GtkWidget* map_source_satellite = nullptr; + GtkWidget* map_mqtt_nodes = nullptr; + GtkWidget* map_contour_visible = nullptr; + GtkWidget* map_contour_fill = nullptr; + GtkWidget* map_contour_status = nullptr; + GtkWidget* map_measure_button = nullptr; + GtkWidget* map_measure_clear = nullptr; + GtkWidget* map_measure_status = nullptr; + GtkWidget* map_recenter = nullptr; + GtkWidget* map_context_popover = nullptr; + GtkWidget* map_context_label = nullptr; + double map_context_lat = 0.0; + double map_context_lon = 0.0; + bool map_context_valid = false; + std::uint32_t map_selected_node_id = 0; + double map_drag_start_lat = 0.0; + double map_drag_start_lon = 0.0; + int map_drag_start_zoom = 14; + bool map_dragging = false; + bool map_measure_enabled = false; + bool map_measure_has_start = false; + bool map_measure_has_end = false; + double map_measure_start_lat = 0.0; + double map_measure_start_lon = 0.0; + double map_measure_end_lat = 0.0; + double map_measure_end_lon = 0.0; + ::gps::GnssSkyplotView overview_gnss_view{}; + + struct MapFetchJob + { + std::string key{}; + ::platform::linux_runtime::MapTileId tile{}; + std::future<::platform::linux_runtime::MapTileResult> future{}; + }; + + struct MapFetchRetryState + { + unsigned attempts = 0; + std::chrono::steady_clock::time_point next_retry{}; + std::string last_error{}; + }; + + struct MapContourFillJob + { + std::future<::platform::linux_runtime::MapContourGenerationResult> + future{}; + }; + + std::vector map_fetch_jobs{}; + std::set map_inflight_tiles{}; + std::map map_failed_tiles{}; + MapContourFillJob contour_fill_job{}; + std::string map_fetch_status{}; + std::string contour_fill_status{}; + std::string map_grid_signature{}; + int overview_timeline_filter_index = 0; + std::string settings_notice{}; + int settings_notice_ticks = 0; + + std::vector page_lifecycle{}; + std::string active_page{}; + guint refresh_source = 0; + bool shutdown_complete = false; +}; + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_chat_layout.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_chat_layout.cpp new file mode 100644 index 00000000..cf996798 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_chat_layout.cpp @@ -0,0 +1,171 @@ +#include "platform/gtk/gtk_uconsole_layout_spec.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* launchChatLayout(GtkUConsoleAppState& state) +{ + GtkWidget* root = makeWorkbench(GTK_ORIENTATION_HORIZONTAL, 7); + gtk_widget_add_css_class(root, "chat-root"); + + GtkWidget* conversation_panel = makePanel(); + gtk_widget_add_css_class(conversation_panel, "chat-rail"); + gtk_widget_set_hexpand(conversation_panel, FALSE); + gtk_widget_set_size_request(conversation_panel, + layout_spec::kChatConversationRailWidth, + -1); + gtk_widget_set_vexpand(conversation_panel, TRUE); + + GtkWidget* rail_header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 7); + GtkWidget* rail_title = makeLabel("Chats", "pane-heading"); + gtk_widget_set_hexpand(rail_title, TRUE); + gtk_box_append(GTK_BOX(rail_header), rail_title); + state.chat_sort_combo = gtk_combo_box_text_new(); + gtk_widget_add_css_class(state.chat_sort_combo, "chat-sort"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.chat_sort_combo), + "Recent"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.chat_sort_combo), + "Hops"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.chat_sort_combo), + "Distance"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.chat_sort_combo), + "Last seen"); + gtk_combo_box_set_active(GTK_COMBO_BOX(state.chat_sort_combo), 0); + g_signal_connect(state.chat_sort_combo, + "changed", + G_CALLBACK(onChatSortChanged), + &state); + gtk_box_append(GTK_BOX(rail_header), state.chat_sort_combo); + gtk_box_append(GTK_BOX(conversation_panel), rail_header); + + state.chat_conversation_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 7); + gtk_widget_add_css_class(state.chat_conversation_list, + "chat-thread-list"); + gtk_widget_set_vexpand(state.chat_conversation_list, TRUE); + GtkWidget* conversation_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(conversation_scroll), + state.chat_conversation_list); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(conversation_scroll), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_vexpand(conversation_scroll, TRUE); + gtk_box_append(GTK_BOX(conversation_panel), conversation_scroll); + gtk_box_append(GTK_BOX(root), conversation_panel); + + GtkWidget* message_panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(message_panel, "chat-main"); + gtk_widget_set_hexpand(message_panel, TRUE); + gtk_widget_set_vexpand(message_panel, TRUE); + GtkWidget* thread_header = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + gtk_widget_add_css_class(thread_header, "chat-titlebar"); + + GtkWidget* title_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget* thread_text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 1); + gtk_widget_set_hexpand(thread_text, TRUE); + state.chat_title = makeLabel("Chat", "chat-title-line"); + state.chat_meta = makeLabel("", "row-meta"); + gtk_box_append(GTK_BOX(thread_text), state.chat_title); + gtk_box_append(GTK_BOX(thread_text), state.chat_meta); + gtk_box_append(GTK_BOX(title_row), thread_text); + gtk_box_append(GTK_BOX(thread_header), title_row); + + GtkWidget* action_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(action_row, "chat-action-row"); + state.chat_add_contact_button = gtk_button_new_with_label("Contact"); + state.chat_request_nodeinfo_button = gtk_button_new_with_label("NodeInfo"); + state.chat_send_position_button = gtk_button_new_with_label("Position"); + state.chat_send_poi_button = gtk_button_new_with_label("POI"); + gtk_widget_add_css_class(state.chat_add_contact_button, + "chat-action-button"); + gtk_widget_add_css_class(state.chat_request_nodeinfo_button, + "chat-action-button"); + gtk_widget_add_css_class(state.chat_send_position_button, + "chat-action-button"); + gtk_widget_add_css_class(state.chat_send_poi_button, + "chat-action-button"); + g_signal_connect(state.chat_add_contact_button, + "clicked", + G_CALLBACK(onChatAddContactClicked), + &state); + g_signal_connect(state.chat_request_nodeinfo_button, + "clicked", + G_CALLBACK(onChatRequestNodeInfoClicked), + &state); + g_signal_connect(state.chat_send_position_button, + "clicked", + G_CALLBACK(onChatSendPositionClicked), + &state); + g_signal_connect(state.chat_send_poi_button, + "clicked", + G_CALLBACK(onChatSendPoiClicked), + &state); + gtk_box_append(GTK_BOX(action_row), state.chat_add_contact_button); + gtk_box_append(GTK_BOX(action_row), state.chat_request_nodeinfo_button); + gtk_box_append(GTK_BOX(action_row), state.chat_send_position_button); + gtk_box_append(GTK_BOX(action_row), state.chat_send_poi_button); + gtk_box_append(GTK_BOX(thread_header), action_row); + gtk_box_append(GTK_BOX(message_panel), thread_header); + + state.chat_message_list = gtk_list_box_new(); + gtk_widget_add_css_class(state.chat_message_list, "chat-transcript"); + gtk_list_box_set_selection_mode(GTK_LIST_BOX(state.chat_message_list), + GTK_SELECTION_NONE); + state.chat_message_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(state.chat_message_scroll), + state.chat_message_list); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(state.chat_message_scroll), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_vexpand(state.chat_message_scroll, TRUE); + gtk_box_append(GTK_BOX(message_panel), state.chat_message_scroll); + + GtkWidget* composer_shell = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(composer_shell, "chat-composer-shell"); + GtkWidget* composer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 7); + gtk_widget_add_css_class(composer, "chat-composer"); + state.chat_entry = gtk_entry_new(); + gtk_widget_add_css_class(state.chat_entry, "chat-entry"); + gtk_entry_set_placeholder_text(GTK_ENTRY(state.chat_entry), "Message"); + gtk_widget_set_hexpand(state.chat_entry, TRUE); + g_signal_connect(state.chat_entry, "activate", + G_CALLBACK(onChatEntryActivate), &state); + state.chat_send_button = gtk_button_new_with_label("Send"); + gtk_widget_add_css_class(state.chat_send_button, "chat-send"); + g_signal_connect(state.chat_send_button, "clicked", + G_CALLBACK(onSendClicked), &state); + gtk_box_append(GTK_BOX(composer), state.chat_entry); + gtk_box_append(GTK_BOX(composer), state.chat_send_button); + gtk_box_append(GTK_BOX(composer_shell), composer); + + state.chat_status = makeLabel("Ready.", "chat-action-status"); + gtk_box_append(GTK_BOX(composer_shell), state.chat_status); + gtk_box_append(GTK_BOX(message_panel), composer_shell); + gtk_box_append(GTK_BOX(root), message_panel); + + GtkWidget* node_panel = makePanel(); + gtk_widget_add_css_class(node_panel, "chat-node-panel"); + gtk_widget_set_hexpand(node_panel, FALSE); + gtk_widget_set_size_request(node_panel, + layout_spec::kChatNodeInspectorWidth, + -1); + gtk_widget_set_vexpand(node_panel, TRUE); + gtk_box_append(GTK_BOX(node_panel), + makeLabel("Nodes", "pane-heading")); + state.chat_node_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_add_css_class(state.chat_node_box, "chat-node-list"); + gtk_widget_set_vexpand(state.chat_node_box, TRUE); + GtkWidget* node_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(node_scroll), + state.chat_node_box); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(node_scroll), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_vexpand(node_scroll, TRUE); + gtk_box_append(GTK_BOX(node_panel), node_scroll); + gtk_box_append(GTK_BOX(root), node_panel); + return root; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_chat_logic.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_chat_logic.cpp new file mode 100644 index 00000000..192aee8b --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_chat_logic.cpp @@ -0,0 +1,1075 @@ +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_shell.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace trailmate::uconsole::gtk +{ + +int sortModeIndex(ChatThreadSortMode mode) +{ + switch (mode) + { + case ChatThreadSortMode::Hops: + return 1; + case ChatThreadSortMode::Distance: + return 2; + case ChatThreadSortMode::LastSeen: + return 3; + case ChatThreadSortMode::Recent: + default: + return 0; + } +} + +ChatThreadSortMode sortModeFromIndex(int index) +{ + switch (index) + { + case 1: + return ChatThreadSortMode::Hops; + case 2: + return ChatThreadSortMode::Distance; + case 3: + return ChatThreadSortMode::LastSeen; + case 0: + default: + return ChatThreadSortMode::Recent; + } +} + +constexpr int kNodeInfoMapWidth = 420; +constexpr int kNodeInfoMapHeight = 292; + +struct NodeInfoMapLine +{ + double node_x = 0.0; + double node_y = 0.0; + double self_x = 0.0; + double self_y = 0.0; +}; + +std::string formatGtkNodeId(::chat::NodeId node_id) +{ + char buffer[16] = {}; + std::snprintf(buffer, + sizeof(buffer), + "!%08lX", + static_cast(node_id)); + return buffer; +} + +std::string formatNodeInfoCoordinate(const char* prefix, double value) +{ + char buffer[40] = {}; + std::snprintf(buffer, sizeof(buffer), "%s %.5f", prefix, value); + return buffer; +} + +std::string formatNodeInfoDistance(double meters) +{ + if (!std::isfinite(meters) || meters < 0.0) + { + return "-"; + } + char buffer[32] = {}; + if (meters < 1000.0) + { + std::snprintf(buffer, sizeof(buffer), "%.0f m", meters); + } + else + { + std::snprintf(buffer, sizeof(buffer), "%.1f km", meters / 1000.0); + } + return buffer; +} + +const char* compassRose(double bearing) +{ + static constexpr const char* kNames[] = { + "N", "NE", "E", "SE", "S", "SW", "W", "NW"}; + if (!std::isfinite(bearing)) + { + return "-"; + } + const int index = + static_cast(std::lround(std::fmod(bearing + 360.0, 360.0) / + 45.0)) % + 8; + return kNames[index]; +} + +int nodeInfoZoomFor(const ChatNodeDetailSnapshot& detail) +{ + if (!detail.has_self_position || !std::isfinite(detail.distance_m)) + { + return 12; + } + if (detail.distance_m > 500000.0) return 4; + if (detail.distance_m > 100000.0) return 6; + if (detail.distance_m > 20000.0) return 8; + if (detail.distance_m > 5000.0) return 10; + if (detail.distance_m > 1000.0) return 12; + return 14; +} + +std::string detailRowValue(const ChatNodeDetailSnapshot& detail, + const char* label) +{ + for (const auto& section : detail.sections) + { + for (const auto& row : section.rows) + { + if (row.label == label) + { + return row.value; + } + } + } + return {}; +} + +void drawNodeInfoLine(GtkDrawingArea*, + cairo_t* cr, + int width, + int height, + gpointer data) +{ + const auto* line = static_cast(data); + if (line == nullptr || cr == nullptr) + { + return; + } + cairo_set_source_rgba(cr, 0.10, 0.34, 0.30, 0.78); + cairo_set_line_width(cr, 2.0); + cairo_move_to(cr, + line->node_x * static_cast(width), + line->node_y * static_cast(height)); + cairo_line_to(cr, + line->self_x * static_cast(width), + line->self_y * static_cast(height)); + cairo_stroke(cr); +} + +double mapLongitudeToWorldPxForChat(double lon, int zoom) +{ + constexpr double kTileSizePx = 256.0; + const double tiles = static_cast(1U << zoom); + return ((lon + 180.0) / 360.0) * tiles * kTileSizePx; +} + +double mapLatitudeToWorldPxForChat(double lat, int zoom) +{ + constexpr double kTileSizePx = 256.0; + constexpr double kMaxMercatorLat = 85.05112878; + const double clamped_lat = + std::clamp(lat, -kMaxMercatorLat, kMaxMercatorLat); + const double lat_rad = clamped_lat * 3.14159265358979323846 / 180.0; + const double tiles = static_cast(1U << zoom); + const double mercator = + std::log(std::tan(lat_rad) + (1.0 / std::cos(lat_rad))); + return ((1.0 - mercator / 3.14159265358979323846) / 2.0) * tiles * + kTileSizePx; +} + +bool projectNodeInfoMapPoint(const MapWorkspaceSnapshot& snapshot, + double lat, + double lon, + double& out_x_fraction, + double& out_y_fraction) +{ + if (!snapshot.has_center || snapshot.tiles.empty() || + !std::isfinite(lat) || !std::isfinite(lon)) + { + return false; + } + + constexpr double kTileSizePx = 256.0; + const auto top_left = snapshot.tiles.front().id; + const double map_width_px = + static_cast(std::max(1U, snapshot.columns)) * + kTileSizePx; + const double map_height_px = + static_cast(std::max(1U, snapshot.rows)) * + kTileSizePx; + const double world_width_px = + static_cast(1U << snapshot.zoom) * kTileSizePx; + const double left_px = static_cast(top_left.x) * kTileSizePx; + const double top_px = static_cast(top_left.y) * kTileSizePx; + + double x = mapLongitudeToWorldPxForChat(lon, snapshot.zoom) - left_px; + if (x < 0.0) + { + x += world_width_px; + } + if (x > map_width_px && (x - world_width_px) >= 0.0) + { + x -= world_width_px; + } + const double y = mapLatitudeToWorldPxForChat(lat, snapshot.zoom) - top_px; + if (x < 0.0 || x > map_width_px || y < 0.0 || y > map_height_px) + { + return false; + } + out_x_fraction = map_width_px > 0.0 ? x / map_width_px : 0.0; + out_y_fraction = map_height_px > 0.0 ? y / map_height_px : 0.0; + return true; +} + +void onConversationActivated(GtkListBox*, + GtkListBoxRow* row, + gpointer data) +{ + auto& state = *static_cast(data); + const guint index = + GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(row), "trailmate-index")); + if (state.chat_model.selectConversationAt(index, + kConversationLimit, + state.chat_sort_mode)) + { + refreshUi(state); + } +} + +void onConversationButtonClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + const guint index = GPOINTER_TO_UINT( + g_object_get_data(G_OBJECT(button), "trailmate-index")); + if (state.chat_model.selectConversationAt(index, + kConversationLimit, + state.chat_sort_mode)) + { + refreshUi(state); + } +} + +void onChatSortChanged(GtkComboBox* combo, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_sort_mode = sortModeFromIndex(gtk_combo_box_get_active(combo)); + refreshUi(state); +} + +void onChatGroupExpandedChanged(GObject* object, GParamSpec*, gpointer data) +{ + auto& state = *static_cast(data); + const char* group = + static_cast(g_object_get_data(object, "trailmate-group")); + if (group == nullptr) + { + return; + } + state.chat_group_expanded[group] = + gtk_expander_get_expanded(GTK_EXPANDER(object)); +} + +void submitChatComposer(GtkUConsoleAppState& state) +{ + const char* text = gtk_editable_get_text(GTK_EDITABLE(state.chat_entry)); + if (state.chat_model.sendText(text ? text : "")) + { + gtk_editable_set_text(GTK_EDITABLE(state.chat_entry), ""); + } + refreshUi(state); +} + +gboolean scrollChatTranscriptToBottom(gpointer data) +{ + if (data == nullptr) + { + return G_SOURCE_REMOVE; + } + auto* scrolled = GTK_SCROLLED_WINDOW(data); + GtkAdjustment* adjustment = + gtk_scrolled_window_get_vadjustment(scrolled); + if (adjustment != nullptr) + { + gtk_adjustment_set_value(adjustment, + gtk_adjustment_get_upper(adjustment)); + } + return G_SOURCE_REMOVE; +} + +void onSendClicked(GtkButton*, gpointer data) +{ + submitChatComposer(*static_cast(data)); +} + +void onChatEntryActivate(GtkEntry*, gpointer data) +{ + submitChatComposer(*static_cast(data)); +} + +void onChatAddContactClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.addActivePeerAsContact(); + refreshUi(state); +} + +void onChatRequestNodeInfoClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.requestActiveNodeInfo(); + refreshUi(state); +} + +void onChatSendPositionClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.sendCurrentPosition(); + refreshUi(state); +} + +void onChatSendPoiClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.sendCurrentPoi(); + refreshUi(state); +} + +::chat::NodeId nodeIdFromButton(GtkButton* button) +{ + return static_cast<::chat::NodeId>(GPOINTER_TO_UINT( + g_object_get_data(G_OBJECT(button), "trailmate-node-id"))); +} + +GtkWidget* buildNodeDetailRow(const ChatNodeDetailRow& row) +{ + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(box, "node-info-row"); + + GtkWidget* label = makeLabel(row.label.c_str(), "node-info-key", true); + gtk_widget_set_size_request(label, 96, -1); + gtk_widget_set_hexpand(label, FALSE); + gtk_box_append(GTK_BOX(box), label); + + GtkWidget* value = + makeLabel(row.value.c_str(), + row.attention ? "node-info-value-attention" + : "node-info-value", + true); + gtk_widget_set_hexpand(value, TRUE); + gtk_box_append(GTK_BOX(box), value); + return box; +} + +GtkWidget* buildNodeDetailSection(const ChatNodeDetailSection& section) +{ + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + gtk_widget_add_css_class(box, "node-info-section"); + gtk_box_append(GTK_BOX(box), + makeLabel(section.title.c_str(), "node-info-section-title")); + for (const auto& row : section.rows) + { + gtk_box_append(GTK_BOX(box), buildNodeDetailRow(row)); + } + return box; +} + +GtkWidget* buildNodeInfoTileCell(const MapTileItem& item) +{ + GtkWidget* cell = gtk_overlay_new(); + gtk_widget_add_css_class(cell, "node-info-map-tile"); + gtk_widget_set_hexpand(cell, TRUE); + gtk_widget_set_vexpand(cell, TRUE); + gtk_widget_set_can_target(cell, FALSE); + + if (item.available) + { + GtkWidget* picture = + gtk_picture_new_for_filename(item.path.string().c_str()); + gtk_picture_set_content_fit(GTK_PICTURE(picture), + GTK_CONTENT_FIT_FILL); + gtk_picture_set_can_shrink(GTK_PICTURE(picture), TRUE); + gtk_widget_set_hexpand(picture, TRUE); + gtk_widget_set_vexpand(picture, TRUE); + gtk_widget_set_can_target(picture, FALSE); + gtk_overlay_set_child(GTK_OVERLAY(cell), picture); + } + else + { + GtkWidget* pending = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(pending, "node-info-map-tile-pending"); + gtk_widget_set_hexpand(pending, TRUE); + gtk_widget_set_vexpand(pending, TRUE); + gtk_widget_set_can_target(pending, FALSE); + gtk_overlay_set_child(GTK_OVERLAY(cell), pending); + } + return cell; +} + +GtkWidget* buildNodeInfoMapGrid(const MapWorkspaceSnapshot& snapshot) +{ + GtkWidget* grid = gtk_grid_new(); + gtk_widget_add_css_class(grid, "node-info-map-grid"); + gtk_grid_set_row_spacing(GTK_GRID(grid), 0); + gtk_grid_set_column_spacing(GTK_GRID(grid), 0); + gtk_grid_set_row_homogeneous(GTK_GRID(grid), TRUE); + gtk_grid_set_column_homogeneous(GTK_GRID(grid), TRUE); + gtk_widget_set_hexpand(grid, TRUE); + gtk_widget_set_vexpand(grid, TRUE); + gtk_widget_set_can_target(grid, FALSE); + + for (std::size_t index = 0; index < snapshot.tiles.size(); ++index) + { + const auto columns = std::max(1U, snapshot.columns); + const int col = static_cast(index % columns); + const int row = static_cast(index / columns); + gtk_grid_attach(GTK_GRID(grid), + buildNodeInfoTileCell(snapshot.tiles[index]), + col, + row, + 1, + 1); + } + return grid; +} + +GtkWidget* makeNodeInfoMarker(const char* text, const char* css_class) +{ + GtkWidget* marker = makeLabel(text, css_class); + gtk_label_set_xalign(GTK_LABEL(marker), 0.5F); + gtk_widget_set_can_target(marker, FALSE); + return marker; +} + +GtkWidget* buildNodeInfoOverlayPanel(const ChatNodeDetailSnapshot& detail) +{ + GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_add_css_class(panel, "node-info-map-panel"); + const std::string protocol = detailRowValue(detail, "Protocol"); + const std::string rssi = detailRowValue(detail, "RSSI"); + const std::string snr = detailRowValue(detail, "SNR"); + const std::string seen = detailRowValue(detail, "Last seen"); + if (!protocol.empty()) + { + gtk_box_append(GTK_BOX(panel), + makeLabel(protocol.c_str(), "node-info-map-protocol")); + } + if (!rssi.empty()) + { + gtk_box_append(GTK_BOX(panel), + makeLabel(rssi.c_str(), "node-info-map-rssi")); + } + if (!snr.empty()) + { + gtk_box_append(GTK_BOX(panel), + makeLabel(snr.c_str(), "node-info-map-snr")); + } + if (!seen.empty()) + { + gtk_box_append(GTK_BOX(panel), + makeLabel(seen.c_str(), "node-info-map-seen")); + } + return panel; +} + +GtkWidget* buildNodeInfoMapStage(GtkUConsoleAppState& state, + const ChatNodeDetailSnapshot& detail) +{ + GtkWidget* stage = gtk_overlay_new(); + gtk_widget_add_css_class(stage, "node-info-map-stage"); + gtk_widget_set_size_request(stage, kNodeInfoMapWidth, kNodeInfoMapHeight); + gtk_widget_set_hexpand(stage, TRUE); + gtk_widget_set_vexpand(stage, TRUE); + gtk_widget_set_overflow(stage, GTK_OVERFLOW_HIDDEN); + + if (!detail.has_position) + { + GtkWidget* empty = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(empty, "node-info-map-empty"); + gtk_widget_set_valign(empty, GTK_ALIGN_CENTER); + gtk_widget_set_halign(empty, GTK_ALIGN_CENTER); + gtk_box_append(GTK_BOX(empty), + makeLabel("No position available", "row-title")); + gtk_box_append(GTK_BOX(empty), + makeLabel(formatGtkNodeId(detail.node_id).c_str(), + "row-meta")); + gtk_overlay_set_child(GTK_OVERLAY(stage), empty); + return stage; + } + + const int zoom = nodeInfoZoomFor(detail); + MapWorkspaceSnapshot snapshot = + state.map_model.snapshotAround(detail.lat, detail.lon, zoom, 0, 0); + if (!snapshot.tiles.empty() && !snapshot.tiles.front().available) + { + static_cast(state.map_model.ensureTile(snapshot.tiles.front().id)); + snapshot = + state.map_model.snapshotAround(detail.lat, detail.lon, zoom, 0, 0); + } + gtk_overlay_set_child(GTK_OVERLAY(stage), buildNodeInfoMapGrid(snapshot)); + + double node_x = 0.5; + double node_y = 0.5; + static_cast(projectNodeInfoMapPoint(snapshot, + detail.lat, + detail.lon, + node_x, + node_y)); + + double self_x = 0.0; + double self_y = 0.0; + const bool self_visible = + detail.has_self_position && + projectNodeInfoMapPoint(snapshot, + detail.self_lat, + detail.self_lon, + self_x, + self_y); + if (self_visible) + { + auto* line = g_new0(NodeInfoMapLine, 1); + line->node_x = node_x; + line->node_y = node_y; + line->self_x = self_x; + line->self_y = self_y; + GtkWidget* drawing = gtk_drawing_area_new(); + gtk_widget_set_can_target(drawing, FALSE); + gtk_widget_set_hexpand(drawing, TRUE); + gtk_widget_set_vexpand(drawing, TRUE); + gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(drawing), + drawNodeInfoLine, + line, + g_free); + gtk_overlay_add_overlay(GTK_OVERLAY(stage), drawing); + } + + GtkWidget* layer = gtk_fixed_new(); + gtk_widget_set_hexpand(layer, TRUE); + gtk_widget_set_vexpand(layer, TRUE); + gtk_widget_set_can_target(layer, FALSE); + gtk_overlay_add_overlay(GTK_OVERLAY(stage), layer); + + gtk_fixed_put(GTK_FIXED(layer), + makeNodeInfoMarker("NODE", "node-info-marker-node"), + std::clamp(node_x * kNodeInfoMapWidth - 23.0, + 4.0, + static_cast(kNodeInfoMapWidth - 56)), + std::clamp(node_y * kNodeInfoMapHeight - 11.0, + 4.0, + static_cast(kNodeInfoMapHeight - 26))); + + if (self_visible) + { + gtk_fixed_put(GTK_FIXED(layer), + makeNodeInfoMarker("ME", "node-info-marker-self"), + std::clamp(self_x * kNodeInfoMapWidth - 15.0, + 4.0, + static_cast(kNodeInfoMapWidth - 40)), + std::clamp(self_y * kNodeInfoMapHeight - 10.0, + 4.0, + static_cast(kNodeInfoMapHeight - 24))); + } + + gtk_fixed_put(GTK_FIXED(layer), + makeLabel(formatGtkNodeId(detail.node_id).c_str(), + "node-info-map-id"), + 8.0, + 8.0); + gtk_fixed_put(GTK_FIXED(layer), + buildNodeInfoOverlayPanel(detail), + static_cast(kNodeInfoMapWidth - 128), + 8.0); + gtk_fixed_put(GTK_FIXED(layer), + makeLabel(formatNodeInfoCoordinate("LON", detail.lon).c_str(), + "node-info-map-lon"), + 8.0, + static_cast(kNodeInfoMapHeight - 44)); + gtk_fixed_put(GTK_FIXED(layer), + makeLabel(formatNodeInfoCoordinate("LAT", detail.lat).c_str(), + "node-info-map-lat"), + 8.0, + static_cast(kNodeInfoMapHeight - 24)); + + if (detail.has_self_position) + { + std::string distance = formatNodeInfoDistance(detail.distance_m); + distance += " / "; + distance += compassRose(detail.bearing_deg); + const double label_x = + self_visible ? ((node_x + self_x) * 0.5 * kNodeInfoMapWidth - 44.0) + : 136.0; + const double label_y = + self_visible ? ((node_y + self_y) * 0.5 * kNodeInfoMapHeight - 14.0) + : static_cast(kNodeInfoMapHeight - 46); + gtk_fixed_put(GTK_FIXED(layer), + makeLabel(distance.c_str(), "node-info-distance"), + std::clamp(label_x, + 6.0, + static_cast(kNodeInfoMapWidth - 110)), + std::clamp(label_y, + 6.0, + static_cast(kNodeInfoMapHeight - 30))); + } + + return stage; +} + +void showChatNodeInfoDialog(GtkUConsoleAppState& state, ::chat::NodeId node_id) +{ + const ChatNodeDetailSnapshot detail = state.chat_model.nodeDetails(node_id); + + GtkWidget* dialog = gtk_window_new(); + gtk_widget_add_css_class(dialog, "node-info-dialog"); + gtk_window_set_title(GTK_WINDOW(dialog), detail.title.c_str()); + gtk_window_set_modal(GTK_WINDOW(dialog), TRUE); + gtk_window_set_default_size(GTK_WINDOW(dialog), 456, 392); + if (state.window != nullptr) + { + gtk_window_set_transient_for(GTK_WINDOW(dialog), + GTK_WINDOW(state.window)); + } + + GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_add_css_class(root, "node-info-dialog-body"); + + GtkWidget* header = gtk_box_new(GTK_ORIENTATION_VERTICAL, 3); + gtk_box_append(GTK_BOX(header), + makeLabel(detail.title.c_str(), "node-info-title", true)); + gtk_box_append(GTK_BOX(header), + makeLabel(detail.subtitle.c_str(), "row-meta", true)); + gtk_box_append(GTK_BOX(root), header); + + gtk_box_append(GTK_BOX(root), buildNodeInfoMapStage(state, detail)); + + GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_halign(actions, GTK_ALIGN_END); + GtkWidget* close = gtk_button_new_with_label("Close"); + gtk_widget_add_css_class(close, "chat-action-button"); + g_signal_connect_swapped(close, + "clicked", + G_CALLBACK(gtk_window_destroy), + dialog); + gtk_box_append(GTK_BOX(actions), close); + gtk_box_append(GTK_BOX(root), actions); + + gtk_window_set_child(GTK_WINDOW(dialog), root); + gtk_window_present(GTK_WINDOW(dialog)); +} + +GtkWidget* makeNodeActionButton(GtkUConsoleAppState& state, + const char* label, + const ChatNodeInfoItem& item, + GCallback callback, + bool enabled = true) +{ + GtkWidget* button = gtk_button_new_with_label(label); + gtk_widget_add_css_class(button, "chat-node-action"); + g_object_set_data(G_OBJECT(button), + "trailmate-node-id", + GUINT_TO_POINTER(static_cast(item.node_id))); + gtk_widget_set_sensitive(button, enabled ? TRUE : FALSE); + g_signal_connect(button, "clicked", callback, &state); + return button; +} + +void onChatNodeChatClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.selectNodeConversation(nodeIdFromButton(button)); + refreshUi(state); +} + +void onChatNodeAddClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.addNodeAsContact(nodeIdFromButton(button)); + refreshUi(state); +} + +void onChatNodeInfoClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + showChatNodeInfoDialog(state, nodeIdFromButton(button)); +} + +void onChatNodeIgnoreClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.toggleNodeIgnored(nodeIdFromButton(button)); + refreshUi(state); +} + +void onChatNodeExchangeUserInfoClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.exchangeUserInfo(nodeIdFromButton(button)); + refreshUi(state); +} + +void onChatNodeVerifyKeyClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + state.chat_model.verifyNodeKey(nodeIdFromButton(button)); + refreshUi(state); +} + +GtkWidget* buildChatNodeInfoCard(GtkUConsoleAppState& state, + const ChatNodeInfoItem& item) +{ + GtkWidget* card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(card, "chat-node-card"); + if (item.via_mqtt) + { + gtk_widget_add_css_class(card, "chat-node-mqtt"); + } + + GtkWidget* title_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* title = makeLabel(item.title.c_str(), "row-title"); + gtk_widget_set_hexpand(title, TRUE); + gtk_box_append(GTK_BOX(title_row), title); + gtk_box_append(GTK_BOX(title_row), + makeLabel(item.via_mqtt ? "MQTT" : "LoRa", "mini-chip")); + gtk_box_append(GTK_BOX(card), title_row); + gtk_box_append(GTK_BOX(card), + makeLabel(item.subtitle.c_str(), "row-meta", true)); + gtk_box_append(GTK_BOX(card), + makeLabel(item.status.c_str(), "row-meta", true)); + gtk_box_append(GTK_BOX(card), + makeLabel(item.signal.c_str(), "row-meta", true)); + gtk_box_append(GTK_BOX(card), + makeLabel(item.position.c_str(), + item.has_position ? "chat-node-position" + : "row-meta", + true)); + GtkWidget* actions = gtk_flow_box_new(); + gtk_widget_add_css_class(actions, "chat-node-actions"); + gtk_flow_box_set_selection_mode(GTK_FLOW_BOX(actions), GTK_SELECTION_NONE); + gtk_flow_box_set_max_children_per_line(GTK_FLOW_BOX(actions), 3); + gtk_flow_box_set_row_spacing(GTK_FLOW_BOX(actions), 4); + gtk_flow_box_set_column_spacing(GTK_FLOW_BOX(actions), 4); + gtk_flow_box_append( + GTK_FLOW_BOX(actions), + makeNodeActionButton(state, + "Chat", + item, + G_CALLBACK(onChatNodeChatClicked))); + gtk_flow_box_append(GTK_FLOW_BOX(actions), + makeNodeActionButton(state, + item.is_contact ? "Added" : "Add", + item, + G_CALLBACK(onChatNodeAddClicked), + !item.is_contact)); + gtk_flow_box_append( + GTK_FLOW_BOX(actions), + makeNodeActionButton(state, + "Info", + item, + G_CALLBACK(onChatNodeInfoClicked))); + gtk_flow_box_append(GTK_FLOW_BOX(actions), + makeNodeActionButton( + state, + item.is_ignored ? "Unignore" : "Ignore", + item, + G_CALLBACK(onChatNodeIgnoreClicked))); + gtk_flow_box_append(GTK_FLOW_BOX(actions), + makeNodeActionButton( + state, + "Exchange", + item, + G_CALLBACK(onChatNodeExchangeUserInfoClicked))); + gtk_flow_box_append(GTK_FLOW_BOX(actions), + makeNodeActionButton( + state, + item.key_verified ? "Trusted" : "Key", + item, + G_CALLBACK(onChatNodeVerifyKeyClicked), + !item.key_verified)); + gtk_box_append(GTK_BOX(card), actions); + return card; +} + +GtkWidget* makeConversationButton(const ChatConversationItem& item, + std::size_t index, + GtkUConsoleAppState& state) +{ + GtkWidget* row_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(row_box, "chat-thread-row"); + if (item.active) + { + gtk_widget_add_css_class(row_box, "chat-thread-active"); + } + if (item.team) + { + gtk_widget_add_css_class(row_box, "chat-thread-team"); + } + if (item.broadcast) + { + gtk_widget_add_css_class(row_box, "chat-thread-broadcast"); + } + + GtkWidget* title_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* title = makeLabel(item.title.c_str(), "chat-thread-title"); + gtk_widget_set_hexpand(title, TRUE); + gtk_box_append(GTK_BOX(title_row), title); + if (item.unread > 0) + { + char unread[16] = {}; + std::snprintf(unread, sizeof(unread), "%d", item.unread); + GtkWidget* unread_label = makeLabel(unread, "chat-thread-unread"); + gtk_label_set_xalign(GTK_LABEL(unread_label), 0.5F); + gtk_box_append(GTK_BOX(title_row), unread_label); + } + gtk_box_append(GTK_BOX(row_box), title_row); + if (!item.preview.empty()) + { + gtk_box_append(GTK_BOX(row_box), + makeLabel(item.preview.c_str(), + "chat-thread-preview", + true)); + } + if (!item.unread_source.empty()) + { + gtk_box_append(GTK_BOX(row_box), + makeLabel(item.unread_source.c_str(), + "chat-thread-unread-source", + true)); + } + gtk_box_append(GTK_BOX(row_box), + makeLabel(item.facts.c_str(), "chat-thread-facts", true)); + gtk_box_append(GTK_BOX(row_box), + makeLabel(item.meta.c_str(), "row-meta", true)); + + GtkWidget* button = gtk_button_new(); + gtk_widget_add_css_class(button, "chat-thread-button"); + gtk_button_set_child(GTK_BUTTON(button), row_box); + g_object_set_data(G_OBJECT(button), + "trailmate-index", + GUINT_TO_POINTER(static_cast(index))); + g_signal_connect(button, + "clicked", + G_CALLBACK(onConversationButtonClicked), + &state); + return button; +} + +std::vector> chatGroupOrder() +{ + return {{"Nearby", "Nearby people"}, + {"Contacts", "Contacts"}, + {"Broadcast", "Broadcast"}, + {"Team", "Team"}}; +} + +void refreshConversationGroups(GtkUConsoleAppState& state, + const ChatWorkspaceSnapshot& snapshot) +{ + clearBox(state.chat_conversation_list); + if (snapshot.conversations.empty()) + { + GtkWidget* empty = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(empty, "empty-state"); + gtk_box_append(GTK_BOX(empty), makeLabel("No threads", "row-title")); + gtk_box_append(GTK_BOX(empty), + makeLabel("No messages are stored locally.", + "row-meta")); + gtk_box_append(GTK_BOX(state.chat_conversation_list), empty); + return; + } + + for (const auto& group : chatGroupOrder()) + { + std::vector indexes{}; + for (std::size_t index = 0; index < snapshot.conversations.size(); + ++index) + { + if (snapshot.conversations[index].group == group.first) + { + indexes.push_back(index); + } + } + if (indexes.empty()) + { + continue; + } + + const std::string label = + group.second + " (" + std::to_string(indexes.size()) + ")"; + GtkWidget* expander = gtk_expander_new(label.c_str()); + gtk_widget_add_css_class(expander, "chat-group"); + const auto expanded_it = state.chat_group_expanded.find(group.first); + gtk_expander_set_expanded( + GTK_EXPANDER(expander), + expanded_it == state.chat_group_expanded.end() + ? TRUE + : (expanded_it->second ? TRUE : FALSE)); + g_object_set_data_full(G_OBJECT(expander), + "trailmate-group", + g_strdup(group.first.c_str()), + g_free); + g_signal_connect(expander, + "notify::expanded", + G_CALLBACK(onChatGroupExpandedChanged), + &state); + + GtkWidget* group_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + gtk_widget_add_css_class(group_box, "chat-group-list"); + for (const std::size_t index : indexes) + { + gtk_box_append(GTK_BOX(group_box), + makeConversationButton(snapshot.conversations[index], + index, + state)); + } + gtk_expander_set_child(GTK_EXPANDER(expander), group_box); + gtk_box_append(GTK_BOX(state.chat_conversation_list), expander); + } +} + +static void refreshChat(GtkUConsoleAppState& state) +{ + ChatWorkspaceSnapshot snapshot = state.chat_model.snapshot( + kConversationLimit, + kMessageLimit, + state.chat_sort_mode); + + if (state.chat_sort_combo != nullptr && + gtk_combo_box_get_active(GTK_COMBO_BOX(state.chat_sort_combo)) != + sortModeIndex(state.chat_sort_mode)) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.chat_sort_combo), + sortModeIndex(state.chat_sort_mode)); + } + + std::ostringstream message_signature; + message_signature << snapshot.active_title << '\n' + << snapshot.active_meta; + for (const auto& item : snapshot.messages) + { + message_signature << '\n' + << (item.outgoing ? '>' : '<') + << (item.failed ? '!' : '.') << item.sender << '\t' + << item.meta << '\t' << item.text; + } + const std::string next_message_signature = message_signature.str(); + const bool message_list_changed = + next_message_signature != state.chat_message_signature; + state.chat_message_signature = next_message_signature; + + setLabel(state.chat_title, snapshot.active_title); + setLabel(state.chat_meta, snapshot.active_meta); + setLabel(state.chat_status, + snapshot.action_status.empty() ? "Ready." + : snapshot.action_status); + gtk_widget_set_sensitive(state.chat_send_button, + snapshot.can_send ? TRUE : FALSE); + gtk_widget_set_sensitive(state.chat_entry, snapshot.can_send ? TRUE : FALSE); + gtk_widget_set_sensitive(state.chat_add_contact_button, + snapshot.can_contact_active_peer ? TRUE : FALSE); + gtk_widget_set_sensitive(state.chat_request_nodeinfo_button, + snapshot.can_request_nodeinfo ? TRUE : FALSE); + gtk_widget_set_sensitive(state.chat_send_position_button, + snapshot.can_send_position ? TRUE : FALSE); + gtk_widget_set_sensitive(state.chat_send_poi_button, + snapshot.can_send_poi ? TRUE : FALSE); + + refreshConversationGroups(state, snapshot); + + if (state.chat_node_box != nullptr) + { + clearBox(state.chat_node_box); + if (snapshot.nodes.empty()) + { + GtkWidget* empty = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(empty, "empty-state"); + gtk_box_append(GTK_BOX(empty), + makeLabel("No node info", "row-title")); + gtk_box_append(GTK_BOX(empty), + makeLabel("NodeInfo, Position and MQTT node details appear here after they are decoded.", + "row-meta", + true)); + gtk_box_append(GTK_BOX(state.chat_node_box), empty); + } + for (const auto& node : snapshot.nodes) + { + gtk_box_append(GTK_BOX(state.chat_node_box), + buildChatNodeInfoCard(state, node)); + } + } + + clearListBox(state.chat_message_list); + if (snapshot.messages.empty()) + { + GtkWidget* empty = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(empty, "empty-state"); + gtk_box_append(GTK_BOX(empty), makeLabel("No messages", "row-title")); + gtk_box_append(GTK_BOX(empty), + makeLabel("This conversation has no stored messages.", + "row-meta")); + gtk_list_box_append(GTK_LIST_BOX(state.chat_message_list), empty); + } + for (const auto& item : snapshot.messages) + { + GtkWidget* shell = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(shell, "chat-message-shell"); + gtk_widget_set_hexpand(shell, TRUE); + + GtkWidget* spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_set_hexpand(spacer, TRUE); + + GtkWidget* bubble = gtk_box_new(GTK_ORIENTATION_VERTICAL, 3); + gtk_widget_add_css_class(bubble, "chat-bubble"); + gtk_widget_add_css_class(bubble, + item.failed + ? "chat-bubble-failed" + : (item.outgoing ? "chat-bubble-out" + : "chat-bubble-in")); + gtk_widget_set_halign(bubble, + item.outgoing ? GTK_ALIGN_END : GTK_ALIGN_START); + gtk_widget_set_hexpand(bubble, FALSE); + + GtkWidget* sender = makeLabel(item.sender.c_str(), "chat-sender"); + GtkWidget* text = makeLabel(item.text.c_str(), "chat-text", true); + gtk_label_set_max_width_chars(GTK_LABEL(text), 56); + GtkWidget* meta = makeLabel(item.meta.c_str(), "chat-message-meta"); + gtk_label_set_max_width_chars(GTK_LABEL(meta), 56); + gtk_box_append(GTK_BOX(bubble), sender); + gtk_box_append(GTK_BOX(bubble), text); + gtk_box_append(GTK_BOX(bubble), meta); + + if (item.outgoing) + { + gtk_box_append(GTK_BOX(shell), spacer); + gtk_box_append(GTK_BOX(shell), bubble); + } + else + { + gtk_box_append(GTK_BOX(shell), bubble); + gtk_box_append(GTK_BOX(shell), spacer); + } + gtk_list_box_append(GTK_LIST_BOX(state.chat_message_list), shell); + } + if (message_list_changed && state.chat_message_scroll != nullptr) + { + g_idle_add(scrollChatTranscriptToBottom, state.chat_message_scroll); + } +} + +void refreshChatLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot&) +{ + refreshChat(state); +} + +GtkUConsolePageLifecycle makeChatPageLifecycle() +{ + return {.name = "chat", + .title = "Chat", + .onLaunch = launchChatLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshChatLogic, + .onDestroy = nullptr}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_data_layout.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_data_layout.cpp new file mode 100644 index 00000000..a31874f7 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_data_layout.cpp @@ -0,0 +1,15 @@ +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* launchDataLayout(GtkUConsoleAppState& state) +{ + return buildDetailsWorkspace( + "Data", + "Local SQLite state, message/contact counts, and map cache.", + &state.data_page_box); +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_data_logic.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_data_logic.cpp new file mode 100644 index 00000000..765ed767 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_data_logic.cpp @@ -0,0 +1,85 @@ +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include + +namespace trailmate::uconsole::gtk +{ + +static void refreshDataPage(GtkUConsoleAppState& state, + const UConsoleDashboardSnapshot& dashboard, + const MapWorkspaceSnapshot& map_snapshot) +{ + clearBox(state.data_page_box); + + GtkWidget* strip = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(strip, "metric-strip"); + gtk_widget_set_hexpand(strip, TRUE); + gtk_box_append(GTK_BOX(strip), + makeMetricCard("Messages", + std::to_string(dashboard.conversation_count), + std::to_string(dashboard.unread_count) + + " unread", + dashboard.unread_count > 0)); + gtk_box_append(GTK_BOX(strip), + makeMetricCard("Contacts", + std::to_string(dashboard.contact_count), + std::to_string(dashboard.nearby_count) + + " nearby / " + + std::to_string(dashboard.ignored_count) + + " ignored")); + gtk_box_append(GTK_BOX(strip), + makeMetricCard("Map cache", + std::to_string( + map_snapshot.cache_stats.cached_tiles), + formatBytes( + map_snapshot.cache_stats.total_bytes), + map_snapshot.cache_stats.failed_tiles > 0)); + gtk_box_append(GTK_BOX(strip), + makeMetricCard("Storage", "SQLite", + map_snapshot.cache_stats.database.filename() + .string())); + gtk_box_append(GTK_BOX(state.data_page_box), strip); + + GtkWidget* detail = makePanel(); + gtk_widget_add_css_class(detail, "inspector-pane"); + gtk_box_append(GTK_BOX(detail), + makeLabel("Local data roots", "pane-heading")); + gtk_box_append(GTK_BOX(detail), + buildDetailRow("SQLite database", + map_snapshot.cache_stats.database.string())); + gtk_box_append(GTK_BOX(detail), + buildDetailRow("Map cache root", + map_snapshot.cache_stats.root.string())); + gtk_box_append(GTK_BOX(detail), + buildDetailRow("Map cache health", + std::to_string( + map_snapshot.cache_stats.cached_tiles) + + " cached / " + + std::to_string( + map_snapshot.cache_stats.failed_tiles) + + " failed / " + + formatBytes( + map_snapshot.cache_stats.total_bytes), + map_snapshot.cache_stats.failed_tiles > 0)); + gtk_box_append(GTK_BOX(state.data_page_box), detail); +} + +void refreshDataLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + refreshDataPage(state, snapshot.dashboard, snapshot.map); +} + +GtkUConsolePageLifecycle makeDataPageLifecycle() +{ + return {.name = "data", + .title = "Data", + .onLaunch = launchDataLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshDataLogic, + .onDestroy = nullptr}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_hardware_layout.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_hardware_layout.cpp new file mode 100644 index 00000000..e7ea3110 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_hardware_layout.cpp @@ -0,0 +1,15 @@ +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* launchHardwareLayout(GtkUConsoleAppState& state) +{ + return buildDetailsWorkspace( + "Hardware", + "uConsole/AIO2 endpoints and current driver binding state.", + &state.hardware_page_box); +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_hardware_logic.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_hardware_logic.cpp new file mode 100644 index 00000000..a25fb1a7 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_hardware_logic.cpp @@ -0,0 +1,82 @@ +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* buildHardwareCard(const HardwareStatusItem& item) +{ + GtkWidget* card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 3); + gtk_widget_add_css_class(card, "hardware-card"); + if (item.attention) + { + gtk_widget_add_css_class(card, "hardware-card-alert"); + } + gtk_widget_set_hexpand(card, TRUE); + + gtk_box_append(GTK_BOX(card), makeLabel(item.name.c_str(), "metric-label")); + GtkWidget* state_label = makeLabel(item.state.c_str(), "hardware-state"); + if (item.attention) + { + gtk_widget_add_css_class(state_label, "hardware-state-alert"); + } + gtk_box_append(GTK_BOX(card), state_label); + gtk_box_append(GTK_BOX(card), + makeLabel(item.detail.c_str(), "row-meta", true)); + return card; +} +static void refreshHardwarePage(GtkUConsoleAppState& state, + const UConsoleDashboardSnapshot& snapshot) +{ + clearBox(state.hardware_page_box); + + GtkWidget* grid = gtk_grid_new(); + gtk_widget_add_css_class(grid, "detail-grid"); + gtk_grid_set_row_spacing(GTK_GRID(grid), 8); + gtk_grid_set_column_spacing(GTK_GRID(grid), 8); + gtk_grid_set_column_homogeneous(GTK_GRID(grid), TRUE); + gtk_widget_set_hexpand(grid, TRUE); + for (std::size_t index = 0; index < snapshot.hardware.size(); ++index) + { + const auto& item = snapshot.hardware[index]; + gtk_grid_attach(GTK_GRID(grid), + buildHardwareCard(item), + static_cast(index % 3U), + static_cast(index / 3U), + 1, + 1); + } + gtk_box_append(GTK_BOX(state.hardware_page_box), grid); + + GtkWidget* detail = makePanel(); + gtk_widget_add_css_class(detail, "inspector-pane"); + gtk_box_append(GTK_BOX(detail), + makeLabel("Capability and driver state", "pane-heading")); + for (const auto& line : snapshot.capability_lines) + { + gtk_box_append(GTK_BOX(detail), makeLabel(line.c_str(), "row-meta", + true)); + } + gtk_box_append(GTK_BOX(state.hardware_page_box), detail); +} + +void refreshHardwareLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + refreshHardwarePage(state, snapshot.dashboard); +} + +GtkUConsolePageLifecycle makeHardwarePageLifecycle() +{ + return {.name = "hardware", + .title = "Hardware", + .onLaunch = launchHardwareLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshHardwareLogic, + .onDestroy = nullptr}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_layout_spec.h b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_layout_spec.h new file mode 100644 index 00000000..5a054485 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_layout_spec.h @@ -0,0 +1,28 @@ +#pragma once + +namespace trailmate::uconsole::gtk::layout_spec +{ + +// These constants mirror docs/specs/uconsole-aio2-linux.md. +// Update the specification before changing product geometry here. + +constexpr int kGlobalStatusBarHeight = 28; + +constexpr int kOverviewGpsRailWidth = 208; +constexpr int kOverviewTimelineRailWidth = 252; +constexpr int kOverviewLocationMapWidth = 200; +constexpr int kOverviewLocationMapHeight = 128; +constexpr int kOverviewLocationPictureHeight = 104; +constexpr int kOverviewSkyplotWidth = 200; +constexpr int kOverviewSkyplotHeight = 120; +constexpr int kOverviewSatelliteListWidth = 200; +constexpr int kOverviewSatelliteListHeight = 132; + +constexpr int kChatConversationRailWidth = 216; +constexpr int kChatNodeInspectorWidth = 220; + +constexpr int kMapSideRailWidth = 152; +constexpr int kMapRailTextWidthChars = 15; +constexpr int kMapStatusValueMaxChars = 28; + +} // namespace trailmate::uconsole::gtk::layout_spec diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_logs_layout.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_logs_layout.cpp new file mode 100644 index 00000000..7ecab074 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_logs_layout.cpp @@ -0,0 +1,45 @@ +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* launchLogsLayout(GtkUConsoleAppState& state) +{ + GtkWidget* root = makeWorkbench(GTK_ORIENTATION_VERTICAL, 6); + + GtkWidget* toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(toolbar, "log-toolbar"); + state.logs_source_lora = gtk_button_new_with_label("LoRa"); + gtk_widget_add_css_class(state.logs_source_lora, "nav-button"); + g_signal_connect(state.logs_source_lora, "clicked", + G_CALLBACK(onLogsSourceLoraClicked), &state); + gtk_box_append(GTK_BOX(toolbar), state.logs_source_lora); + state.logs_source_mqtt = gtk_button_new_with_label("MQTT"); + gtk_widget_add_css_class(state.logs_source_mqtt, "nav-button"); + g_signal_connect(state.logs_source_mqtt, "clicked", + G_CALLBACK(onLogsSourceMqttClicked), &state); + gtk_box_append(GTK_BOX(toolbar), state.logs_source_mqtt); + state.logs_source_gps = gtk_button_new_with_label("GPS"); + gtk_widget_add_css_class(state.logs_source_gps, "nav-button"); + g_signal_connect(state.logs_source_gps, "clicked", + G_CALLBACK(onLogsSourceGpsClicked), &state); + gtk_box_append(GTK_BOX(toolbar), state.logs_source_gps); + gtk_box_append(GTK_BOX(root), toolbar); + + state.logs_page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_hexpand(state.logs_page_box, TRUE); + + GtkWidget* scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), + state.logs_page_box); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), + GTK_POLICY_AUTOMATIC, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_hexpand(scroll, TRUE); + gtk_widget_set_vexpand(scroll, TRUE); + gtk_box_append(GTK_BOX(root), scroll); + return root; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_logs_logic.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_logs_logic.cpp new file mode 100644 index 00000000..da6c95f9 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_logs_logic.cpp @@ -0,0 +1,168 @@ +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_shell.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include +#include + +namespace trailmate::uconsole::gtk +{ +namespace +{ + +std::string formatLogTimestamp(std::uint64_t timestamp_ms) +{ + if (timestamp_ms == 0) + { + return "--"; + } + + const std::time_t seconds = + static_cast(timestamp_ms / 1000ULL); + std::tm local{}; +#if defined(_WIN32) + localtime_s(&local, &seconds); +#else + localtime_r(&seconds, &local); +#endif + char buffer[32] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &local); + return buffer; +} + +} // namespace + +void onLogsSourceGpsClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.logs_source = ::platform::linux_runtime::PacketLogSource::Gps; + refreshUi(state); +} + +void onLogsSourceLoraClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.logs_source = ::platform::linux_runtime::PacketLogSource::Lora; + refreshUi(state); +} + +void onLogsSourceMqttClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.logs_source = ::platform::linux_runtime::PacketLogSource::Mqtt; + refreshUi(state); +} +GtkWidget* buildPacketLogEntry( + const ::platform::linux_runtime::PacketLogEntry& entry) +{ + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_add_css_class(row, "log-entry"); + gtk_widget_set_hexpand(row, TRUE); + + GtkWidget* header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(header, "log-entry-header"); + gtk_widget_set_hexpand(header, TRUE); + gtk_box_append(GTK_BOX(header), + makeLabel(formatLogTimestamp(entry.timestamp_ms).c_str(), + "log-time")); + gtk_box_append(GTK_BOX(header), + makeLabel(::platform::linux_runtime::packet_log_source_label( + entry.source), + "log-source")); + gtk_box_append(GTK_BOX(header), + makeLabel(::platform::linux_runtime:: + packet_log_direction_label(entry.direction), + "log-direction")); + + GtkWidget* title_label = makeLabel(entry.title.c_str(), "row-title", true); + gtk_widget_set_hexpand(title_label, TRUE); + gtk_box_append(GTK_BOX(header), title_label); + gtk_box_append(GTK_BOX(row), header); + + gtk_box_append(GTK_BOX(row), + makeLabel(entry.summary.c_str(), "row-meta", true)); + + GtkWidget* segment_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(segment_box, "log-segments"); + gtk_widget_set_hexpand(segment_box, TRUE); + for (const auto& segment : entry.segments) + { + const std::string text = segment.label.empty() + ? segment.text + : segment.label + ": " + segment.text; + GtkWidget* label = makeLabel( + text.c_str(), + ::platform::linux_runtime::packet_log_segment_class(segment.kind), + true); + constrainLabelWidth(label, 118); + gtk_box_append(GTK_BOX(segment_box), label); + } + gtk_box_append(GTK_BOX(row), segment_box); + + if (!entry.raw_hex.empty()) + { + GtkWidget* raw = makeLabel(entry.raw_hex.c_str(), "log-hex", true); + constrainLabelWidth(raw, 118); + gtk_box_append(GTK_BOX(row), raw); + } + return row; +} + +static void refreshLogsPage(GtkUConsoleAppState& state) +{ + clearBox(state.logs_page_box); + const bool lora_active = + state.logs_source == ::platform::linux_runtime::PacketLogSource::Lora; + const bool mqtt_active = + state.logs_source == ::platform::linux_runtime::PacketLogSource::Mqtt; + const bool gps_active = + state.logs_source == ::platform::linux_runtime::PacketLogSource::Gps; + if (lora_active) + gtk_widget_add_css_class(state.logs_source_lora, "nav-button-active"); + else + gtk_widget_remove_css_class(state.logs_source_lora, + "nav-button-active"); + if (mqtt_active) + gtk_widget_add_css_class(state.logs_source_mqtt, "nav-button-active"); + else + gtk_widget_remove_css_class(state.logs_source_mqtt, + "nav-button-active"); + if (gps_active) + gtk_widget_add_css_class(state.logs_source_gps, "nav-button-active"); + else + gtk_widget_remove_css_class(state.logs_source_gps, + "nav-button-active"); + + const auto entries = ::platform::linux_runtime::recent_packet_logs( + state.logs_source, 80); + if (entries.empty()) + { + gtk_box_append(GTK_BOX(state.logs_page_box), + makeLabel("No packet logs yet.", "empty-state")); + return; + } + for (const auto& entry : entries) + { + gtk_box_append(GTK_BOX(state.logs_page_box), + buildPacketLogEntry(entry)); + } +} + +void refreshLogsLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot&) +{ + refreshLogsPage(state); +} + +GtkUConsolePageLifecycle makeLogsPageLifecycle() +{ + return {.name = "logs", + .title = "Logs", + .onLaunch = launchLogsLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshLogsLogic, + .onDestroy = nullptr}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_map_layout.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_map_layout.cpp new file mode 100644 index 00000000..5663c6bf --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_map_layout.cpp @@ -0,0 +1,350 @@ +#include "platform/gtk/gtk_uconsole_layout_spec.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* buildMapSourceButton(GtkUConsoleAppState& state, + GtkWidget** out_button, + const char* label, + ::platform::linux_runtime::MapBaseSource source) +{ + GtkWidget* button = gtk_button_new_with_label(label); + gtk_widget_add_css_class(button, "nav-button"); + gtk_widget_set_hexpand(button, TRUE); + g_object_set_data(G_OBJECT(button), "trailmate-source", + GUINT_TO_POINTER(static_cast(source))); + g_signal_connect(button, "clicked", G_CALLBACK(onMapSourceClicked), + &state); + *out_button = button; + return button; +} + +GtkWidget* buildMapContextPopover(GtkUConsoleAppState& state, + GtkWidget* parent) +{ + GtkWidget* popover = gtk_popover_new(); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + gtk_widget_add_css_class(box, "map-context-menu"); + + state.map_context_label = makeLabel("Point", "row-meta"); + gtk_box_append(GTK_BOX(box), state.map_context_label); + + GtkWidget* center = gtk_button_new_with_label("Center here"); + gtk_widget_add_css_class(center, "nav-button"); + g_signal_connect(center, + "clicked", + G_CALLBACK(onMapContextCenterClicked), + &state); + gtk_box_append(GTK_BOX(box), center); + + GtkWidget* zoom_in = gtk_button_new_with_label("Zoom in here"); + gtk_widget_add_css_class(zoom_in, "nav-button"); + g_signal_connect(zoom_in, + "clicked", + G_CALLBACK(onMapContextZoomInClicked), + &state); + gtk_box_append(GTK_BOX(box), zoom_in); + + GtkWidget* zoom_out = gtk_button_new_with_label("Zoom out here"); + gtk_widget_add_css_class(zoom_out, "nav-button"); + g_signal_connect(zoom_out, + "clicked", + G_CALLBACK(onMapContextZoomOutClicked), + &state); + gtk_box_append(GTK_BOX(box), zoom_out); + + GtkWidget* measure_start = gtk_button_new_with_label("Measure from here"); + gtk_widget_add_css_class(measure_start, "nav-button"); + g_signal_connect(measure_start, + "clicked", + G_CALLBACK(onMapContextMeasureStartClicked), + &state); + gtk_box_append(GTK_BOX(box), measure_start); + + GtkWidget* measure_end = gtk_button_new_with_label("Measure to here"); + gtk_widget_add_css_class(measure_end, "nav-button"); + g_signal_connect(measure_end, + "clicked", + G_CALLBACK(onMapContextMeasureEndClicked), + &state); + gtk_box_append(GTK_BOX(box), measure_end); + + gtk_popover_set_child(GTK_POPOVER(popover), box); + gtk_widget_set_parent(popover, parent); + return popover; +} + +GtkWidget* makeMapRailViewport(GtkWidget* panel) +{ + GtkWidget* viewport = gtk_scrolled_window_new(); + gtk_widget_set_hexpand(viewport, FALSE); + gtk_widget_set_vexpand(viewport, TRUE); + gtk_widget_set_size_request(viewport, + layout_spec::kMapSideRailWidth, + 1); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(viewport), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_min_content_width( + GTK_SCROLLED_WINDOW(viewport), + layout_spec::kMapSideRailWidth); + gtk_scrolled_window_set_min_content_height(GTK_SCROLLED_WINDOW(viewport), + 1); + gtk_scrolled_window_set_propagate_natural_width( + GTK_SCROLLED_WINDOW(viewport), + FALSE); + gtk_scrolled_window_set_propagate_natural_height( + GTK_SCROLLED_WINDOW(viewport), + FALSE); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(viewport), panel); + return viewport; +} + +GtkWidget* launchMapLayout(GtkUConsoleAppState& state) +{ + GtkWidget* root = makeWorkbench(GTK_ORIENTATION_HORIZONTAL, 7); + + GtkWidget* left_panel = makePanel(); + gtk_widget_add_css_class(left_panel, "map-side-panel"); + gtk_widget_set_hexpand(left_panel, FALSE); + gtk_widget_set_size_request(left_panel, + layout_spec::kMapSideRailWidth, + -1); + gtk_widget_set_vexpand(left_panel, TRUE); + + state.map_title = makeLabel("OSM map", "pane-heading"); + constrainLabelWidth(state.map_title, layout_spec::kMapRailTextWidthChars); + gtk_box_append(GTK_BOX(left_panel), state.map_title); + state.map_meta = makeLabel("", "row-meta", true); + constrainLabelWidth(state.map_meta, layout_spec::kMapRailTextWidthChars); + gtk_box_append(GTK_BOX(left_panel), state.map_meta); + + GtkWidget* source_section = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + gtk_widget_add_css_class(source_section, "map-tool-section"); + gtk_box_append(GTK_BOX(source_section), + makeLabel("Base layer", "map-tool-title")); + GtkWidget* source_row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(source_row, "map-tool-row"); + gtk_box_append(GTK_BOX(source_row), + buildMapSourceButton( + state, + &state.map_source_osm, + "OSM", + ::platform::linux_runtime::MapBaseSource::Osm)); + gtk_box_append(GTK_BOX(source_row), + buildMapSourceButton( + state, + &state.map_source_terrain, + "Terrain", + ::platform::linux_runtime::MapBaseSource::Terrain)); + gtk_box_append(GTK_BOX(source_row), + buildMapSourceButton( + state, + &state.map_source_satellite, + "Satellite", + ::platform::linux_runtime::MapBaseSource::Satellite)); + gtk_box_append(GTK_BOX(source_section), source_row); + gtk_box_append(GTK_BOX(left_panel), source_section); + + GtkWidget* view_section = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + gtk_widget_add_css_class(view_section, "map-tool-section"); + gtk_box_append(GTK_BOX(view_section), makeLabel("View", "map-tool-title")); + GtkWidget* view_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); + gtk_widget_add_css_class(view_row, "map-tool-row"); + GtkWidget* zoom_out = gtk_button_new_with_label("-"); + gtk_widget_add_css_class(zoom_out, "nav-button"); + g_signal_connect(zoom_out, + "clicked", + G_CALLBACK(onMapZoomOutClicked), + &state); + gtk_box_append(GTK_BOX(view_row), zoom_out); + GtkWidget* zoom_in = gtk_button_new_with_label("+"); + gtk_widget_add_css_class(zoom_in, "nav-button"); + g_signal_connect(zoom_in, + "clicked", + G_CALLBACK(onMapZoomInClicked), + &state); + gtk_box_append(GTK_BOX(view_row), zoom_in); + state.map_recenter = gtk_button_new_with_label("Cen"); + gtk_widget_add_css_class(state.map_recenter, "nav-button"); + g_signal_connect(state.map_recenter, + "clicked", + G_CALLBACK(onMapRecenterClicked), + &state); + gtk_box_append(GTK_BOX(view_row), state.map_recenter); + gtk_box_append(GTK_BOX(view_section), view_row); + gtk_box_append(GTK_BOX(left_panel), view_section); + + GtkWidget* layers_section = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_add_css_class(layers_section, "map-tool-section"); + gtk_box_append(GTK_BOX(layers_section), + makeLabel("Layers", "map-tool-title")); + + GtkWidget* mqtt_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(mqtt_row, "map-tool-row"); + GtkWidget* mqtt_label = makeLabel("MQTT", "row-meta"); + gtk_widget_set_hexpand(mqtt_label, TRUE); + gtk_box_append(GTK_BOX(mqtt_row), mqtt_label); + state.map_mqtt_nodes = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(state.map_mqtt_nodes), + state.map_model.snapshot().show_mqtt_nodes); + g_signal_connect(state.map_mqtt_nodes, + "notify::active", + G_CALLBACK(onMapMqttNodesToggled), + &state); + gtk_box_append(GTK_BOX(mqtt_row), state.map_mqtt_nodes); + gtk_box_append(GTK_BOX(layers_section), mqtt_row); + + GtkWidget* contour_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(contour_row, "map-tool-row"); + GtkWidget* contour_label = makeLabel("Contours", "row-meta"); + gtk_widget_set_hexpand(contour_label, TRUE); + gtk_box_append(GTK_BOX(contour_row), contour_label); + state.map_contour_visible = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(state.map_contour_visible), + state.map_model.snapshot().contour_enabled); + g_signal_connect(state.map_contour_visible, + "notify::active", + G_CALLBACK(onMapContourVisibleToggled), + &state); + gtk_box_append(GTK_BOX(contour_row), state.map_contour_visible); + gtk_box_append(GTK_BOX(layers_section), contour_row); + + state.map_contour_fill = gtk_button_new_with_label("Fill"); + gtk_widget_add_css_class(state.map_contour_fill, "nav-button"); + g_signal_connect(state.map_contour_fill, + "clicked", + G_CALLBACK(onMapContourFillClicked), + &state); + gtk_box_append(GTK_BOX(layers_section), state.map_contour_fill); + gtk_box_append(GTK_BOX(left_panel), layers_section); + + GtkWidget* status_section = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(status_section, "map-tool-section"); + state.map_status = makeLabel("", "row-meta", true); + constrainLabelWidth(state.map_status, layout_spec::kMapRailTextWidthChars); + gtk_box_append(GTK_BOX(status_section), state.map_status); + state.map_contour_status = makeLabel("", "row-meta", true); + constrainLabelWidth(state.map_contour_status, + layout_spec::kMapRailTextWidthChars); + gtk_box_append(GTK_BOX(status_section), state.map_contour_status); + state.map_cache_status = makeLabel("", "row-meta", true); + constrainLabelWidth(state.map_cache_status, + layout_spec::kMapRailTextWidthChars); + gtk_box_append(GTK_BOX(status_section), state.map_cache_status); + gtk_box_append(GTK_BOX(left_panel), status_section); + gtk_box_append(GTK_BOX(root), makeMapRailViewport(left_panel)); + + state.map_canvas = gtk_overlay_new(); + gtk_widget_add_css_class(state.map_canvas, "map-canvas"); + gtk_widget_set_hexpand(state.map_canvas, TRUE); + gtk_widget_set_vexpand(state.map_canvas, TRUE); + gtk_widget_set_overflow(state.map_canvas, GTK_OVERFLOW_HIDDEN); + + state.map_grid = gtk_grid_new(); + gtk_widget_add_css_class(state.map_grid, "map-grid"); + gtk_widget_set_size_request(state.map_grid, 1, 1); + gtk_grid_set_row_spacing(GTK_GRID(state.map_grid), 0); + gtk_grid_set_column_spacing(GTK_GRID(state.map_grid), 0); + gtk_grid_set_row_homogeneous(GTK_GRID(state.map_grid), TRUE); + gtk_grid_set_column_homogeneous(GTK_GRID(state.map_grid), TRUE); + gtk_widget_set_hexpand(state.map_grid, TRUE); + gtk_widget_set_vexpand(state.map_grid, TRUE); + + state.map_contour_grid = gtk_grid_new(); + gtk_widget_add_css_class(state.map_contour_grid, "map-contour-grid"); + gtk_widget_set_size_request(state.map_contour_grid, 1, 1); + gtk_grid_set_row_spacing(GTK_GRID(state.map_contour_grid), 0); + gtk_grid_set_column_spacing(GTK_GRID(state.map_contour_grid), 0); + gtk_grid_set_row_homogeneous(GTK_GRID(state.map_contour_grid), TRUE); + gtk_grid_set_column_homogeneous(GTK_GRID(state.map_contour_grid), TRUE); + gtk_widget_set_hexpand(state.map_contour_grid, TRUE); + gtk_widget_set_vexpand(state.map_contour_grid, TRUE); + gtk_widget_set_can_target(state.map_contour_grid, FALSE); + + GtkWidget* map_viewport = gtk_overlay_new(); + gtk_widget_set_hexpand(map_viewport, TRUE); + gtk_widget_set_vexpand(map_viewport, TRUE); + gtk_overlay_set_child(GTK_OVERLAY(map_viewport), state.map_grid); + gtk_overlay_add_overlay(GTK_OVERLAY(map_viewport), + state.map_contour_grid); + state.map_marker_layer = gtk_fixed_new(); + gtk_widget_set_hexpand(state.map_marker_layer, TRUE); + gtk_widget_set_vexpand(state.map_marker_layer, TRUE); + gtk_overlay_add_overlay(GTK_OVERLAY(map_viewport), + state.map_marker_layer); + + GtkGesture* drag = gtk_gesture_drag_new(); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), + GDK_BUTTON_PRIMARY); + g_signal_connect(drag, "drag-begin", G_CALLBACK(onMapDragBegin), &state); + g_signal_connect(drag, "drag-update", G_CALLBACK(onMapDragUpdate), &state); + g_signal_connect(drag, "drag-end", G_CALLBACK(onMapDragEnd), &state); + gtk_widget_add_controller(map_viewport, GTK_EVENT_CONTROLLER(drag)); + + GtkGesture* context_click = gtk_gesture_click_new(); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(context_click), + GDK_BUTTON_SECONDARY); + g_signal_connect(context_click, + "pressed", + G_CALLBACK(onMapContextPressed), + &state); + gtk_widget_add_controller(map_viewport, + GTK_EVENT_CONTROLLER(context_click)); + state.map_context_popover = buildMapContextPopover(state, map_viewport); + + GtkGesture* primary_click = gtk_gesture_click_new(); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(primary_click), + GDK_BUTTON_PRIMARY); + g_signal_connect(primary_click, + "pressed", + G_CALLBACK(onMapPrimaryPressed), + &state); + gtk_widget_add_controller(map_viewport, + GTK_EVENT_CONTROLLER(primary_click)); + + gtk_widget_set_size_request(map_viewport, 1, 1); + gtk_widget_set_overflow(map_viewport, GTK_OVERFLOW_HIDDEN); + gtk_overlay_set_child(GTK_OVERLAY(state.map_canvas), map_viewport); + gtk_box_append(GTK_BOX(root), state.map_canvas); + + GtkWidget* right_panel = makePanel(); + gtk_widget_add_css_class(right_panel, "map-tools-panel"); + gtk_widget_set_hexpand(right_panel, FALSE); + gtk_widget_set_size_request(right_panel, + layout_spec::kMapSideRailWidth, + -1); + gtk_widget_set_vexpand(right_panel, TRUE); + gtk_box_append(GTK_BOX(right_panel), + makeLabel("Tools", "pane-heading")); + GtkWidget* distance_section = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_add_css_class(distance_section, "map-tool-section"); + gtk_box_append(GTK_BOX(distance_section), + makeLabel("Distance", "map-tool-title")); + state.map_measure_button = gtk_button_new_with_label("Measure"); + gtk_widget_add_css_class(state.map_measure_button, "nav-button"); + g_signal_connect(state.map_measure_button, + "clicked", + G_CALLBACK(onMapMeasureClicked), + &state); + gtk_box_append(GTK_BOX(distance_section), state.map_measure_button); + state.map_measure_clear = gtk_button_new_with_label("Clear"); + gtk_widget_add_css_class(state.map_measure_clear, "nav-button"); + g_signal_connect(state.map_measure_clear, + "clicked", + G_CALLBACK(onMapMeasureClearClicked), + &state); + gtk_box_append(GTK_BOX(distance_section), state.map_measure_clear); + state.map_measure_status = makeLabel("", "row-meta", true); + constrainLabelWidth(state.map_measure_status, + layout_spec::kMapRailTextWidthChars); + gtk_box_append(GTK_BOX(distance_section), state.map_measure_status); + gtk_box_append(GTK_BOX(right_panel), distance_section); + + gtk_box_append(GTK_BOX(root), makeMapRailViewport(right_panel)); + return root; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_map_logic.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_map_logic.cpp new file mode 100644 index 00000000..cb44e82f --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_map_logic.cpp @@ -0,0 +1,1481 @@ +#include "platform/gtk/gtk_uconsole_layout_spec.h" +#include "platform/gtk/gtk_uconsole_mqtt_settings.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_shell.h" +#include "platform/gtk/gtk_uconsole_widgets.h" +#include "sys/clock.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace trailmate::uconsole::gtk +{ + +std::string tileKey(const ::platform::linux_runtime::MapTileId& tile) +{ + std::ostringstream out; + out << static_cast(tile.source) << ':' << tile.z << ':' << tile.x + << ':' << tile.y; + return out.str(); +} + +int mapFetchRetryDelaySeconds(unsigned attempts) +{ + const unsigned retry_index = + attempts > 0U ? std::min(attempts - 1U, 6U) : 0U; + const int delay = kMapFetchRetryBaseSeconds << retry_index; + return std::clamp(delay, + kMapFetchRetryBaseSeconds, + kMapFetchRetryMaxSeconds); +} + +std::string mapGridSignature(const MapWorkspaceSnapshot& snapshot) +{ + std::ostringstream out; + out << snapshot.source_label << ':' << snapshot.zoom << ':' + << snapshot.columns << 'x' << snapshot.rows << ':' + << snapshot.center_tile_index << ':' + << (snapshot.contour_enabled ? 'C' : '-'); + for (const auto& tile : snapshot.tiles) + { + out << '|' << tileKey(tile.id) << ':' + << (tile.available ? '1' : '0') << ':' + << tile.path.generic_string(); + } + if (snapshot.contour_enabled) + { + for (const auto& contour : snapshot.contour_tiles) + { + out << "|c" << contour.base_tile_index << ':' + << ::platform::linux_runtime::map_contour_profile_key( + contour.id.profile) + << ':' << contour.id.z << ':' << contour.id.x << ':' + << contour.id.y << ':' << (contour.available ? '1' : '0') + << ':' << contour.path.generic_string(); + } + } + return out.str(); +} + +std::vector<::platform::linux_runtime::MapContourTileId> missingContourTiles( + const MapWorkspaceSnapshot& snapshot) +{ + std::vector<::platform::linux_runtime::MapContourTileId> out; + if (!snapshot.contour_enabled) + { + return out; + } + out.reserve(snapshot.contour_missing_count); + for (const auto& tile : snapshot.contour_tiles) + { + if (!tile.available) + { + out.push_back(tile.id); + } + } + return out; +} +void onMapSourceClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + const guint source_value = + GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(button), + "trailmate-source")); + state.map_model.setSource( + ::platform::linux_runtime::sanitize_map_base_source(source_value)); + state.map_failed_tiles.clear(); + state.map_fetch_status.clear(); + refreshUi(state); +} + +void onMapZoomInClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.map_model.zoomIn(); + state.map_failed_tiles.clear(); + refreshUi(state); +} + +void onMapZoomOutClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.map_model.zoomOut(); + state.map_failed_tiles.clear(); + refreshUi(state); +} + +void onMapRecenterClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.map_model.clearManualCenter(); + state.map_failed_tiles.clear(); + state.map_fetch_status.clear(); + refreshUi(state); +} + +std::string formatMapCoordinate(double lat, double lon) +{ + char buffer[64] = {}; + std::snprintf(buffer, sizeof(buffer), "%.5f, %.5f", lat, lon); + return std::string(buffer); +} + +std::string formatMapDecimal(double value) +{ + char buffer[32] = {}; + std::snprintf(buffer, sizeof(buffer), "%.5f", value); + return std::string(buffer); +} + +std::string truncateMapStatusValue(const std::string& value) +{ + constexpr std::size_t kMaxChars = layout_spec::kMapStatusValueMaxChars; + if (value.size() <= kMaxChars) + { + return value; + } + return value.substr(0, kMaxChars - 3U) + "..."; +} + +double mapDegToRad(double degrees) +{ + return degrees * 3.14159265358979323846 / 180.0; +} + +double mapDistanceMeters(double lat_a, + double lon_a, + double lat_b, + double lon_b) +{ + constexpr double kEarthRadiusM = 6371000.0; + const double d_lat = mapDegToRad(lat_b - lat_a); + const double d_lon = mapDegToRad(lon_b - lon_a); + const double a = + std::sin(d_lat / 2.0) * std::sin(d_lat / 2.0) + + std::cos(mapDegToRad(lat_a)) * std::cos(mapDegToRad(lat_b)) * + std::sin(d_lon / 2.0) * std::sin(d_lon / 2.0); + const double c = 2.0 * std::atan2(std::sqrt(a), std::sqrt(1.0 - a)); + return kEarthRadiusM * c; +} + +std::string formatMapDistance(double meters) +{ + if (!std::isfinite(meters) || meters < 0.0) + { + return "-"; + } + char buffer[32] = {}; + if (meters < 1000.0) + { + std::snprintf(buffer, sizeof(buffer), "%.0f m", meters); + } + else + { + std::snprintf(buffer, sizeof(buffer), "%.2f km", meters / 1000.0); + } + return std::string(buffer); +} + +std::string formatMapNodeAge(std::uint32_t timestamp) +{ + if (timestamp == 0) + { + return "seen: --"; + } + const std::uint32_t now = sys::epoch_seconds_now(); + if (timestamp >= now) + { + return "seen: now"; + } + const std::uint32_t age = now - timestamp; + char buffer[32] = {}; + if (age < 60U) + { + std::snprintf(buffer, sizeof(buffer), "seen: now"); + } + else if (age < 3600U) + { + std::snprintf(buffer, + sizeof(buffer), + "seen: %lum", + static_cast(age / 60U)); + } + else if (age < 86400U) + { + std::snprintf(buffer, + sizeof(buffer), + "seen: %luh", + static_cast(age / 3600U)); + } + else + { + std::snprintf(buffer, + sizeof(buffer), + "seen: %lud", + static_cast(age / 86400U)); + } + return buffer; +} + +void updateMapMeasureStatus(GtkUConsoleAppState& state) +{ + if (state.map_measure_button != nullptr) + { + if (state.map_measure_enabled) + { + gtk_widget_add_css_class(state.map_measure_button, + "nav-button-active"); + } + else + { + gtk_widget_remove_css_class(state.map_measure_button, + "nav-button-active"); + } + } + + if (state.map_measure_status == nullptr) + { + return; + } + if (!state.map_measure_has_start) + { + setLabel(state.map_measure_status, "No measure points."); + return; + } + + std::string text = "A lat: " + + formatMapDecimal(state.map_measure_start_lat) + + "\nA lon: " + + formatMapDecimal(state.map_measure_start_lon); + if (state.map_measure_has_end) + { + const double meters = mapDistanceMeters(state.map_measure_start_lat, + state.map_measure_start_lon, + state.map_measure_end_lat, + state.map_measure_end_lon); + text += "\nB lat: "; + text += formatMapDecimal(state.map_measure_end_lat); + text += "\nB lon: "; + text += formatMapDecimal(state.map_measure_end_lon); + text += "\ndist: "; + text += formatMapDistance(meters); + } + else + { + text += "\nB pending"; + } + setLabel(state.map_measure_status, text); +} + +void setMapMeasurePoint(GtkUConsoleAppState& state, + double lat, + double lon, + bool end_point) +{ + if (end_point && state.map_measure_has_start) + { + state.map_measure_end_lat = lat; + state.map_measure_end_lon = lon; + state.map_measure_has_end = true; + } + else + { + state.map_measure_start_lat = lat; + state.map_measure_start_lon = lon; + state.map_measure_has_start = true; + state.map_measure_has_end = false; + } + updateMapMeasureStatus(state); + refreshMap(state); +} + +void refreshAfterMapContextAction(GtkUConsoleAppState& state) +{ + if (state.map_context_popover != nullptr) + { + gtk_popover_popdown(GTK_POPOVER(state.map_context_popover)); + } + state.map_failed_tiles.clear(); + state.map_fetch_status.clear(); + refreshUi(state); +} + +void onMapContextCenterClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (!state.map_context_valid) + { + return; + } + + state.map_model.centerOn(state.map_context_lat, + state.map_context_lon, + true); + refreshAfterMapContextAction(state); +} + +void onMapContextZoomInClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (!state.map_context_valid) + { + return; + } + + state.map_model.zoomInAt(state.map_context_lat, state.map_context_lon); + refreshAfterMapContextAction(state); +} + +void onMapContextZoomOutClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (!state.map_context_valid) + { + return; + } + + state.map_model.zoomOutAt(state.map_context_lat, state.map_context_lon); + refreshAfterMapContextAction(state); +} + +void onMapContextMeasureStartClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (!state.map_context_valid) + { + return; + } + state.map_measure_enabled = true; + setMapMeasurePoint(state, + state.map_context_lat, + state.map_context_lon, + false); + refreshAfterMapContextAction(state); +} + +void onMapContextMeasureEndClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (!state.map_context_valid) + { + return; + } + state.map_measure_enabled = true; + setMapMeasurePoint(state, + state.map_context_lat, + state.map_context_lon, + true); + refreshAfterMapContextAction(state); +} + +MapCoordinate coordinateAtPointer(GtkUConsoleAppState& state, + double x, + double y) +{ + const int width = state.map_marker_layer != nullptr + ? gtk_widget_get_width(state.map_marker_layer) + : 0; + const int height = state.map_marker_layer != nullptr + ? gtk_widget_get_height(state.map_marker_layer) + : 0; + const MapWorkspaceSnapshot snapshot = state.map_model.snapshot(); + return state.map_model.coordinateAtDisplayPoint(snapshot, + x, + y, + width, + height); +} + +void onMapContextPressed(GtkGestureClick* gesture, + int, + double x, + double y, + gpointer data) +{ + auto& state = *static_cast(data); + if (state.map_context_popover == nullptr) + { + return; + } + + const MapCoordinate coordinate = coordinateAtPointer(state, x, y); + if (!coordinate.valid) + { + state.map_context_valid = false; + return; + } + + state.map_context_valid = true; + state.map_context_lat = coordinate.lat; + state.map_context_lon = coordinate.lon; + setLabel(state.map_context_label, + "Point " + formatMapCoordinate(coordinate.lat, coordinate.lon)); + + const GdkRectangle rect{static_cast(std::lround(x)), + static_cast(std::lround(y)), + 1, + 1}; + gtk_popover_set_pointing_to(GTK_POPOVER(state.map_context_popover), &rect); + gtk_popover_popup(GTK_POPOVER(state.map_context_popover)); + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); +} + +void onMapPrimaryPressed(GtkGestureClick* gesture, + int, + double x, + double y, + gpointer data) +{ + auto& state = *static_cast(data); + if (!state.map_measure_enabled) + { + return; + } + const MapCoordinate coordinate = coordinateAtPointer(state, x, y); + if (!coordinate.valid) + { + return; + } + setMapMeasurePoint(state, + coordinate.lat, + coordinate.lon, + state.map_measure_has_start && + !state.map_measure_has_end); + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); +} + +void panMapFromDrag(GtkUConsoleAppState& state, + double offset_x, + double offset_y, + bool persist) +{ + if (!state.map_dragging && !persist) + { + return; + } + + const int width = state.map_marker_layer != nullptr + ? gtk_widget_get_width(state.map_marker_layer) + : 0; + const int height = state.map_marker_layer != nullptr + ? gtk_widget_get_height(state.map_marker_layer) + : 0; + state.map_model.panByDisplayDelta(offset_x, + offset_y, + width, + height, + state.map_drag_start_lat, + state.map_drag_start_lon, + state.map_drag_start_zoom, + persist); + state.map_failed_tiles.clear(); + refreshMap(state); +} + +void onMapDragBegin(GtkGestureDrag*, double, double, gpointer data) +{ + auto& state = *static_cast(data); + if (state.map_measure_enabled) + { + state.map_dragging = false; + return; + } + const MapWorkspaceSnapshot snapshot = state.map_model.snapshot(); + if (!snapshot.has_center) + { + state.map_dragging = false; + return; + } + + state.map_dragging = true; + state.map_drag_start_lat = snapshot.lat; + state.map_drag_start_lon = snapshot.lon; + state.map_drag_start_zoom = snapshot.zoom; + state.map_fetch_status.clear(); +} + +void onMapDragUpdate(GtkGestureDrag*, double offset_x, double offset_y, + gpointer data) +{ + panMapFromDrag(*static_cast(data), + offset_x, + offset_y, + false); +} + +void onMapDragEnd(GtkGestureDrag*, double offset_x, double offset_y, + gpointer data) +{ + auto& state = *static_cast(data); + if (!state.map_dragging) + { + return; + } + panMapFromDrag(state, offset_x, offset_y, true); + state.map_dragging = false; +} + +void onMapMqttNodesToggled(GObject*, GParamSpec*, gpointer data) +{ + auto& state = *static_cast(data); + if (state.map_mqtt_nodes == nullptr) + { + return; + } + const bool enabled = + gtk_switch_get_active(GTK_SWITCH(state.map_mqtt_nodes)); + state.map_model.setShowMqttNodes(enabled); + if (state.settings_map_mqtt_nodes != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_map_mqtt_nodes), + enabled); + } + refreshUi(state); +} + +void onMapContourVisibleToggled(GObject*, GParamSpec*, gpointer data) +{ + auto& state = *static_cast(data); + if (state.map_contour_visible == nullptr) + { + return; + } + + const bool enabled = + gtk_switch_get_active(GTK_SWITCH(state.map_contour_visible)); + state.map_model.setContourEnabled(enabled); + if (state.settings_map_contour != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_map_contour), + enabled); + } + state.map_grid_signature.clear(); + state.contour_fill_status = + enabled ? "Contours visible." : "Contours hidden."; + refreshUi(state); +} + +void onMapContourFillClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (state.contour_fill_job.future.valid()) + { + state.contour_fill_status = "Contour fill already running."; + refreshMap(state); + return; + } + + const MapWorkspaceSnapshot snapshot = state.map_model.snapshot(); + if (!snapshot.contour_enabled) + { + state.contour_fill_status = "Turn on contours before filling."; + refreshMap(state, snapshot); + return; + } + if (!snapshot.earthdata_token_configured) + { + state.contour_fill_status = "Earthdata token missing."; + refreshMap(state, snapshot); + return; + } + + auto tiles = missingContourTiles(snapshot); + if (tiles.empty()) + { + state.contour_fill_status = "Visible contour tiles already cached."; + refreshMap(state, snapshot); + return; + } + + state.contour_fill_status = + "Filling visible contours: " + std::to_string(tiles.size()) + + " tiles queued."; + auto* model = &state.map_model; + state.contour_fill_job.future = + std::async(std::launch::async, + [model, tiles = std::move(tiles)]() + { + return model->ensureContourTiles(tiles); + }); + refreshMap(state, snapshot); +} + +void onMapMeasureClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.map_measure_enabled = !state.map_measure_enabled; + updateMapMeasureStatus(state); + refreshMap(state); +} + +void onMapMeasureClearClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + state.map_measure_has_start = false; + state.map_measure_has_end = false; + updateMapMeasureStatus(state); + refreshMap(state); +} +void setActiveMapSourceButton( + GtkWidget* button, + ::platform::linux_runtime::MapBaseSource expected, + const std::string& active_label) +{ + if (button == nullptr) return; + const bool active = + active_label == + ::platform::linux_runtime::map_base_source_label(expected); + if (active) + gtk_widget_add_css_class(button, "source-button-active"); + else + gtk_widget_remove_css_class(button, "source-button-active"); +} + +void pollMapFetch(GtkUConsoleAppState& state) +{ + using namespace std::chrono_literals; + if (state.map_fetch_jobs.empty()) + { + return; + } + + const auto now = std::chrono::steady_clock::now(); + std::size_t completed = 0; + std::size_t failed = 0; + std::size_t ready = 0; + std::string last_failure{}; + + for (std::size_t index = 0; index < state.map_fetch_jobs.size();) + { + auto& job = state.map_fetch_jobs[index]; + if (!job.future.valid() || + job.future.wait_for(0ms) != std::future_status::ready) + { + ++index; + continue; + } + + ::platform::linux_runtime::MapTileResult result{}; + try + { + result = job.future.get(); + } + catch (const std::exception& ex) + { + result.status = ::platform::linux_runtime::MapTileStatus::Failed; + result.tile = job.tile; + result.message = ex.what(); + } + catch (...) + { + result.status = ::platform::linux_runtime::MapTileStatus::Failed; + result.tile = job.tile; + result.message = "Unknown tile fetch error."; + } + + const std::string key = job.key; + state.map_inflight_tiles.erase(key); + ++completed; + + if (result.status == ::platform::linux_runtime::MapTileStatus::Failed) + { + auto& retry = state.map_failed_tiles[key]; + ++retry.attempts; + const int delay_seconds = + mapFetchRetryDelaySeconds(retry.attempts); + retry.next_retry = now + std::chrono::seconds(delay_seconds); + retry.last_error = result.message; + last_failure = result.message; + ++failed; + } + else + { + state.map_failed_tiles.erase(key); + ++ready; + } + + state.map_fetch_jobs.erase(state.map_fetch_jobs.begin() + + static_cast(index)); + } + + if (completed == 0) + { + return; + } + + if (failed > 0) + { + state.map_fetch_status = "state: retry queued\nready: " + + std::to_string(ready) + + "\nfailed: " + + std::to_string(failed); + if (!last_failure.empty()) + { + state.map_fetch_status += + "\nlast: " + truncateMapStatusValue(last_failure); + } + } + else + { + state.map_fetch_status = + "state: updated\nready: " + std::to_string(ready); + } + if (!state.map_fetch_jobs.empty()) + { + state.map_fetch_status += "\nactive: " + + std::to_string(state.map_fetch_jobs.size()); + } +} + +void maybeStartMapFetch(GtkUConsoleAppState& state, + const MapWorkspaceSnapshot& snapshot) +{ + pollMapFetch(state); + if (!snapshot.has_center || + state.map_fetch_jobs.size() >= kMaxConcurrentMapFetches) + { + return; + } + + struct Candidate + { + int priority = 0; + std::size_t index = 0; + ::platform::linux_runtime::MapTileId tile{}; + std::string key{}; + }; + + const auto now = std::chrono::steady_clock::now(); + const auto center = + snapshot.center_tile_index < snapshot.tiles.size() + ? snapshot.tiles[snapshot.center_tile_index].id + : ::platform::linux_runtime::MapTileId{}; + std::vector candidates; + candidates.reserve(snapshot.tiles.size()); + + for (const auto& item : snapshot.tiles) + { + if (item.available) + { + continue; + } + const std::string key = tileKey(item.id); + if (state.map_inflight_tiles.find(key) != + state.map_inflight_tiles.end()) + { + continue; + } + + const auto failed_it = state.map_failed_tiles.find(key); + if (failed_it != state.map_failed_tiles.end() && + now < failed_it->second.next_retry) + { + continue; + } + + const int distance = + std::abs(item.id.x - center.x) + std::abs(item.id.y - center.y); + candidates.push_back(Candidate{ + .priority = + item.id.x == center.x && item.id.y == center.y ? -1 + : distance, + .index = candidates.size(), + .tile = item.id, + .key = key, + }); + } + + std::sort(candidates.begin(), + candidates.end(), + [](const Candidate& lhs, const Candidate& rhs) + { + if (lhs.priority != rhs.priority) + { + return lhs.priority < rhs.priority; + } + return lhs.index < rhs.index; + }); + + std::size_t started = 0; + auto* model = &state.map_model; + for (const auto& candidate : candidates) + { + if (state.map_fetch_jobs.size() >= kMaxConcurrentMapFetches) + { + break; + } + if (!state.map_inflight_tiles.insert(candidate.key).second) + { + continue; + } + + const auto tile = candidate.tile; + state.map_fetch_jobs.push_back(GtkUConsoleAppState::MapFetchJob{ + .key = candidate.key, + .tile = tile, + .future = std::async(std::launch::async, + [model, tile]() + { + return model->ensureTile(tile); + }), + }); + ++started; + } + + if (started > 0) + { + state.map_fetch_status = "state: fetching\nsource: " + + truncateMapStatusValue( + snapshot.source_label) + + "\nqueued: " + std::to_string(started) + + "\nactive: " + + std::to_string(state.map_fetch_jobs.size()); + } + else if (!state.map_failed_tiles.empty() && state.map_fetch_jobs.empty()) + { + state.map_fetch_status = "state: retry pending\nfailed: " + + std::to_string(state.map_failed_tiles.size()); + } +} + +void pollContourFill(GtkUConsoleAppState& state) +{ + if (!state.contour_fill_job.future.valid()) + { + return; + } + + using namespace std::chrono_literals; + if (state.contour_fill_job.future.wait_for(0ms) != + std::future_status::ready) + { + state.contour_fill_status = "Contour fill running."; + return; + } + + try + { + const auto result = state.contour_fill_job.future.get(); + state.contour_fill_status = result.message.empty() + ? "Contour fill complete." + : result.message; + } + catch (const std::exception& ex) + { + state.contour_fill_status = + std::string("Contour fill failed: ") + ex.what(); + } + catch (...) + { + state.contour_fill_status = "Contour fill failed."; + } + state.map_grid_signature.clear(); +} + +void onMapNodeMarkerClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + const auto node_id = static_cast( + GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(button), + "trailmate-map-node-id"))); + state.map_selected_node_id = + state.map_selected_node_id == node_id ? 0U : node_id; + refreshMap(state); +} + +GtkWidget* buildNodeMarker(GtkUConsoleAppState& state, + const MapNodeOverlayItem& node) +{ + const std::string label = + node.via_mqtt ? ("MQ " + node.label) : node.label; + GtkWidget* marker = gtk_button_new_with_label(label.c_str()); + gtk_widget_add_css_class( + marker, + node.via_mqtt ? "map-marker-mqtt" : "map-marker-local"); + gtk_widget_add_css_class(marker, "map-node-marker-button"); + std::ostringstream tip; + tip << (node.via_mqtt ? "MQTT" : "Mesh") << " node 0x" + << std::hex << std::uppercase << node.node_id << std::dec + << " / lat " << node.lat << " / lon " << node.lon; + gtk_widget_set_tooltip_text(marker, tip.str().c_str()); + g_object_set_data(G_OBJECT(marker), + "trailmate-map-node-id", + GUINT_TO_POINTER(static_cast(node.node_id))); + g_signal_connect(marker, + "clicked", + G_CALLBACK(onMapNodeMarkerClicked), + &state); + return marker; +} + +GtkWidget* buildMapNodeBubble(const MapNodeOverlayItem& node) +{ + GtkWidget* bubble = gtk_box_new(GTK_ORIENTATION_VERTICAL, 3); + gtk_widget_add_css_class(bubble, "map-node-bubble"); + gtk_widget_set_size_request(bubble, 176, -1); + + GtkWidget* title_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* title = makeLabel(node.label.c_str(), "row-title", true); + gtk_widget_set_hexpand(title, TRUE); + gtk_box_append(GTK_BOX(title_row), title); + gtk_box_append(GTK_BOX(title_row), + makeLabel(node.via_mqtt ? "MQTT" : "LoRa", "mini-chip")); + gtk_box_append(GTK_BOX(bubble), title_row); + + char id[24] = {}; + std::snprintf(id, + sizeof(id), + "id: !%08lX", + static_cast(node.node_id)); + gtk_box_append(GTK_BOX(bubble), makeLabel(id, "row-meta")); + + std::string position = "lat: " + formatMapDecimal(node.lat) + + "\nlon: " + formatMapDecimal(node.lon); + if (node.has_altitude) + { + position += "\nalt: " + std::to_string(node.altitude_m) + " m"; + } + gtk_box_append(GTK_BOX(bubble), + makeLabel(position.c_str(), "row-meta", true)); + + std::string radio = formatMapNodeAge(node.last_seen); + radio += "\nhops: "; + radio += node.hops_away == 0xFF + ? "?" + : std::to_string(static_cast(node.hops_away)); + radio += "\nrssi: "; + radio += std::isfinite(node.rssi) + ? std::to_string(static_cast(std::lround(node.rssi))) + : "?"; + radio += " dBm\nsnr: "; + if (std::isfinite(node.snr)) + { + char snr[24] = {}; + std::snprintf(snr, sizeof(snr), "%.1f dB", node.snr); + radio += snr; + } + else + { + radio += "?"; + } + gtk_box_append(GTK_BOX(bubble), + makeLabel(radio.c_str(), "row-meta", true)); + return bubble; +} + +double mapLongitudeToWorldPx(double lon, int zoom) +{ + constexpr double kTileSizePx = 256.0; + const double tiles = static_cast(1U << zoom); + return ((lon + 180.0) / 360.0) * tiles * kTileSizePx; +} + +double mapLatitudeToWorldPx(double lat, int zoom) +{ + constexpr double kTileSizePx = 256.0; + constexpr double kMaxMercatorLat = 85.05112878; + const double clamped_lat = + std::clamp(lat, -kMaxMercatorLat, kMaxMercatorLat); + const double lat_rad = mapDegToRad(clamped_lat); + const double tiles = static_cast(1U << zoom); + const double mercator = + std::log(std::tan(lat_rad) + (1.0 / std::cos(lat_rad))); + return ((1.0 - mercator / 3.14159265358979323846) / 2.0) * tiles * + kTileSizePx; +} + +bool projectMapPoint(const MapWorkspaceSnapshot& snapshot, + double lat, + double lon, + double& out_x_fraction, + double& out_y_fraction) +{ + if (!snapshot.has_center || snapshot.tiles.empty() || + !std::isfinite(lat) || !std::isfinite(lon)) + { + return false; + } + + constexpr double kTileSizePx = 256.0; + const auto top_left = snapshot.tiles.front().id; + const double map_width_px = + static_cast(std::max(1U, snapshot.columns)) * + kTileSizePx; + const double map_height_px = + static_cast(std::max(1U, snapshot.rows)) * + kTileSizePx; + const double world_width_px = + static_cast(1U << snapshot.zoom) * kTileSizePx; + const double left_px = static_cast(top_left.x) * kTileSizePx; + const double top_px = static_cast(top_left.y) * kTileSizePx; + + double x = mapLongitudeToWorldPx(lon, snapshot.zoom) - left_px; + if (x < 0.0) + { + x += world_width_px; + } + if (x > map_width_px && (x - world_width_px) >= 0.0) + { + x -= world_width_px; + } + const double y = mapLatitudeToWorldPx(lat, snapshot.zoom) - top_px; + if (x < 0.0 || x > map_width_px || y < 0.0 || y > map_height_px) + { + return false; + } + out_x_fraction = map_width_px > 0.0 ? x / map_width_px : 0.0; + out_y_fraction = map_height_px > 0.0 ? y / map_height_px : 0.0; + return true; +} + +GtkWidget* buildMeasureMarker(const char* label) +{ + GtkWidget* marker = makeLabel(label, "map-marker-measure"); + gtk_label_set_xalign(GTK_LABEL(marker), 0.5F); + return marker; +} + +void putMeasureMarker(GtkUConsoleAppState& state, + const MapWorkspaceSnapshot& snapshot, + double lat, + double lon, + const char* label, + int width, + int height) +{ + double x_fraction = 0.0; + double y_fraction = 0.0; + if (!projectMapPoint(snapshot, lat, lon, x_fraction, y_fraction)) + { + return; + } + GtkWidget* marker = buildMeasureMarker(label); + const double x = std::clamp(x_fraction, 0.0, 1.0) * + static_cast(width); + const double y = std::clamp(y_fraction, 0.0, 1.0) * + static_cast(height); + gtk_fixed_put(GTK_FIXED(state.map_marker_layer), + marker, + std::max(0.0, x - 10.0), + std::max(0.0, y - 10.0)); +} + +void refreshMapMarkers(GtkUConsoleAppState& state, + const MapWorkspaceSnapshot& snapshot) +{ + clearFixed(state.map_marker_layer); + if (state.map_marker_layer == nullptr) + { + return; + } + + const int width = gtk_widget_get_width(state.map_marker_layer); + const int height = gtk_widget_get_height(state.map_marker_layer); + if (width <= 0 || height <= 0) + { + return; + } + + if (state.map_selected_node_id != 0 && + std::none_of(snapshot.nodes.begin(), + snapshot.nodes.end(), + [&](const auto& node) + { + return node.node_id == state.map_selected_node_id; + })) + { + state.map_selected_node_id = 0; + } + + for (const auto& node : snapshot.nodes) + { + GtkWidget* marker = buildNodeMarker(state, node); + const double x = std::clamp(node.x_fraction, 0.0, 1.0) * + static_cast(width); + const double y = std::clamp(node.y_fraction, 0.0, 1.0) * + static_cast(height); + gtk_fixed_put(GTK_FIXED(state.map_marker_layer), + marker, + std::max(0.0, x - 14.0), + std::max(0.0, y - 11.0)); + if (state.map_selected_node_id == node.node_id) + { + GtkWidget* bubble = buildMapNodeBubble(node); + gtk_fixed_put( + GTK_FIXED(state.map_marker_layer), + bubble, + std::clamp(x + 12.0, + 4.0, + static_cast(std::max(4, width - 178))), + std::clamp(y - 18.0, + 4.0, + static_cast(std::max(4, height - 156)))); + } + } + + if (state.map_measure_has_start) + { + putMeasureMarker(state, + snapshot, + state.map_measure_start_lat, + state.map_measure_start_lon, + "A", + width, + height); + } + if (state.map_measure_has_end) + { + putMeasureMarker(state, + snapshot, + state.map_measure_end_lat, + state.map_measure_end_lon, + "B", + width, + height); + + const double midpoint_lat = + (state.map_measure_start_lat + state.map_measure_end_lat) * 0.5; + const double midpoint_lon = + (state.map_measure_start_lon + state.map_measure_end_lon) * 0.5; + const double meters = mapDistanceMeters(state.map_measure_start_lat, + state.map_measure_start_lon, + state.map_measure_end_lat, + state.map_measure_end_lon); + putMeasureMarker(state, + snapshot, + midpoint_lat, + midpoint_lon, + formatMapDistance(meters).c_str(), + width, + height); + } +} + +GtkWidget* buildTileCell(const MapTileItem& item, bool center) +{ + GtkWidget* cell = gtk_overlay_new(); + gtk_widget_add_css_class(cell, "tile-cell"); + gtk_widget_set_size_request(cell, 1, 1); + gtk_widget_set_hexpand(cell, TRUE); + gtk_widget_set_vexpand(cell, TRUE); + + GtkWidget* content = nullptr; + if (item.available) + { + content = + gtk_picture_new_for_filename(item.path.string().c_str()); + gtk_picture_set_content_fit(GTK_PICTURE(content), + GTK_CONTENT_FIT_FILL); + gtk_picture_set_can_shrink(GTK_PICTURE(content), TRUE); + } + else + { + content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(content, "map-tile-pending"); + } + gtk_widget_set_size_request(content, 1, 1); + gtk_widget_set_hexpand(content, TRUE); + gtk_widget_set_vexpand(content, TRUE); + gtk_overlay_set_child(GTK_OVERLAY(cell), content); + + if (center) + { + GtkWidget* marker = makeLabel("+", "map-marker"); + gtk_widget_set_halign(marker, GTK_ALIGN_CENTER); + gtk_widget_set_valign(marker, GTK_ALIGN_CENTER); + gtk_overlay_add_overlay(GTK_OVERLAY(cell), marker); + } + return cell; +} + +GtkWidget* buildContourTileCell(const MapWorkspaceSnapshot& snapshot, + std::size_t base_tile_index) +{ + GtkWidget* cell = gtk_overlay_new(); + gtk_widget_add_css_class(cell, "map-contour-cell"); + gtk_widget_set_size_request(cell, 1, 1); + gtk_widget_set_hexpand(cell, TRUE); + gtk_widget_set_vexpand(cell, TRUE); + gtk_widget_set_can_target(cell, FALSE); + + GtkWidget* blank = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_size_request(blank, 1, 1); + gtk_widget_set_hexpand(blank, TRUE); + gtk_widget_set_vexpand(blank, TRUE); + gtk_widget_set_can_target(blank, FALSE); + gtk_overlay_set_child(GTK_OVERLAY(cell), blank); + + for (const auto& contour : snapshot.contour_tiles) + { + if (!contour.available || contour.base_tile_index != base_tile_index) + { + continue; + } + + GtkWidget* image = + gtk_picture_new_for_filename(contour.path.string().c_str()); + gtk_picture_set_content_fit(GTK_PICTURE(image), GTK_CONTENT_FIT_FILL); + gtk_picture_set_can_shrink(GTK_PICTURE(image), TRUE); + gtk_widget_set_size_request(image, 1, 1); + gtk_widget_set_hexpand(image, TRUE); + gtk_widget_set_vexpand(image, TRUE); + gtk_widget_set_can_target(image, FALSE); + gtk_widget_set_opacity( + image, + contour.id.profile.kind == + ::platform::linux_runtime::MapContourKind::Minor + ? 0.70 + : 0.85); + gtk_overlay_add_overlay(GTK_OVERLAY(cell), image); + } + + return cell; +} + +void refreshMap(GtkUConsoleAppState& state, + const MapWorkspaceSnapshot& snapshot) +{ + pollContourFill(state); + maybeStartMapFetch(state, snapshot); + + setActiveMapSourceButton( + state.map_source_osm, + ::platform::linux_runtime::MapBaseSource::Osm, + snapshot.source_label); + setActiveMapSourceButton( + state.map_source_terrain, + ::platform::linux_runtime::MapBaseSource::Terrain, + snapshot.source_label); + setActiveMapSourceButton( + state.map_source_satellite, + ::platform::linux_runtime::MapBaseSource::Satellite, + snapshot.source_label); + if (state.map_mqtt_nodes != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.map_mqtt_nodes), + snapshot.show_mqtt_nodes); + } + if (state.map_contour_visible != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.map_contour_visible), + snapshot.contour_enabled); + } + if (state.map_contour_fill != nullptr) + { + gtk_widget_set_sensitive( + state.map_contour_fill, + snapshot.contour_enabled && + !state.contour_fill_job.future.valid() + ? TRUE + : FALSE); + } + if (state.settings_map_mqtt_nodes != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_map_mqtt_nodes), + snapshot.show_mqtt_nodes); + } + if (state.map_recenter != nullptr) + { + gtk_widget_set_sensitive(state.map_recenter, + snapshot.has_manual_center ? TRUE : FALSE); + } + if (state.map_measure_clear != nullptr) + { + gtk_widget_set_sensitive( + state.map_measure_clear, + state.map_measure_has_start || state.map_measure_has_end ? TRUE + : FALSE); + } + updateMapMeasureStatus(state); + + std::string title = snapshot.source_label + " map"; + title += " / z" + std::to_string(snapshot.zoom); + setLabel(state.map_title, title); + + if (snapshot.has_center) + { + std::string meta = truncateMapStatusValue(snapshot.fix_label); + meta += "\nlat: " + formatMapDecimal(snapshot.lat); + meta += "\nlon: " + formatMapDecimal(snapshot.lon); + if (snapshot.has_altitude) + { + meta += "\nalt: " + + std::to_string(static_cast( + std::lround(snapshot.altitude_m))) + + " m"; + } + if (snapshot.satellites > 0) + { + meta += "\nsats: " + std::to_string(snapshot.satellites); + } + setLabel(state.map_meta, meta); + } + else + { + setLabel(state.map_meta, "No map center."); + } + + const std::string grid_signature = mapGridSignature(snapshot); + if (grid_signature != state.map_grid_signature) + { + clearGrid(state.map_grid); + clearGrid(state.map_contour_grid); + if (!snapshot.has_center) + { + GtkWidget* empty = makeRowBox(); + gtk_box_append(GTK_BOX(empty), + makeLabel("No map center", "row-title")); + gtk_grid_attach(GTK_GRID(state.map_grid), + empty, + 0, + 0, + 3, + 1); + } + else + { + for (std::size_t index = 0; index < snapshot.tiles.size(); ++index) + { + const auto columns = + std::max(1U, snapshot.columns); + const int col = static_cast(index % columns); + const int row = static_cast(index / columns); + gtk_grid_attach(GTK_GRID(state.map_grid), + buildTileCell( + snapshot.tiles[index], + index == snapshot.center_tile_index), + col, + row, + 1, + 1); + if (snapshot.contour_enabled && + state.map_contour_grid != nullptr) + { + gtk_grid_attach(GTK_GRID(state.map_contour_grid), + buildContourTileCell(snapshot, index), + col, + row, + 1, + 1); + } + } + } + state.map_grid_signature = grid_signature; + } + refreshMapMarkers(state, snapshot); + + std::string tile_status = "Tiles\n"; + tile_status += state.map_fetch_status.empty() ? "state: idle" + : state.map_fetch_status; + tile_status += "\nactive: " + std::to_string(state.map_fetch_jobs.size()); + tile_status += "\nretry: " + + std::to_string(state.map_failed_tiles.size()); + setLabel(state.map_status, tile_status); + if (state.map_contour_status != nullptr) + { + std::string contour = "Contours\n"; + contour += snapshot.contour_enabled ? "visible: yes" : "visible: no"; + contour += "\ncached: " + + std::to_string(snapshot.contour_available_count) + "/" + + std::to_string(snapshot.contour_tiles.size()); + contour += snapshot.earthdata_token_configured + ? "\ntoken: set" + : "\ntoken: missing"; + if (!snapshot.contour_profiles.empty()) + { + contour += "\nprofile: "; + for (std::size_t i = 0; i < snapshot.contour_profiles.size(); ++i) + { + if (i > 0) + { + contour += ","; + } + contour += snapshot.contour_profiles[i]; + } + } + if (!state.contour_fill_status.empty()) + { + contour += "\nfill: " + state.contour_fill_status; + } + setLabel(state.map_contour_status, contour); + } + + std::string cache = "Cache\nbase: " + + std::to_string(snapshot.cache_stats.cached_tiles) + + "\nbytes: " + + formatBytes(snapshot.cache_stats.total_bytes) + + "\nfailed: " + + std::to_string(snapshot.cache_stats.failed_tiles) + + "\nnodes: " + + std::to_string(snapshot.visible_node_count); + if (snapshot.show_mqtt_nodes) + { + cache += "\nmqtt: " + + std::to_string(snapshot.visible_mqtt_node_count); + } + else if (snapshot.hidden_mqtt_node_count > 0) + { + cache += "\nmqtt hidden: " + + std::to_string(snapshot.hidden_mqtt_node_count); + } + const auto mqtt = loadMqttSettings(); + if (mqtt.enabled) + { + cache += "\nmqtt client: offline"; + } + setLabel(state.map_cache_status, cache); + if (state.map_cache_status != nullptr) + { + gtk_widget_set_tooltip_text(state.map_cache_status, + snapshot.cache_stats.root.string().c_str()); + } +} + +void refreshMap(GtkUConsoleAppState& state) +{ + refreshMap(state, state.map_model.snapshot()); +} + +void refreshMapLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + refreshMap(state, snapshot.map); +} + +static void destroyMapLogic(GtkUConsoleAppState& state) +{ + for (auto& job : state.map_fetch_jobs) + { + if (job.future.valid()) + { + job.future.wait(); + } + } + state.map_fetch_jobs.clear(); + state.map_inflight_tiles.clear(); + if (state.contour_fill_job.future.valid()) + { + state.contour_fill_job.future.wait(); + } +} + +GtkUConsolePageLifecycle makeMapPageLifecycle() +{ + return {.name = "map", + .title = "Map", + .onLaunch = launchMapLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshMapLogic, + .onDestroy = destroyMapLogic}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_mqtt_settings.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_mqtt_settings.cpp new file mode 100644 index 00000000..6ccbfddc --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_mqtt_settings.cpp @@ -0,0 +1,98 @@ +#include "platform/gtk/gtk_uconsole_mqtt_settings.h" + +#include +#include + +#include "platform/ui/settings_store.h" + +namespace trailmate::uconsole::gtk +{ + +constexpr const char* kMqttSettingsNamespace = "uconsole_mqtt"; +constexpr const char* kMqttEnabledKey = "enabled"; +constexpr const char* kMqttNameKey = "name"; +constexpr const char* kMqttHostKey = "host"; +constexpr const char* kMqttPortKey = "port"; +constexpr const char* kMqttUsernameKey = "username"; +constexpr const char* kMqttPasswordKey = "password"; +constexpr const char* kMqttTopicKey = "topic"; +constexpr const char* kMqttTlsKey = "tls"; +constexpr const char* kMqttClientIdKey = "client_id"; +constexpr const char* kMqttCleanSessionKey = "clean_session"; +constexpr const char* kMqttQosKey = "qos"; +std::string storedString(const char* key, const char* fallback) +{ + std::string value{}; + if (::platform::ui::settings_store::get_string( + kMqttSettingsNamespace, key, value)) + { + return value; + } + return fallback ? std::string(fallback) : std::string(); +} + +LinuxMqttUiSettings loadMqttSettings() +{ + LinuxMqttUiSettings settings{}; + settings.enabled = ::platform::ui::settings_store::get_bool( + kMqttSettingsNamespace, kMqttEnabledKey, settings.enabled); + settings.name = storedString(kMqttNameKey, settings.name.c_str()); + settings.host = storedString(kMqttHostKey, settings.host.c_str()); + settings.port = std::clamp(::platform::ui::settings_store::get_int( + kMqttSettingsNamespace, + kMqttPortKey, + settings.port), + 1, + 65535); + settings.username = + storedString(kMqttUsernameKey, settings.username.c_str()); + settings.password = + storedString(kMqttPasswordKey, settings.password.c_str()); + settings.topic = storedString(kMqttTopicKey, settings.topic.c_str()); + settings.tls = ::platform::ui::settings_store::get_bool( + kMqttSettingsNamespace, kMqttTlsKey, settings.tls); + settings.client_id = + storedString(kMqttClientIdKey, settings.client_id.c_str()); + settings.clean_session = ::platform::ui::settings_store::get_bool( + kMqttSettingsNamespace, + kMqttCleanSessionKey, + settings.clean_session); + settings.qos = std::clamp(::platform::ui::settings_store::get_int( + kMqttSettingsNamespace, + kMqttQosKey, + settings.qos), + 0, + 2); + return settings; +} + +void saveMqttSettings(const LinuxMqttUiSettings& settings) +{ + ::platform::ui::settings_store::put_bool( + kMqttSettingsNamespace, kMqttEnabledKey, settings.enabled); + ::platform::ui::settings_store::put_string( + kMqttSettingsNamespace, kMqttNameKey, settings.name.c_str()); + ::platform::ui::settings_store::put_string( + kMqttSettingsNamespace, kMqttHostKey, settings.host.c_str()); + ::platform::ui::settings_store::put_int( + kMqttSettingsNamespace, kMqttPortKey, + std::clamp(settings.port, 1, 65535)); + ::platform::ui::settings_store::put_string( + kMqttSettingsNamespace, kMqttUsernameKey, settings.username.c_str()); + ::platform::ui::settings_store::put_string( + kMqttSettingsNamespace, kMqttPasswordKey, settings.password.c_str()); + ::platform::ui::settings_store::put_string( + kMqttSettingsNamespace, kMqttTopicKey, settings.topic.c_str()); + ::platform::ui::settings_store::put_bool( + kMqttSettingsNamespace, kMqttTlsKey, settings.tls); + ::platform::ui::settings_store::put_string( + kMqttSettingsNamespace, kMqttClientIdKey, settings.client_id.c_str()); + ::platform::ui::settings_store::put_bool( + kMqttSettingsNamespace, + kMqttCleanSessionKey, + settings.clean_session); + ::platform::ui::settings_store::put_int( + kMqttSettingsNamespace, kMqttQosKey, std::clamp(settings.qos, 0, 2)); +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_mqtt_settings.h b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_mqtt_settings.h new file mode 100644 index 00000000..2cddc470 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_mqtt_settings.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace trailmate::uconsole::gtk +{ + +struct LinuxMqttUiSettings +{ + bool enabled = false; + std::string name = "Meshtastic CN"; + std::string host = "mqtt.mess.host"; + int port = 1883; + std::string username = "meshdev"; + std::string password = "large4cats"; + std::string topic = "msh/CN/#"; + bool tls = false; + std::string client_id{}; + bool clean_session = false; + int qos = 1; +}; + +LinuxMqttUiSettings loadMqttSettings(); +void saveMqttSettings(const LinuxMqttUiSettings& settings); + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_overview_layout.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_overview_layout.cpp new file mode 100644 index 00000000..e99f11e4 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_overview_layout.cpp @@ -0,0 +1,181 @@ +#include "platform/gtk/gtk_uconsole_layout_spec.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* launchOverviewLayout(GtkUConsoleAppState& state) +{ + GtkWidget* root = makeWorkbench(GTK_ORIENTATION_HORIZONTAL, 8); + state.hardware_box = nullptr; + + state.overview_location_panel = makePanel(); + gtk_widget_add_css_class(state.overview_location_panel, "pane-primary"); + gtk_widget_set_hexpand(state.overview_location_panel, FALSE); + gtk_widget_set_size_request(state.overview_location_panel, + layout_spec::kOverviewGpsRailWidth, + -1); + gtk_widget_set_vexpand(state.overview_location_panel, TRUE); + state.overview_location_state = makeLabel("", "summary-title"); + state.overview_location_coordinates = makeLabel("", "row-title"); + state.overview_location_detail = makeLabel("", "summary-detail", true); + + state.overview_location_map = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(state.overview_location_map, "location-map"); + gtk_widget_set_size_request(state.overview_location_map, + layout_spec::kOverviewLocationMapWidth, + layout_spec::kOverviewLocationMapHeight); + gtk_widget_set_hexpand(state.overview_location_map, FALSE); + gtk_widget_set_halign(state.overview_location_map, GTK_ALIGN_CENTER); + gtk_box_append(GTK_BOX(state.overview_location_panel), + state.overview_location_map); + state.overview_location_map_meta = makeLabel("", "row-meta", true); + + state.overview_gnss_skyplot = gtk_drawing_area_new(); + gtk_widget_add_css_class(state.overview_gnss_skyplot, "gnss-skyplot"); + gtk_widget_set_size_request(state.overview_gnss_skyplot, + layout_spec::kOverviewSkyplotWidth, + layout_spec::kOverviewSkyplotHeight); + gtk_widget_set_hexpand(state.overview_gnss_skyplot, FALSE); + gtk_widget_set_halign(state.overview_gnss_skyplot, GTK_ALIGN_CENTER); + gtk_drawing_area_set_draw_func( + GTK_DRAWING_AREA(state.overview_gnss_skyplot), + drawOverviewGnssSkyplot, + &state, + nullptr); + gtk_box_append(GTK_BOX(state.overview_location_panel), + state.overview_gnss_skyplot); + + state.overview_satellite_list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 3); + gtk_widget_add_css_class(state.overview_satellite_list, + "gnss-satellite-list"); + GtkWidget* sat_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(sat_scroll), + state.overview_satellite_list); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(sat_scroll), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_size_request(sat_scroll, + layout_spec::kOverviewSatelliteListWidth, + layout_spec::kOverviewSatelliteListHeight); + gtk_widget_set_hexpand(sat_scroll, FALSE); + gtk_widget_set_halign(sat_scroll, GTK_ALIGN_CENTER); + gtk_widget_set_vexpand(sat_scroll, TRUE); + gtk_box_append(GTK_BOX(state.overview_location_panel), sat_scroll); + gtk_box_append(GTK_BOX(root), state.overview_location_panel); + + GtkWidget* center = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_hexpand(center, TRUE); + gtk_widget_set_vexpand(center, TRUE); + gtk_box_append(GTK_BOX(root), center); + + state.overview_messages_panel = makePanel(); + gtk_widget_add_css_class(state.overview_messages_panel, "overview-summary-panel"); + gtk_widget_set_vexpand(state.overview_messages_panel, FALSE); + gtk_box_append(GTK_BOX(state.overview_messages_panel), + makeLabel("Messages", "overview-panel-title")); + state.overview_messages_title = makeLabel("", "summary-title"); + state.overview_messages_detail = makeLabel("", "summary-detail", true); + state.overview_messages_latest = makeLabel("", nullptr, true); + gtk_box_append(GTK_BOX(state.overview_messages_panel), + state.overview_messages_title); + gtk_box_append(GTK_BOX(state.overview_messages_panel), + state.overview_messages_detail); + gtk_box_append(GTK_BOX(state.overview_messages_panel), + state.overview_messages_latest); + state.team_summary = makeLabel("", "summary-detail", true); + gtk_box_append(GTK_BOX(state.overview_messages_panel), state.team_summary); + gtk_box_append(GTK_BOX(center), state.overview_messages_panel); + + GtkWidget* recent_panel = makePanel(); + gtk_widget_add_css_class(recent_panel, "overview-recent-panel"); + gtk_widget_set_vexpand(recent_panel, TRUE); + gtk_box_append(GTK_BOX(recent_panel), + makeLabel("Recent contacts", "overview-panel-title")); + state.overview_conversations = gtk_box_new(GTK_ORIENTATION_VERTICAL, 7); + gtk_widget_add_css_class(state.overview_conversations, "recent-contact-list"); + gtk_widget_set_vexpand(state.overview_conversations, TRUE); + GtkWidget* recent_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(recent_scroll), + state.overview_conversations); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(recent_scroll), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_vexpand(recent_scroll, TRUE); + gtk_box_append(GTK_BOX(recent_panel), recent_scroll); + gtk_box_append(GTK_BOX(center), recent_panel); + + GtkWidget* timeline_panel = makePanel(); + gtk_widget_add_css_class(timeline_panel, "overview-timeline-panel"); + gtk_widget_set_hexpand(timeline_panel, FALSE); + gtk_widget_set_size_request(timeline_panel, + layout_spec::kOverviewTimelineRailWidth, + -1); + gtk_widget_set_vexpand(timeline_panel, TRUE); + + GtkWidget* timeline_header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 7); + GtkWidget* timeline_title = makeLabel("Activity timeline", + "overview-panel-title"); + gtk_widget_set_hexpand(timeline_title, TRUE); + gtk_box_append(GTK_BOX(timeline_header), timeline_title); + state.overview_timeline_filter = gtk_combo_box_text_new(); + gtk_widget_add_css_class(state.overview_timeline_filter, + "timeline-filter"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.overview_timeline_filter), + "All"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.overview_timeline_filter), + "Messages"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.overview_timeline_filter), + "NodeInfo"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.overview_timeline_filter), + "Position"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.overview_timeline_filter), + "Telemetry"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.overview_timeline_filter), + "Team"); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(state.overview_timeline_filter), + "System"); + gtk_combo_box_set_active(GTK_COMBO_BOX(state.overview_timeline_filter), 0); + g_signal_connect(state.overview_timeline_filter, + "changed", + G_CALLBACK(onOverviewTimelineFilterChanged), + &state); + gtk_box_append(GTK_BOX(timeline_header), state.overview_timeline_filter); + gtk_box_append(GTK_BOX(timeline_panel), timeline_header); + + GtkWidget* timeline_tools = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* top_button = gtk_button_new_with_label("Newest"); + GtkWidget* bottom_button = gtk_button_new_with_label("Oldest"); + gtk_widget_add_css_class(top_button, "timeline-jump-button"); + gtk_widget_add_css_class(bottom_button, "timeline-jump-button"); + g_signal_connect(top_button, + "clicked", + G_CALLBACK(onOverviewTimelineTopClicked), + &state); + g_signal_connect(bottom_button, + "clicked", + G_CALLBACK(onOverviewTimelineBottomClicked), + &state); + gtk_box_append(GTK_BOX(timeline_tools), top_button); + gtk_box_append(GTK_BOX(timeline_tools), bottom_button); + gtk_box_append(GTK_BOX(timeline_panel), timeline_tools); + + state.overview_timeline_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 7); + gtk_widget_add_css_class(state.overview_timeline_box, "timeline-list"); + gtk_widget_set_vexpand(state.overview_timeline_box, TRUE); + state.overview_timeline_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child( + GTK_SCROLLED_WINDOW(state.overview_timeline_scroll), + state.overview_timeline_box); + gtk_scrolled_window_set_policy( + GTK_SCROLLED_WINDOW(state.overview_timeline_scroll), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_vexpand(state.overview_timeline_scroll, TRUE); + gtk_box_append(GTK_BOX(timeline_panel), state.overview_timeline_scroll); + gtk_box_append(GTK_BOX(root), timeline_panel); + return root; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_overview_logic.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_overview_logic.cpp new file mode 100644 index 00000000..1ee99d47 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_overview_logic.cpp @@ -0,0 +1,565 @@ +#include "platform/gtk/gtk_uconsole_layout_spec.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_shell.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "gps/usecase/gnss_skyplot_presenter.h" +#include "platform/ui/gps_runtime.h" + +namespace trailmate::uconsole::gtk +{ +namespace +{ + +constexpr double kPi = 3.14159265358979323846; + +void setCairoRgb(cairo_t* cr, double r, double g, double b) +{ + cairo_set_source_rgb(cr, r, g, b); +} + +void setSignalColor(cairo_t* cr, ::gps::GnssSignalState state) +{ + switch (state) + { + case ::gps::GnssSignalState::Good: + setCairoRgb(cr, 0.16, 0.47, 0.33); + break; + case ::gps::GnssSignalState::Fair: + setCairoRgb(cr, 0.77, 0.53, 0.14); + break; + case ::gps::GnssSignalState::Weak: + setCairoRgb(cr, 0.75, 0.29, 0.18); + break; + case ::gps::GnssSignalState::NotUsed: + setCairoRgb(cr, 0.45, 0.50, 0.48); + break; + case ::gps::GnssSignalState::InView: + default: + setCairoRgb(cr, 0.35, 0.43, 0.40); + break; + } +} + +} // namespace + +void refreshLocationMiniMap(GtkUConsoleAppState& state, + const MapWorkspaceSnapshot& map_snapshot) +{ + clearBox(state.overview_location_map); + if (!map_snapshot.has_center) + { + gtk_box_append(GTK_BOX(state.overview_location_map), + makeLabel("No map center", "row-title")); + return; + } + + const MapTileItem* center_tile = nullptr; + if (map_snapshot.center_tile_index < map_snapshot.tiles.size()) + { + center_tile = &map_snapshot.tiles[map_snapshot.center_tile_index]; + } + + if (center_tile != nullptr && center_tile->available) + { + GtkWidget* picture = + gtk_picture_new_for_filename(center_tile->path.string().c_str()); + gtk_widget_add_css_class(picture, "location-picture"); + gtk_picture_set_content_fit(GTK_PICTURE(picture), + GTK_CONTENT_FIT_COVER); + gtk_widget_set_size_request(picture, + layout_spec::kOverviewLocationMapWidth, + layout_spec::kOverviewLocationPictureHeight); + gtk_widget_set_hexpand(picture, FALSE); + gtk_widget_set_halign(picture, GTK_ALIGN_CENTER); + gtk_widget_set_vexpand(picture, TRUE); + gtk_box_append(GTK_BOX(state.overview_location_map), picture); + } + else + { + gtk_box_append(GTK_BOX(state.overview_location_map), + makeLabel("Tile pending", "row-title")); + gtk_box_append(GTK_BOX(state.overview_location_map), + makeLabel("Center tile is not cached yet.", + "row-meta", + true)); + } + + if (center_tile != nullptr) + { + const std::string tile_meta = + "z" + std::to_string(center_tile->id.z) + " " + + std::to_string(center_tile->id.x) + "/" + + std::to_string(center_tile->id.y); + gtk_box_append(GTK_BOX(state.overview_location_map), + makeLabel(tile_meta.c_str(), "row-meta")); + } +} + +void drawOverviewGnssSkyplot(GtkDrawingArea*, + cairo_t* cr, + int width, + int height, + gpointer data) +{ + auto* state = static_cast(data); + if (state == nullptr || cr == nullptr || width <= 0 || height <= 0) + { + return; + } + + const double w = static_cast(width); + const double h = static_cast(height); + const double cx = w * 0.5; + const double cy = h * 0.52; + const double radius = std::max(8.0, std::min(w, h) * 0.42); + + cairo_save(cr); + setCairoRgb(cr, 0.97, 0.98, 0.96); + cairo_paint(cr); + + cairo_set_line_width(cr, 1.0); + setCairoRgb(cr, 0.73, 0.78, 0.73); + for (int ring = 1; ring <= 3; ++ring) + { + cairo_arc(cr, cx, cy, radius * static_cast(ring) / 3.0, + 0.0, 2.0 * kPi); + cairo_stroke(cr); + } + cairo_move_to(cr, cx - radius, cy); + cairo_line_to(cr, cx + radius, cy); + cairo_move_to(cr, cx, cy - radius); + cairo_line_to(cr, cx, cy + radius); + cairo_stroke(cr); + + cairo_select_font_face(cr, + "Sans", + CAIRO_FONT_SLANT_NORMAL, + CAIRO_FONT_WEIGHT_BOLD); + cairo_set_font_size(cr, 9.0); + + for (const auto& sat : state->overview_gnss_view.satellites) + { + const double az = (static_cast(sat.azimuth) - 90.0) * + kPi / 180.0; + const double normalized_r = + std::clamp((90.0 - static_cast(sat.elevation)) / 90.0, + 0.0, + 1.0); + const double x = cx + std::cos(az) * radius * normalized_r; + const double y = cy + std::sin(az) * radius * normalized_r; + const double dot = sat.used ? 5.5 : 4.5; + + setSignalColor(cr, sat.signal); + cairo_arc(cr, x, y, dot, 0.0, 2.0 * kPi); + cairo_fill_preserve(cr); + setCairoRgb(cr, 0.12, 0.16, 0.14); + cairo_stroke(cr); + + char label[8] = {}; + std::snprintf(label, sizeof(label), "%u", + static_cast(sat.id)); + cairo_text_extents_t extents{}; + cairo_text_extents(cr, label, &extents); + setCairoRgb(cr, 0.12, 0.16, 0.14); + cairo_move_to(cr, x - extents.width * 0.5, y - dot - 3.0); + cairo_show_text(cr, label); + } + + cairo_set_font_size(cr, 10.0); + const auto& status = state->overview_gnss_view.status; + std::string caption = + std::string(::gps::gnss_fix_label(status.fix)) + " / use " + + std::to_string(status.sats_in_use) + " / view " + + std::to_string(status.sats_in_view); + if (status.hdop > 0.0F) + { + char hdop[24] = {}; + std::snprintf(hdop, + sizeof(hdop), + " / HDOP %.1f", + static_cast(status.hdop)); + caption += hdop; + } + setCairoRgb(cr, 0.24, 0.29, 0.26); + cairo_move_to(cr, 8.0, h - 8.0); + cairo_show_text(cr, caption.c_str()); + cairo_restore(cr); +} + +GtkWidget* buildSatelliteRow(const ::gps::GnssSkyplotSatellite& sat) +{ + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_add_css_class(row, "gnss-sat-row"); + gtk_widget_add_css_class(row, sat.used ? "gnss-sat-used" + : "gnss-sat-unused"); + switch (sat.signal) + { + case ::gps::GnssSignalState::Good: + gtk_widget_add_css_class(row, "gnss-signal-good"); + break; + case ::gps::GnssSignalState::Fair: + gtk_widget_add_css_class(row, "gnss-signal-fair"); + break; + case ::gps::GnssSignalState::Weak: + gtk_widget_add_css_class(row, "gnss-signal-weak"); + break; + case ::gps::GnssSignalState::NotUsed: + case ::gps::GnssSignalState::InView: + default: + gtk_widget_add_css_class(row, "gnss-signal-idle"); + break; + } + + char id[32] = {}; + std::snprintf(id, + sizeof(id), + "%s %u", + ::gps::gnss_system_label(sat.system), + static_cast(sat.id)); + char facts[96] = {}; + std::snprintf(facts, + sizeof(facts), + "%s / SNR %d / el %u / az %u", + sat.used ? "used" : "view", + static_cast(sat.snr), + static_cast(sat.elevation), + static_cast(sat.azimuth)); + GtkWidget* title = makeLabel(id, "gnss-sat-title"); + GtkWidget* meta = makeLabel(facts, "gnss-sat-meta", true); + gtk_label_set_max_width_chars(GTK_LABEL(meta), 18); + gtk_box_append(GTK_BOX(row), title); + gtk_box_append(GTK_BOX(row), meta); + return row; +} + +void refreshOverviewGnss(GtkUConsoleAppState& state) +{ + std::array<::gps::GnssSatInfo, ::gps::kMaxGnssSats> sats{}; + std::size_t sat_count = 0; + ::gps::GnssStatus status{}; + const bool has_snapshot = ::platform::ui::gps::get_gnss_snapshot( + sats.data(), sats.size(), &sat_count, &status); + const auto gps_state = ::platform::ui::gps::get_data(); + state.overview_gnss_view = ::gps::build_gnss_skyplot_view(sats.data(), + sat_count, + status, + gps_state, + has_snapshot, + 18U); + if (state.overview_gnss_skyplot != nullptr) + { + gtk_widget_queue_draw(state.overview_gnss_skyplot); + } + + clearBox(state.overview_satellite_list); + if (!has_snapshot || state.overview_gnss_view.satellites.empty()) + { + gtk_box_append(GTK_BOX(state.overview_satellite_list), + makeLabel("No satellite data.", "empty-state")); + return; + } + for (const auto& sat : state.overview_gnss_view.satellites) + { + gtk_box_append(GTK_BOX(state.overview_satellite_list), + buildSatelliteRow(sat)); + } +} + +const char* timelineFilterKind(int index) +{ + switch (index) + { + case 1: + return "message"; + case 2: + return "node"; + case 3: + return "position"; + case 4: + return "telemetry"; + case 5: + return "team"; + case 6: + return "system"; + default: + return ""; + } +} + +bool timelineItemVisible(const OverviewTimelineItem& item, int filter_index) +{ + if (filter_index <= 0) + { + return true; + } + const std::string filter = timelineFilterKind(filter_index); + if (filter == "team") + { + return item.team || item.kind == "team"; + } + return item.kind == filter; +} + +GtkWidget* makeTimelineBadge(const char* text, const char* css_class) +{ + GtkWidget* badge = makeLabel(text, css_class); + gtk_label_set_xalign(GTK_LABEL(badge), 0.5F); + return badge; +} + +GtkWidget* buildOverviewTimelineRow(const OverviewTimelineItem& item) +{ + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 3); + gtk_widget_add_css_class(row, "timeline-row"); + gtk_widget_add_css_class(row, + item.team ? "timeline-row-team" + : "timeline-row-mesh"); + gtk_widget_add_css_class(row, + item.direct ? "timeline-row-direct" + : "timeline-row-broadcast"); + if (item.outgoing) + { + gtk_widget_add_css_class(row, "timeline-row-outgoing"); + } + const std::string kind_class = "timeline-kind-" + item.kind; + gtk_widget_add_css_class(row, kind_class.c_str()); + if (item.attention) + { + gtk_widget_add_css_class(row, "timeline-row-alert"); + } + gtk_widget_set_hexpand(row, TRUE); + + GtkWidget* top = gtk_flow_box_new(); + gtk_flow_box_set_selection_mode(GTK_FLOW_BOX(top), GTK_SELECTION_NONE); + gtk_flow_box_set_max_children_per_line(GTK_FLOW_BOX(top), 3); + gtk_flow_box_set_row_spacing(GTK_FLOW_BOX(top), 3); + gtk_flow_box_set_column_spacing(GTK_FLOW_BOX(top), 4); + gtk_flow_box_append(GTK_FLOW_BOX(top), + makeTimelineBadge(item.time_label.c_str(), + "timeline-time")); + gtk_flow_box_append(GTK_FLOW_BOX(top), + makeTimelineBadge(item.team ? "Team" : "Mesh", + item.team + ? "timeline-badge-team" + : "timeline-badge-mesh")); + gtk_flow_box_append(GTK_FLOW_BOX(top), + makeTimelineBadge(item.direct ? "Direct" : "Cast", + item.direct + ? "timeline-badge-direct" + : "timeline-badge-broadcast")); + if (!item.badge.empty()) + { + gtk_flow_box_append(GTK_FLOW_BOX(top), + makeTimelineBadge(item.badge.c_str(), + "timeline-badge-kind")); + } + gtk_box_append(GTK_BOX(row), top); + + gtk_box_append(GTK_BOX(row), + makeLabel(item.title.c_str(), "row-title", true)); + gtk_box_append(GTK_BOX(row), + makeLabel(item.detail.c_str(), "row-meta", true)); + return row; +} + +GtkWidget* buildRecentContactRow(GtkUConsoleAppState& state, + const RecentContactPreview& item) +{ + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(row, "recent-contact-row"); + if (item.has_unread) + { + gtk_widget_add_css_class(row, "recent-contact-unread"); + } + gtk_widget_set_hexpand(row, TRUE); + + GtkWidget* title_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* title = makeLabel(item.name.c_str(), "row-title", true); + gtk_widget_set_hexpand(title, TRUE); + gtk_box_append(GTK_BOX(title_row), title); + gtk_box_append(GTK_BOX(title_row), + makeTimelineBadge(item.badge.c_str(), + item.team ? "timeline-badge-team" + : (item.direct + ? "timeline-badge-direct" + : "timeline-badge-broadcast"))); + GtkWidget* button = gtk_button_new_with_label("Chat"); + gtk_widget_add_css_class(button, "recent-contact-chat"); + auto* conversation = new ::chat::ConversationId(item.conversation); + g_object_set_data_full(G_OBJECT(button), + "trailmate-conversation", + conversation, + [](gpointer pointer) + { + delete static_cast<::chat::ConversationId*>( + pointer); + }); + g_signal_connect(button, + "clicked", + G_CALLBACK(onOverviewRecentContactClicked), + &state); + gtk_box_append(GTK_BOX(title_row), button); + gtk_box_append(GTK_BOX(row), title_row); + gtk_box_append(GTK_BOX(row), + makeLabel(item.meta.c_str(), "row-meta", true)); + gtk_box_append(GTK_BOX(row), + makeLabel(item.detail.c_str(), "summary-detail", true)); + return row; +} + +static void refreshOverview(GtkUConsoleAppState& state, + const UConsoleDashboardSnapshot& snapshot, + const MapWorkspaceSnapshot& map_snapshot) +{ + setAttentionClass(state.overview_location_panel, + snapshot.location.attention); + setAttentionClass(state.overview_messages_panel, + snapshot.messages.attention); + setLabel(state.overview_location_state, snapshot.location.state); + setLabel(state.overview_location_coordinates, + snapshot.location.coordinates); + setLabel(state.overview_location_detail, snapshot.location.detail); + const std::string map_meta = + map_snapshot.source_label + " / z" + std::to_string(map_snapshot.zoom) + + " / " + std::to_string(map_snapshot.cache_stats.cached_tiles) + + " cached tiles"; + setLabel(state.overview_location_map_meta, + map_meta.empty() ? snapshot.location.map_meta : map_meta); + refreshLocationMiniMap(state, map_snapshot); + refreshOverviewGnss(state); + + setLabel(state.overview_messages_title, snapshot.messages.title); + setLabel(state.overview_messages_detail, snapshot.messages.detail); + setLabel(state.overview_messages_latest, snapshot.messages.latest); + setLabel(state.team_summary, snapshot.team_summary); + + clearBox(state.overview_conversations); + if (snapshot.recent_contacts.empty()) + { + gtk_box_append(GTK_BOX(state.overview_conversations), + makeLabel("No recent contacts yet.", "empty-state")); + } + for (const auto& item : snapshot.recent_contacts) + { + gtk_box_append(GTK_BOX(state.overview_conversations), + buildRecentContactRow(state, item)); + } + + clearBox(state.overview_timeline_box); + std::size_t visible_count = 0; + for (const auto& item : snapshot.timeline) + { + if (!timelineItemVisible(item, state.overview_timeline_filter_index)) + { + continue; + } + gtk_box_append(GTK_BOX(state.overview_timeline_box), + buildOverviewTimelineRow(item)); + ++visible_count; + } + if (visible_count == 0U) + { + gtk_box_append(GTK_BOX(state.overview_timeline_box), + makeLabel("No activity matches this filter.", + "empty-state")); + } +} + +static void refreshCapabilities(GtkUConsoleAppState& state, + const UConsoleDashboardSnapshot& snapshot) +{ + if (state.capability_box == nullptr) + { + return; + } + clearBox(state.capability_box); + for (const auto& line : snapshot.capability_lines) + { + gtk_box_append(GTK_BOX(state.capability_box), + makeLabel(line.c_str(), "row-meta", true)); + } +} + +void onOverviewRecentContactClicked(GtkButton* button, gpointer data) +{ + auto& state = *static_cast(data); + auto* conversation = static_cast<::chat::ConversationId*>( + g_object_get_data(G_OBJECT(button), "trailmate-conversation")); + if (conversation == nullptr) + { + return; + } + state.chat_model.selectConversation(*conversation); + showPage(state, "chat"); +} + +void onOverviewTimelineFilterChanged(GtkComboBox* combo, gpointer data) +{ + auto& state = *static_cast(data); + state.overview_timeline_filter_index = gtk_combo_box_get_active(combo); + refreshUi(state); +} + +void onOverviewTimelineTopClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (state.overview_timeline_scroll == nullptr) + { + return; + } + GtkAdjustment* adjustment = gtk_scrolled_window_get_vadjustment( + GTK_SCROLLED_WINDOW(state.overview_timeline_scroll)); + if (adjustment != nullptr) + { + gtk_adjustment_set_value(adjustment, + gtk_adjustment_get_lower(adjustment)); + } +} + +void onOverviewTimelineBottomClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + if (state.overview_timeline_scroll == nullptr) + { + return; + } + GtkAdjustment* adjustment = gtk_scrolled_window_get_vadjustment( + GTK_SCROLLED_WINDOW(state.overview_timeline_scroll)); + if (adjustment != nullptr) + { + const double bottom = std::max( + gtk_adjustment_get_lower(adjustment), + gtk_adjustment_get_upper(adjustment) - + gtk_adjustment_get_page_size(adjustment)); + gtk_adjustment_set_value(adjustment, bottom); + } +} + +void refreshOverviewLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + refreshOverview(state, snapshot.dashboard, snapshot.map); + refreshCapabilities(state, snapshot.dashboard); +} + +GtkUConsolePageLifecycle makeOverviewPageLifecycle() +{ + return {.name = "overview", + .title = "Overview", + .onLaunch = launchOverviewLayout, + .onShow = nullptr, + .onHide = nullptr, + .onRefresh = refreshOverviewLogic, + .onDestroy = nullptr}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_pages.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_pages.cpp new file mode 100644 index 00000000..9d690fe2 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_pages.cpp @@ -0,0 +1,17 @@ +#include "platform/gtk/gtk_uconsole_pages.h" + +namespace trailmate::uconsole::gtk +{ + +std::vector buildGtkUConsolePageRegistry() +{ + return {makeOverviewPageLifecycle(), + makeChatPageLifecycle(), + makeMapPageLifecycle(), + makeHardwarePageLifecycle(), + makeDataPageLifecycle(), + makeLogsPageLifecycle(), + makeSettingsPageLifecycle()}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_pages.h b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_pages.h new file mode 100644 index 00000000..0c4da9ab --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_pages.h @@ -0,0 +1,136 @@ +#pragma once + +#include + +#include + +#include "platform/gtk/gtk_uconsole_app_state.h" + +namespace trailmate::uconsole::gtk +{ + +std::vector buildGtkUConsolePageRegistry(); + +GtkUConsolePageLifecycle makeOverviewPageLifecycle(); +GtkWidget* launchOverviewLayout(GtkUConsoleAppState& state); +void refreshOverviewLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot); +void drawOverviewGnssSkyplot(GtkDrawingArea* area, + cairo_t* cr, + int width, + int height, + gpointer data); +void onOverviewRecentContactClicked(GtkButton*, gpointer data); +void onOverviewTimelineFilterChanged(GtkComboBox*, gpointer data); +void onOverviewTimelineTopClicked(GtkButton*, gpointer data); +void onOverviewTimelineBottomClicked(GtkButton*, gpointer data); + +GtkUConsolePageLifecycle makeChatPageLifecycle(); +GtkWidget* launchChatLayout(GtkUConsoleAppState& state); +void refreshChatLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot); +void onConversationActivated(GtkListBox*, GtkListBoxRow* row, gpointer data); +void onConversationButtonClicked(GtkButton* button, gpointer data); +void onChatSortChanged(GtkComboBox*, gpointer data); +void onChatGroupExpandedChanged(GObject* object, GParamSpec*, gpointer data); +void onSendClicked(GtkButton*, gpointer data); +void onChatEntryActivate(GtkEntry*, gpointer data); +void onChatAddContactClicked(GtkButton*, gpointer data); +void onChatRequestNodeInfoClicked(GtkButton*, gpointer data); +void onChatSendPositionClicked(GtkButton*, gpointer data); +void onChatSendPoiClicked(GtkButton*, gpointer data); +void onChatNodeChatClicked(GtkButton*, gpointer data); +void onChatNodeAddClicked(GtkButton*, gpointer data); +void onChatNodeInfoClicked(GtkButton*, gpointer data); +void onChatNodeIgnoreClicked(GtkButton*, gpointer data); +void onChatNodeExchangeUserInfoClicked(GtkButton*, gpointer data); +void onChatNodeVerifyKeyClicked(GtkButton*, gpointer data); + +GtkUConsolePageLifecycle makeMapPageLifecycle(); +GtkWidget* launchMapLayout(GtkUConsoleAppState& state); +void refreshMapLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot); +void refreshMap(GtkUConsoleAppState& state); +void refreshMap(GtkUConsoleAppState& state, const MapWorkspaceSnapshot& snapshot); +void onMapSourceClicked(GtkButton* button, gpointer data); +void onMapZoomInClicked(GtkButton*, gpointer data); +void onMapZoomOutClicked(GtkButton*, gpointer data); +void onMapRecenterClicked(GtkButton*, gpointer data); +void onMapContextCenterClicked(GtkButton*, gpointer data); +void onMapContextZoomInClicked(GtkButton*, gpointer data); +void onMapContextZoomOutClicked(GtkButton*, gpointer data); +void onMapContextMeasureStartClicked(GtkButton*, gpointer data); +void onMapContextMeasureEndClicked(GtkButton*, gpointer data); +void onMapContextPressed(GtkGestureClick* gesture, + int, + double x, + double y, + gpointer data); +void onMapPrimaryPressed(GtkGestureClick* gesture, + int, + double x, + double y, + gpointer data); +void onMapDragBegin(GtkGestureDrag*, double, double, gpointer data); +void onMapDragUpdate(GtkGestureDrag*, + double offset_x, + double offset_y, + gpointer data); +void onMapDragEnd(GtkGestureDrag*, + double offset_x, + double offset_y, + gpointer data); +void onMapMqttNodesToggled(GObject*, GParamSpec*, gpointer data); +void onMapContourVisibleToggled(GObject*, GParamSpec*, gpointer data); +void onMapContourFillClicked(GtkButton*, gpointer data); +void onMapMeasureClicked(GtkButton*, gpointer data); +void onMapMeasureClearClicked(GtkButton*, gpointer data); + +GtkUConsolePageLifecycle makeHardwarePageLifecycle(); +GtkWidget* launchHardwareLayout(GtkUConsoleAppState& state); +void refreshHardwareLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot); + +GtkUConsolePageLifecycle makeDataPageLifecycle(); +GtkWidget* launchDataLayout(GtkUConsoleAppState& state); +void refreshDataLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot); + +GtkUConsolePageLifecycle makeLogsPageLifecycle(); +GtkWidget* launchLogsLayout(GtkUConsoleAppState& state); +void refreshLogsLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot); +void onLogsSourceGpsClicked(GtkButton*, gpointer data); +void onLogsSourceLoraClicked(GtkButton*, gpointer data); +void onLogsSourceMqttClicked(GtkButton*, gpointer data); + +GtkUConsolePageLifecycle makeSettingsPageLifecycle(); +GtkWidget* launchSettingsLayout(GtkUConsoleAppState& state); +void refreshSettingsLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot); +int protocolIndex(::chat::MeshProtocol protocol); +std::vector meshtasticRegionLabels(); +int meshtasticRegionIndex(std::uint8_t region_code); +std::uint8_t meshtasticRegionFromIndex(int index); +std::vector meshtasticPresetLabels(); +int meshtasticPresetIndex(std::uint8_t preset_value); +std::uint8_t meshtasticPresetFromIndex(int index); +float displayFrequencyMhz(const ::chat::MeshConfig& mesh); +float displayBandwidthKHz(const ::chat::MeshConfig& mesh, + ::chat::MeshProtocol protocol); +std::uint8_t displaySpreadFactor(const ::chat::MeshConfig& mesh, + ::chat::MeshProtocol protocol); +std::uint8_t displayCodingRate(const ::chat::MeshConfig& mesh, + ::chat::MeshProtocol protocol); +int meshtasticRegionTxPowerLimitDbm(std::uint8_t region_code); +int displayTxPowerDbm(const ::chat::MeshConfig& mesh); +void setMeshtasticTxPowerControl(GtkWidget* control, + const ::chat::MeshConfig& mesh); +void showSettingsNotice(GtkUConsoleAppState& state, const char* text); +void populateSettingsControls(GtkUConsoleAppState& state); +void onSettingsProtocolChanged(GtkComboBox*, gpointer data); +void onSettingsMeshtasticRegionChanged(GtkComboBox*, gpointer data); +void onSettingsApplyClicked(GtkButton*, gpointer data); +void onSettingsReloadClicked(GtkButton*, gpointer data); + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_settings_layout.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_settings_layout.cpp new file mode 100644 index 00000000..963629a9 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_settings_layout.cpp @@ -0,0 +1,672 @@ +#include "platform/gtk/gtk_uconsole_mqtt_settings.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include +#include + +#include "chat/infra/mesh_protocol_utils.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* makeSettingsSection(const char* title) +{ + GtkWidget* section = makePanel(); + gtk_widget_add_css_class(section, "settings-section"); + gtk_widget_set_vexpand(section, TRUE); + gtk_box_append(GTK_BOX(section), makeLabel(title, "row-title")); + return section; +} + +GtkWidget* makeSettingsRow(const char* title, + const char* detail, + GtkWidget* control) +{ + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_widget_add_css_class(row, "settings-row"); + gtk_widget_set_hexpand(row, TRUE); + + GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_set_hexpand(text, TRUE); + gtk_box_append(GTK_BOX(text), makeLabel(title, "row-title")); + if (detail != nullptr && detail[0] != '\0') + { + gtk_box_append(GTK_BOX(text), makeLabel(detail, "row-meta", true)); + } + gtk_box_append(GTK_BOX(row), text); + + if (control != nullptr) + { + if (GTK_IS_SWITCH(control)) + { + gtk_widget_add_css_class(control, "settings-switch"); + gtk_widget_set_halign(control, GTK_ALIGN_END); + gtk_widget_set_size_request(control, 48, -1); + } + else + { + gtk_widget_add_css_class(control, "settings-control"); + } + gtk_widget_set_valign(control, GTK_ALIGN_CENTER); + gtk_box_append(GTK_BOX(row), control); + } + return row; +} + +GtkWidget* makeCombo(const std::vector& labels, int active) +{ + GtkWidget* combo = gtk_combo_box_text_new(); + for (const char* label : labels) + { + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), label); + } + gtk_combo_box_set_active(GTK_COMBO_BOX(combo), active); + return combo; +} + +GtkWidget* makeSpin(double min, double max, double step, double value) +{ + GtkWidget* spin = gtk_spin_button_new_with_range(min, max, step); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(spin), value); + return spin; +} + +GtkWidget* makeSwitch(bool active) +{ + GtkWidget* sw = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(sw), active); + gtk_widget_add_css_class(sw, "settings-switch"); + gtk_widget_set_halign(sw, GTK_ALIGN_END); + gtk_widget_set_size_request(sw, 48, -1); + return sw; +} + +GtkWidget* wrapSettingsGroup(GtkWidget* section) +{ + GtkWidget* scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), section); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), + GTK_POLICY_AUTOMATIC, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_hexpand(scroll, TRUE); + gtk_widget_set_vexpand(scroll, TRUE); + return scroll; +} + +GtkWidget* addSettingsGroup(GtkWidget* stack, + const char* name, + const char* title, + GtkWidget* section) +{ + GtkWidget* page = wrapSettingsGroup(section); + gtk_stack_add_titled(GTK_STACK(stack), + page, + name, + title); + return page; +} + +GtkWidget* launchSettingsLayout(GtkUConsoleAppState& state) +{ + GtkWidget* root = makeWorkbench(GTK_ORIENTATION_VERTICAL, 8); + + GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(actions, "settings-actions"); + state.settings_status = makeLabel("", "settings-status", true); + gtk_widget_set_hexpand(state.settings_status, TRUE); + gtk_box_append(GTK_BOX(actions), state.settings_status); + GtkWidget* reload = gtk_button_new_with_label("Reload"); + gtk_widget_add_css_class(reload, "nav-button"); + g_signal_connect(reload, "clicked", G_CALLBACK(onSettingsReloadClicked), + &state); + gtk_box_append(GTK_BOX(actions), reload); + GtkWidget* apply = gtk_button_new_with_label("Save"); + gtk_widget_add_css_class(apply, "send"); + g_signal_connect(apply, "clicked", G_CALLBACK(onSettingsApplyClicked), + &state); + gtk_box_append(GTK_BOX(actions), apply); + gtk_box_append(GTK_BOX(root), actions); + + GtkWidget* body = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(body, "settings-body"); + gtk_widget_set_hexpand(body, TRUE); + gtk_widget_set_vexpand(body, TRUE); + + state.settings_group_stack = gtk_stack_new(); + gtk_widget_set_hexpand(state.settings_group_stack, TRUE); + gtk_widget_set_vexpand(state.settings_group_stack, TRUE); + + GtkWidget* sidebar = gtk_stack_sidebar_new(); + gtk_stack_sidebar_set_stack(GTK_STACK_SIDEBAR(sidebar), + GTK_STACK(state.settings_group_stack)); + gtk_widget_add_css_class(sidebar, "settings-sidebar"); + gtk_widget_set_vexpand(sidebar, TRUE); + gtk_widget_set_size_request(sidebar, 160, -1); + gtk_box_append(GTK_BOX(body), sidebar); + gtk_box_append(GTK_BOX(body), state.settings_group_stack); + + state.settings_page_box = body; + + const auto& config = state.services.config(); + const auto& mesh = config.activeMeshConfig(); + const auto protocol = config.mesh_protocol; + const auto mqtt = loadMqttSettings(); + + GtkWidget* identity = makeSettingsSection("Identity"); + state.settings_node_name = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_node_name), + config.node_name); + gtk_box_append(GTK_BOX(identity), + makeSettingsRow("Node name", + "Broadcast name stored in local config.", + state.settings_node_name)); + state.settings_short_name = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_short_name), + config.short_name); + gtk_box_append(GTK_BOX(identity), + makeSettingsRow("Short name", + "Compact display name for status and packets.", + state.settings_short_name)); + addSettingsGroup(state.settings_group_stack, + "identity", + "Identity", + identity); + + GtkWidget* protocol_group = makeSettingsSection("Protocol"); + std::vector protocols{ + ::chat::infra::meshProtocolName(::chat::MeshProtocol::Meshtastic), + ::chat::infra::meshProtocolName(::chat::MeshProtocol::MeshCore), + ::chat::infra::meshProtocolName(::chat::MeshProtocol::RNode), + ::chat::infra::meshProtocolName(::chat::MeshProtocol::LXMF), + }; + state.settings_protocol = + makeCombo(protocols, protocolIndex(config.mesh_protocol)); + g_signal_connect(state.settings_protocol, + "changed", + G_CALLBACK(onSettingsProtocolChanged), + &state); + gtk_box_append(GTK_BOX(protocol_group), + makeSettingsRow("Protocol", + "Active mesh protocol used by chat transport.", + state.settings_protocol)); + addSettingsGroup(state.settings_group_stack, + "protocol", + "Protocol", + protocol_group); + + GtkWidget* meshtastic = makeSettingsSection("Meshtastic"); + state.settings_lora_region = + makeCombo(meshtasticRegionLabels(), + meshtasticRegionIndex(mesh.region)); + g_signal_connect(state.settings_lora_region, + "changed", + G_CALLBACK(onSettingsMeshtasticRegionChanged), + &state); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Region", + "Meshtastic legal radio region.", + state.settings_lora_region)); + state.settings_lora_use_preset = makeSwitch(mesh.use_preset); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Use modem preset", + "Uses modem preset instead of only manual LoRa parameters.", + state.settings_lora_use_preset)); + state.settings_lora_modem_preset = + makeCombo(meshtasticPresetLabels(), + meshtasticPresetIndex(mesh.modem_preset)); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Modem preset", + "Meshtastic speed/range preset.", + state.settings_lora_modem_preset)); + + GtkWidget* link = makeSettingsSection("LoRa link"); + state.settings_lora_freq = + makeSpin(137.0, 1020.0, 0.001, displayFrequencyMhz(mesh)); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(state.settings_lora_freq), 3); + gtk_box_append(GTK_BOX(link), + makeSettingsRow("Frequency MHz", + "SX1262 carrier frequency.", + state.settings_lora_freq)); + state.settings_lora_bw = + makeSpin(7.8, 500.0, 1.0, displayBandwidthKHz(mesh, protocol)); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(state.settings_lora_bw), 1); + gtk_box_append(GTK_BOX(link), + makeSettingsRow("Bandwidth kHz", + "LoRa bandwidth written into active mesh config.", + state.settings_lora_bw)); + state.settings_lora_sf = + makeSpin(5.0, 12.0, 1.0, displaySpreadFactor(mesh, protocol)); + gtk_box_append(GTK_BOX(link), + makeSettingsRow("Spread factor", + "LoRa SF, persisted with the active protocol.", + state.settings_lora_sf)); + state.settings_lora_cr = + makeSpin(5.0, 8.0, 1.0, displayCodingRate(mesh, protocol)); + gtk_box_append(GTK_BOX(link), + makeSettingsRow("Coding rate", + "LoRa coding rate denominator.", + state.settings_lora_cr)); + state.settings_lora_tx = makeSpin(::app::AppConfig::kTxPowerMinDbm, + ::app::AppConfig::kTxPowerMaxDbm, + 1.0, + mesh.tx_power); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("TX power dBm", + "Transmit power clamped to build target limits.", + state.settings_lora_tx)); + state.settings_hop_limit = makeSpin(1.0, 7.0, 1.0, mesh.hop_limit); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Hop limit", + "Maximum relay hop count for outgoing packets.", + state.settings_hop_limit)); + state.settings_tx_enabled = makeSwitch(mesh.tx_enabled); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Transmit", + "Controls whether the active mesh config allows TX.", + state.settings_tx_enabled)); + state.settings_lora_channel_num = + makeSpin(0.0, 65535.0, 1.0, mesh.channel_num); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Channel number", + "0 means automatic channel hash.", + state.settings_lora_channel_num)); + state.settings_lora_freq_offset = + makeSpin(-10.0, 10.0, 0.001, mesh.frequency_offset_mhz); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(state.settings_lora_freq_offset), + 3); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Frequency offset MHz", + "Fine offset applied to the selected channel.", + state.settings_lora_freq_offset)); + state.settings_lora_override_duty = + makeSwitch(mesh.override_duty_cycle); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Override duty cycle", + "Protocol-level duty-cycle override flag.", + state.settings_lora_override_duty)); + state.settings_lora_ignore_mqtt = makeSwitch(mesh.ignore_mqtt); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("Ignore MQTT packets", + "Drops packets marked as arriving through MQTT.", + state.settings_lora_ignore_mqtt)); + state.settings_lora_ok_to_mqtt = + makeSwitch(mesh.config_ok_to_mqtt); + gtk_box_append(GTK_BOX(meshtastic), + makeSettingsRow("OK to MQTT", + "Sets the ok_to_mqtt bit on outgoing packets.", + state.settings_lora_ok_to_mqtt)); + state.settings_meshtastic_page = + addSettingsGroup(state.settings_group_stack, + "meshtastic", + "Meshtastic", + meshtastic); + state.settings_link_page = + addSettingsGroup(state.settings_group_stack, "link", "LoRa link", link); + + GtkWidget* meshcore = makeSettingsSection("MeshCore"); + state.settings_meshcore_region_preset = + makeSpin(0.0, 255.0, 1.0, mesh.meshcore_region_preset); + gtk_box_append(GTK_BOX(meshcore), + makeSettingsRow("MeshCore region preset", + "0 keeps MeshCore in custom frequency mode.", + state.settings_meshcore_region_preset)); + state.settings_meshcore_channel_slot = + makeSpin(0.0, 255.0, 1.0, mesh.meshcore_channel_slot); + gtk_box_append(GTK_BOX(meshcore), + makeSettingsRow("MeshCore channel slot", + "MeshCore channel slot persisted per protocol.", + state.settings_meshcore_channel_slot)); + state.settings_meshcore_channel_name = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_meshcore_channel_name), + mesh.meshcore_channel_name); + gtk_box_append(GTK_BOX(meshcore), + makeSettingsRow("MeshCore channel name", + "MeshCore channel label.", + state.settings_meshcore_channel_name)); + state.settings_meshcore_client_repeat = + makeSwitch(mesh.meshcore_client_repeat); + gtk_box_append(GTK_BOX(meshcore), + makeSettingsRow("MeshCore client repeat", + "Client repeat flag for MeshCore.", + state.settings_meshcore_client_repeat)); + state.settings_meshcore_rx_delay = + makeSpin(0.0, 60.0, 0.1, mesh.meshcore_rx_delay_base); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(state.settings_meshcore_rx_delay), + 1); + gtk_box_append(GTK_BOX(meshcore), + makeSettingsRow("MeshCore RX delay", + "Base receive delay value.", + state.settings_meshcore_rx_delay)); + state.settings_meshcore_airtime = + makeSpin(0.1, 20.0, 0.1, mesh.meshcore_airtime_factor); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(state.settings_meshcore_airtime), + 1); + gtk_box_append(GTK_BOX(meshcore), + makeSettingsRow("MeshCore airtime factor", + "Airtime factor used by MeshCore flood logic.", + state.settings_meshcore_airtime)); + state.settings_meshcore_flood_max = + makeSpin(0.0, 255.0, 1.0, mesh.meshcore_flood_max); + gtk_box_append(GTK_BOX(meshcore), + makeSettingsRow("MeshCore flood max", + "Maximum MeshCore flood window.", + state.settings_meshcore_flood_max)); + state.settings_meshcore_multi_acks = + makeSwitch(mesh.meshcore_multi_acks); + gtk_box_append(GTK_BOX(meshcore), + makeSettingsRow("MeshCore multi ACKs", + "Enables MeshCore multi-ACK behaviour.", + state.settings_meshcore_multi_acks)); + state.settings_meshcore_page = + addSettingsGroup(state.settings_group_stack, + "meshcore", + "MeshCore", + meshcore); + + GtkWidget* channels = makeSettingsSection("Channels"); + state.settings_primary_enabled = makeSwitch(config.primary_enabled); + gtk_box_append(GTK_BOX(channels), + makeSettingsRow("Primary enabled", + "Enables primary mesh channel.", + state.settings_primary_enabled)); + state.settings_secondary_enabled = makeSwitch(config.secondary_enabled); + gtk_box_append(GTK_BOX(channels), + makeSettingsRow("Secondary enabled", + "Enables secondary mesh channel.", + state.settings_secondary_enabled)); + state.settings_primary_uplink = + makeSwitch(config.primary_uplink_enabled); + gtk_box_append(GTK_BOX(channels), + makeSettingsRow("Primary MQTT uplink", + "Allows primary channel uplink through MQTT proxy.", + state.settings_primary_uplink)); + state.settings_primary_downlink = + makeSwitch(config.primary_downlink_enabled); + gtk_box_append(GTK_BOX(channels), + makeSettingsRow("Primary MQTT downlink", + "Allows primary channel downlink from MQTT proxy.", + state.settings_primary_downlink)); + state.settings_secondary_uplink = + makeSwitch(config.secondary_uplink_enabled); + gtk_box_append(GTK_BOX(channels), + makeSettingsRow("Secondary MQTT uplink", + "Allows secondary channel uplink through MQTT proxy.", + state.settings_secondary_uplink)); + state.settings_secondary_downlink = + makeSwitch(config.secondary_downlink_enabled); + gtk_box_append(GTK_BOX(channels), + makeSettingsRow("Secondary MQTT downlink", + "Allows secondary channel downlink from MQTT proxy.", + state.settings_secondary_downlink)); + addSettingsGroup(state.settings_group_stack, + "channels", + "Channels", + channels); + + GtkWidget* chat = makeSettingsSection("Chat / Policy"); + state.settings_chat_channel = + makeCombo({"Primary", "Secondary"}, + std::clamp(config.chat_channel, 0, 1)); + gtk_box_append(GTK_BOX(chat), + makeSettingsRow("Default channel", + "Channel used by outgoing chat messages.", + state.settings_chat_channel)); + state.settings_relay_enabled = + makeSwitch(config.chat_policy.enable_relay); + gtk_box_append(GTK_BOX(chat), + makeSettingsRow("Relay", + "Allows this node to forward mesh traffic.", + state.settings_relay_enabled)); + state.settings_ack_broadcast = + makeSwitch(config.chat_policy.ack_for_broadcast); + gtk_box_append(GTK_BOX(chat), + makeSettingsRow("Broadcast ACK", + "Requests ACK for broadcast messages.", + state.settings_ack_broadcast)); + state.settings_ack_squad = makeSwitch(config.chat_policy.ack_for_squad); + gtk_box_append(GTK_BOX(chat), + makeSettingsRow("Squad ACK", + "Requests ACK for direct or squad messages.", + state.settings_ack_squad)); + state.settings_tx_retries = + makeSpin(0.0, 5.0, 1.0, config.chat_policy.max_tx_retries); + gtk_box_append(GTK_BOX(chat), + makeSettingsRow("TX retries", + "Retry budget used by chat policy.", + state.settings_tx_retries)); + state.settings_max_channels = + makeSpin(1.0, 3.0, 1.0, config.chat_policy.max_channels); + gtk_box_append(GTK_BOX(chat), + makeSettingsRow("Max channels", + "Maximum chat channels exposed by policy.", + state.settings_max_channels)); + addSettingsGroup(state.settings_group_stack, + "chat", + "Chat", + chat); + + GtkWidget* gps = makeSettingsSection("GPS"); + state.settings_gps_enabled = makeSwitch(config.gps_enabled); + gtk_box_append(GTK_BOX(gps), + makeSettingsRow("GPS enabled", + "Applies to the Linux GPS runtime.", + state.settings_gps_enabled)); + state.settings_gps_interval = + makeSpin(1000.0, 3600000.0, 1000.0, config.gps_interval_ms); + gtk_box_append(GTK_BOX(gps), + makeSettingsRow("Interval ms", + "GPS collection interval persisted in AppConfig.", + state.settings_gps_interval)); + state.settings_gps_mode = + makeCombo({"High accuracy", "Power save", "Fix only"}, + std::clamp(config.gps_mode, 0, 2)); + gtk_box_append(GTK_BOX(gps), + makeSettingsRow("Location mode", + "GNSS mode applied to the Linux GPS runtime.", + state.settings_gps_mode)); + state.settings_gps_strategy = + makeCombo({"Continuous", "Motion wake", "Low power off"}, + std::clamp(config.gps_strategy, 0, 2)); + gtk_box_append(GTK_BOX(gps), + makeSettingsRow("Position strategy", + "Power strategy used by GPS collection.", + state.settings_gps_strategy)); + state.settings_external_nmea_hz = + makeSpin(0.0, 10.0, 1.0, config.external_nmea_output_hz); + gtk_box_append(GTK_BOX(gps), + makeSettingsRow("NMEA export Hz", + "0 disables external NMEA output.", + state.settings_external_nmea_hz)); + state.settings_external_nmea_mask = + makeSpin(0.0, 255.0, 1.0, config.external_nmea_sentence_mask); + gtk_box_append(GTK_BOX(gps), + makeSettingsRow("NMEA sentence mask", + "Raw sentence mask persisted for GPS output.", + state.settings_external_nmea_mask)); + addSettingsGroup(state.settings_group_stack, "gps", "GPS", gps); + + GtkWidget* map = makeSettingsSection("Map"); + state.settings_map_source = makeCombo({"OSM", "Terrain", "Satellite"}, + std::clamp(config.map_source, + 0, + 2)); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("Base map", + "Default map source for the uConsole map view.", + state.settings_map_source)); + state.settings_map_zoom = + makeSpin(1.0, 18.0, 1.0, state.map_model.snapshot().zoom); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("Zoom", + "Map zoom used by the GTK map canvas.", + state.settings_map_zoom)); + state.settings_map_contour = makeSwitch(config.map_contour_enabled); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("Contour overlay", + "Shows transparent contour PNG tiles above the selected base map.", + state.settings_map_contour)); + state.settings_map_contour_ultra_fine = + makeSwitch(state.map_model.contourUltraFineEnabled()); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("Ultra-fine contours", + "Adds 5 m minor contours at z17+ for visible-fill generation and cached overlays.", + state.settings_map_contour_ultra_fine)); + GtkWidget* earthdata_token_control = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(earthdata_token_control, + "settings-inline-control"); + state.settings_map_earthdata_token = gtk_entry_new(); + gtk_entry_set_visibility(GTK_ENTRY(state.settings_map_earthdata_token), + FALSE); + gtk_entry_set_input_purpose(GTK_ENTRY(state.settings_map_earthdata_token), + GTK_INPUT_PURPOSE_PASSWORD); + gtk_widget_set_focusable(state.settings_map_earthdata_token, TRUE); + gtk_widget_set_hexpand(state.settings_map_earthdata_token, TRUE); + { + const auto token = state.map_model.earthdataToken(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_map_earthdata_token), + token.c_str()); + } + gtk_box_append(GTK_BOX(earthdata_token_control), + state.settings_map_earthdata_token); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("Earthdata token", + "Stored in local SQLite for NASADEM contour generation and cache workflows.", + earthdata_token_control)); + state.settings_map_track = makeSwitch(config.map_track_enabled); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("Track recording", + "Persists local track recording preference.", + state.settings_map_track)); + state.settings_map_mqtt_nodes = + makeSwitch(state.map_model.snapshot().show_mqtt_nodes); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("MQTT node layer", + "Shows positioned nodes that arrived through MQTT.", + state.settings_map_mqtt_nodes)); + state.settings_map_track_interval = + makeSpin(1.0, 99.0, 1.0, config.map_track_interval); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("Track interval", + "Track interval seconds, 99 means distance mode.", + state.settings_map_track_interval)); + state.settings_map_track_format = + makeCombo({"GPX", "CSV", "Binary"}, + std::clamp(config.map_track_format, 0, 2)); + gtk_box_append(GTK_BOX(map), + makeSettingsRow("Track format", + "Format for future local track exports.", + state.settings_map_track_format)); + addSettingsGroup(state.settings_group_stack, "map", "Map", map); + + GtkWidget* mqtt_section = makeSettingsSection("MQTT"); + state.settings_mqtt_enabled = makeSwitch(mqtt.enabled); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Enabled", + "Enables the Linux MQTT source definition.", + state.settings_mqtt_enabled)); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Runtime", + "Current broker client state.", + makeLabel("Client offline", "settings-status"))); + state.settings_mqtt_name = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_name), + mqtt.name.c_str()); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Source name", + "Local display name for this MQTT source.", + state.settings_mqtt_name)); + state.settings_mqtt_host = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_host), + mqtt.host.c_str()); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Host", + "Broker host, for example mqtt.mess.host.", + state.settings_mqtt_host)); + state.settings_mqtt_port = + makeSpin(1.0, 65535.0, 1.0, mqtt.port); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Port", + "Broker TCP port.", + state.settings_mqtt_port)); + state.settings_mqtt_username = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_username), + mqtt.username.c_str()); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Username", + "MQTT username.", + state.settings_mqtt_username)); + state.settings_mqtt_password = gtk_password_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_password), + mqtt.password.c_str()); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Password", + "MQTT password stored in local SQLite settings.", + state.settings_mqtt_password)); + state.settings_mqtt_topic = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_topic), + mqtt.topic.c_str()); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Topic", + "Subscribe topic such as msh/CN/#.", + state.settings_mqtt_topic)); + state.settings_mqtt_tls = makeSwitch(mqtt.tls); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("TLS", + "Use TLS for the MQTT broker connection.", + state.settings_mqtt_tls)); + state.settings_mqtt_client_id = gtk_entry_new(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_client_id), + mqtt.client_id.c_str()); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Client ID", + "Empty lets the runtime generate a client id.", + state.settings_mqtt_client_id)); + state.settings_mqtt_clean_session = makeSwitch(mqtt.clean_session); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Clean session", + "MQTT clean session flag.", + state.settings_mqtt_clean_session)); + state.settings_mqtt_qos = makeSpin(0.0, 2.0, 1.0, mqtt.qos); + gtk_box_append(GTK_BOX(mqtt_section), + makeSettingsRow("Subscribe QoS", + "0, 1, or 2.", + state.settings_mqtt_qos)); + addSettingsGroup(state.settings_group_stack, + "mqtt", + "MQTT", + mqtt_section); + + GtkWidget* network = makeSettingsSection("Network / Privacy"); + state.settings_net_duty_cycle = makeSwitch(config.net_duty_cycle); + gtk_box_append(GTK_BOX(network), + makeSettingsRow("Duty cycle limits", + "Keeps airtime throttling enabled for normal TX.", + state.settings_net_duty_cycle)); + state.settings_net_channel_util = + makeSpin(0.0, 100.0, 25.0, config.net_channel_util); + gtk_box_append(GTK_BOX(network), + makeSettingsRow("Channel utilization %", + "0 leaves utilization control automatic.", + state.settings_net_channel_util)); + state.settings_privacy_encrypt_mode = + makeCombo({"Off", "PSK", "PKI"}, + std::clamp(config.privacy_encrypt_mode, 0, 2)); + gtk_box_append(GTK_BOX(network), + makeSettingsRow("Encryption mode", + "Persists local privacy mode selection.", + state.settings_privacy_encrypt_mode)); + addSettingsGroup(state.settings_group_stack, + "network", + "Network", + network); + + gtk_stack_set_visible_child_name(GTK_STACK(state.settings_group_stack), + "identity"); + gtk_box_append(GTK_BOX(root), body); + showSettingsNotice(state, "Settings loaded."); + return root; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_settings_logic.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_settings_logic.cpp new file mode 100644 index 00000000..d720d160 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_settings_logic.cpp @@ -0,0 +1,1161 @@ +#include "platform/gtk/gtk_uconsole_mqtt_settings.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_shell.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "chat/infra/mesh_protocol_utils.h" +#include "chat/infra/meshtastic/mt_region.h" + +namespace trailmate::uconsole::gtk +{ + +constexpr std::array<::chat::MeshProtocol, 4> kSettingsProtocols{{ + ::chat::MeshProtocol::Meshtastic, + ::chat::MeshProtocol::MeshCore, + ::chat::MeshProtocol::RNode, + ::chat::MeshProtocol::LXMF, +}}; + +constexpr std::array + kMeshtasticPresetOptions{{ + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_VERY_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + }}; + +int protocolIndex(::chat::MeshProtocol protocol) +{ + for (std::size_t index = 0; index < kSettingsProtocols.size(); ++index) + { + if (kSettingsProtocols[index] == protocol) + { + return static_cast(index); + } + } + return 0; +} + +std::vector meshtasticRegionLabels() +{ + std::size_t count = 0; + const auto* regions = ::chat::meshtastic::getRegionTable(&count); + std::vector labels{}; + labels.reserve(count); + for (std::size_t index = 0; index < count; ++index) + { + labels.push_back(regions[index].label); + } + return labels; +} + +int meshtasticRegionIndex(std::uint8_t region_code) +{ + std::size_t count = 0; + const auto* regions = ::chat::meshtastic::getRegionTable(&count); + for (std::size_t index = 0; index < count; ++index) + { + if (static_cast(regions[index].code) == region_code) + { + return static_cast(index); + } + } + for (std::size_t index = 0; index < count; ++index) + { + if (regions[index].code == + meshtastic_Config_LoRaConfig_RegionCode_CN) + { + return static_cast(index); + } + } + return 0; +} + +std::uint8_t meshtasticRegionFromIndex(int index) +{ + std::size_t count = 0; + const auto* regions = ::chat::meshtastic::getRegionTable(&count); + if (index >= 0 && static_cast(index) < count) + { + return static_cast(regions[index].code); + } + return ::app::AppConfig::kDefaultRegionCode; +} + +std::vector meshtasticPresetLabels() +{ + std::vector labels{}; + labels.reserve(kMeshtasticPresetOptions.size()); + for (const auto preset : kMeshtasticPresetOptions) + { + labels.push_back(::chat::meshtastic::presetDisplayName(preset)); + } + return labels; +} + +int meshtasticPresetIndex(std::uint8_t preset_value) +{ + for (std::size_t index = 0; index < kMeshtasticPresetOptions.size(); + ++index) + { + if (static_cast(kMeshtasticPresetOptions[index]) == + preset_value) + { + return static_cast(index); + } + } + return 0; +} + +std::uint8_t meshtasticPresetFromIndex(int index) +{ + if (index >= 0 && + static_cast(index) < kMeshtasticPresetOptions.size()) + { + return static_cast( + kMeshtasticPresetOptions[static_cast(index)]); + } + return static_cast( + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); +} + +::chat::MeshProtocol protocolFromIndex(int index) +{ + if (index < 0 || + static_cast(index) >= kSettingsProtocols.size()) + { + return ::chat::MeshProtocol::Meshtastic; + } + return kSettingsProtocols[static_cast(index)]; +} + +void copyBounded(char* out, std::size_t out_len, const char* text) +{ + if (out == nullptr || out_len == 0U) + { + return; + } + if (text == nullptr) + { + out[0] = '\0'; + return; + } + std::snprintf(out, out_len, "%s", text); +} + +::chat::MeshConfig& meshConfigForProtocol(::app::AppConfig& config, + ::chat::MeshProtocol protocol) +{ + switch (protocol) + { + case ::chat::MeshProtocol::MeshCore: + return config.meshcore_config; + case ::chat::MeshProtocol::RNode: + case ::chat::MeshProtocol::LXMF: + return config.rnode_config; + case ::chat::MeshProtocol::Meshtastic: + default: + return config.meshtastic_config; + } +} + +const ::chat::MeshConfig& meshConfigForProtocol( + const ::app::AppConfig& config, + ::chat::MeshProtocol protocol) +{ + switch (protocol) + { + case ::chat::MeshProtocol::MeshCore: + return config.meshcore_config; + case ::chat::MeshProtocol::RNode: + case ::chat::MeshProtocol::LXMF: + return config.rnode_config; + case ::chat::MeshProtocol::Meshtastic: + default: + return config.meshtastic_config; + } +} + +float displayFrequencyMhz(const ::chat::MeshConfig& mesh) +{ + if (mesh.override_frequency_mhz > 0.0F) + { + return mesh.override_frequency_mhz; + } + if (mesh.meshcore_freq_mhz > 0.0F && mesh.meshcore_freq_mhz < 1000.0F) + { + return mesh.meshcore_freq_mhz; + } + return 433.175F; +} + +float displayBandwidthKHz(const ::chat::MeshConfig& mesh, + ::chat::MeshProtocol protocol) +{ + if (protocol == ::chat::MeshProtocol::MeshCore && + mesh.meshcore_bw_khz > 0.0F) + { + return mesh.meshcore_bw_khz; + } + return mesh.bandwidth_khz; +} + +std::uint8_t displaySpreadFactor(const ::chat::MeshConfig& mesh, + ::chat::MeshProtocol protocol) +{ + if (protocol == ::chat::MeshProtocol::MeshCore && + mesh.meshcore_sf >= 5U && mesh.meshcore_sf <= 12U) + { + return mesh.meshcore_sf; + } + return mesh.spread_factor; +} + +std::uint8_t displayCodingRate(const ::chat::MeshConfig& mesh, + ::chat::MeshProtocol protocol) +{ + if (protocol == ::chat::MeshProtocol::MeshCore && + mesh.meshcore_cr >= 5U && mesh.meshcore_cr <= 8U) + { + return mesh.meshcore_cr; + } + return mesh.coding_rate; +} + +int meshtasticRegionTxPowerLimitDbm(std::uint8_t region_code) +{ + const auto* region = ::chat::meshtastic::findRegion( + static_cast(region_code)); + int limit = static_cast(::app::AppConfig::kTxPowerMaxDbm); + if (region != nullptr && region->power_limit_dbm > 0U) + { + limit = std::min(limit, static_cast(region->power_limit_dbm)); + } + return std::clamp(limit, + static_cast(::app::AppConfig::kTxPowerMinDbm), + static_cast(::app::AppConfig::kTxPowerMaxDbm)); +} + +int displayTxPowerDbm(const ::chat::MeshConfig& mesh) +{ + return std::clamp(static_cast(mesh.tx_power), + static_cast(::app::AppConfig::kTxPowerMinDbm), + meshtasticRegionTxPowerLimitDbm(mesh.region)); +} + +void setMeshtasticTxPowerControl(GtkWidget* control, + const ::chat::MeshConfig& mesh) +{ + if (control == nullptr) + { + return; + } + const int limit = meshtasticRegionTxPowerLimitDbm(mesh.region); + GtkAdjustment* adjustment = + gtk_spin_button_get_adjustment(GTK_SPIN_BUTTON(control)); + gtk_adjustment_set_lower( + adjustment, static_cast(::app::AppConfig::kTxPowerMinDbm)); + gtk_adjustment_set_upper(adjustment, static_cast(limit)); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(control), + static_cast(displayTxPowerDbm(mesh))); + + const std::string tip = + "Selected Meshtastic region limit: " + std::to_string(limit) + " dBm"; + gtk_widget_set_tooltip_text(control, tip.c_str()); +} +void showSettingsNotice(GtkUConsoleAppState& state, const char* text) +{ + state.settings_notice = text ? text : ""; + state.settings_notice_ticks = 8; + setLabel(state.settings_status, state.settings_notice); +} +::chat::MeshProtocol selectedSettingsProtocol(const GtkUConsoleAppState& state) +{ + if (state.settings_protocol == nullptr) + { + return state.services.config().mesh_protocol; + } + return protocolFromIndex( + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_protocol))); +} + +void setSettingsStackPageVisible(GtkUConsoleAppState& state, + GtkWidget* child, + bool visible) +{ + if (state.settings_group_stack == nullptr || child == nullptr) + { + return; + } + + GtkStackPage* page = + gtk_stack_get_page(GTK_STACK(state.settings_group_stack), child); + if (page != nullptr) + { + gtk_stack_page_set_visible(page, visible ? TRUE : FALSE); + } + else + { + gtk_widget_set_visible(child, visible ? TRUE : FALSE); + } +} + +void updateSettingsProtocolVisibility(GtkUConsoleAppState& state) +{ + const auto protocol = selectedSettingsProtocol(state); + const bool meshtastic = protocol == ::chat::MeshProtocol::Meshtastic; + const bool meshcore = protocol == ::chat::MeshProtocol::MeshCore; + const bool raw_lora = protocol == ::chat::MeshProtocol::RNode || + protocol == ::chat::MeshProtocol::LXMF; + + setSettingsStackPageVisible(state, + state.settings_meshtastic_page, + meshtastic); + setSettingsStackPageVisible(state, state.settings_meshcore_page, meshcore); + setSettingsStackPageVisible(state, state.settings_link_page, raw_lora); + + if (state.settings_group_stack == nullptr) + { + return; + } + + const char* visible_name = gtk_stack_get_visible_child_name( + GTK_STACK(state.settings_group_stack)); + const std::string current = visible_name ? visible_name : ""; + if ((current == "meshtastic" && !meshtastic) || + (current == "meshcore" && !meshcore) || + (current == "link" && !raw_lora)) + { + gtk_stack_set_visible_child_name( + GTK_STACK(state.settings_group_stack), + meshtastic ? "meshtastic" : (meshcore ? "meshcore" : "link")); + } +} +void populateSettingsControls(GtkUConsoleAppState& state) +{ + const auto& config = state.services.config(); + const auto mesh = config.activeMeshConfig(); + const auto protocol = config.mesh_protocol; + const auto mqtt = loadMqttSettings(); + + if (state.settings_node_name != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_node_name), + config.node_name); + } + if (state.settings_short_name != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_short_name), + config.short_name); + } + if (state.settings_protocol != nullptr) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.settings_protocol), + protocolIndex(config.mesh_protocol)); + } + if (state.settings_lora_region != nullptr) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.settings_lora_region), + meshtasticRegionIndex(mesh.region)); + } + if (state.settings_lora_use_preset != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_lora_use_preset), + mesh.use_preset); + } + if (state.settings_lora_modem_preset != nullptr) + { + gtk_combo_box_set_active( + GTK_COMBO_BOX(state.settings_lora_modem_preset), + meshtasticPresetIndex(mesh.modem_preset)); + } + if (state.settings_lora_freq != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_lora_freq), + displayFrequencyMhz(mesh)); + } + if (state.settings_lora_bw != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_lora_bw), + displayBandwidthKHz(mesh, protocol)); + } + if (state.settings_lora_sf != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_lora_sf), + displaySpreadFactor(mesh, protocol)); + } + if (state.settings_lora_cr != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_lora_cr), + displayCodingRate(mesh, protocol)); + } + if (state.settings_lora_tx != nullptr) + { + setMeshtasticTxPowerControl(state.settings_lora_tx, mesh); + } + if (state.settings_hop_limit != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_hop_limit), + mesh.hop_limit); + } + if (state.settings_tx_enabled != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_tx_enabled), + mesh.tx_enabled); + } + if (state.settings_lora_channel_num != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_lora_channel_num), + mesh.channel_num); + } + if (state.settings_lora_freq_offset != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_lora_freq_offset), + mesh.frequency_offset_mhz); + } + if (state.settings_lora_override_duty != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_lora_override_duty), + mesh.override_duty_cycle); + } + if (state.settings_lora_ignore_mqtt != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_lora_ignore_mqtt), + mesh.ignore_mqtt); + } + if (state.settings_lora_ok_to_mqtt != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_lora_ok_to_mqtt), + mesh.config_ok_to_mqtt); + } + if (state.settings_meshcore_region_preset != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_region_preset), + mesh.meshcore_region_preset); + } + if (state.settings_meshcore_channel_slot != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_channel_slot), + mesh.meshcore_channel_slot); + } + if (state.settings_meshcore_channel_name != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_meshcore_channel_name), + mesh.meshcore_channel_name); + } + if (state.settings_meshcore_client_repeat != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_meshcore_client_repeat), + mesh.meshcore_client_repeat); + } + if (state.settings_meshcore_rx_delay != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_rx_delay), + mesh.meshcore_rx_delay_base); + } + if (state.settings_meshcore_airtime != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_airtime), + mesh.meshcore_airtime_factor); + } + if (state.settings_meshcore_flood_max != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_flood_max), + mesh.meshcore_flood_max); + } + if (state.settings_meshcore_multi_acks != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_meshcore_multi_acks), + mesh.meshcore_multi_acks); + } + if (state.settings_primary_enabled != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_primary_enabled), + config.primary_enabled); + } + if (state.settings_secondary_enabled != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_secondary_enabled), + config.secondary_enabled); + } + if (state.settings_primary_uplink != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_primary_uplink), + config.primary_uplink_enabled); + } + if (state.settings_primary_downlink != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_primary_downlink), + config.primary_downlink_enabled); + } + if (state.settings_secondary_uplink != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_secondary_uplink), + config.secondary_uplink_enabled); + } + if (state.settings_secondary_downlink != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_secondary_downlink), + config.secondary_downlink_enabled); + } + if (state.settings_chat_channel != nullptr) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.settings_chat_channel), + std::clamp(config.chat_channel, 0, 1)); + } + if (state.settings_relay_enabled != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_relay_enabled), + config.chat_policy.enable_relay); + } + if (state.settings_ack_broadcast != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_ack_broadcast), + config.chat_policy.ack_for_broadcast); + } + if (state.settings_ack_squad != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_ack_squad), + config.chat_policy.ack_for_squad); + } + if (state.settings_tx_retries != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_tx_retries), + config.chat_policy.max_tx_retries); + } + if (state.settings_max_channels != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_max_channels), + config.chat_policy.max_channels); + } + if (state.settings_gps_enabled != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_gps_enabled), + config.gps_enabled); + } + if (state.settings_gps_interval != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_gps_interval), + config.gps_interval_ms); + } + if (state.settings_gps_mode != nullptr) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.settings_gps_mode), + std::clamp(config.gps_mode, 0, 2)); + } + if (state.settings_gps_strategy != nullptr) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.settings_gps_strategy), + std::clamp(config.gps_strategy, 0, 2)); + } + if (state.settings_external_nmea_hz != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_external_nmea_hz), + config.external_nmea_output_hz); + } + if (state.settings_external_nmea_mask != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_external_nmea_mask), + config.external_nmea_sentence_mask); + } + if (state.settings_map_source != nullptr) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.settings_map_source), + std::clamp(config.map_source, 0, 2)); + } + if (state.settings_map_zoom != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_map_zoom), + state.map_model.snapshot().zoom); + } + if (state.settings_map_contour != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_map_contour), + config.map_contour_enabled); + } + if (state.settings_map_contour_ultra_fine != nullptr) + { + gtk_switch_set_active( + GTK_SWITCH(state.settings_map_contour_ultra_fine), + state.map_model.contourUltraFineEnabled()); + } + if (state.settings_map_earthdata_token != nullptr) + { + const auto token = state.map_model.earthdataToken(); + gtk_editable_set_text(GTK_EDITABLE(state.settings_map_earthdata_token), + token.c_str()); + } + if (state.settings_map_track != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_map_track), + config.map_track_enabled); + } + if (state.settings_map_mqtt_nodes != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_map_mqtt_nodes), + state.map_model.snapshot().show_mqtt_nodes); + } + if (state.settings_map_track_interval != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_map_track_interval), + config.map_track_interval); + } + if (state.settings_map_track_format != nullptr) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.settings_map_track_format), + std::clamp(config.map_track_format, 0, 2)); + } + if (state.settings_mqtt_enabled != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_mqtt_enabled), + mqtt.enabled); + } + if (state.settings_mqtt_name != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_name), + mqtt.name.c_str()); + } + if (state.settings_mqtt_host != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_host), + mqtt.host.c_str()); + } + if (state.settings_mqtt_port != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_mqtt_port), + mqtt.port); + } + if (state.settings_mqtt_username != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_username), + mqtt.username.c_str()); + } + if (state.settings_mqtt_password != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_password), + mqtt.password.c_str()); + } + if (state.settings_mqtt_topic != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_topic), + mqtt.topic.c_str()); + } + if (state.settings_mqtt_tls != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_mqtt_tls), mqtt.tls); + } + if (state.settings_mqtt_client_id != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_mqtt_client_id), + mqtt.client_id.c_str()); + } + if (state.settings_mqtt_clean_session != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_mqtt_clean_session), + mqtt.clean_session); + } + if (state.settings_mqtt_qos != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_mqtt_qos), + mqtt.qos); + } + if (state.settings_net_duty_cycle != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_net_duty_cycle), + config.net_duty_cycle); + } + if (state.settings_net_channel_util != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_net_channel_util), + config.net_channel_util); + } + if (state.settings_privacy_encrypt_mode != nullptr) + { + gtk_combo_box_set_active( + GTK_COMBO_BOX(state.settings_privacy_encrypt_mode), + std::clamp(config.privacy_encrypt_mode, 0, 2)); + } + updateSettingsProtocolVisibility(state); + showSettingsNotice(state, "Settings loaded."); +} + +void onSettingsProtocolChanged(GtkComboBox*, gpointer data) +{ + auto& state = *static_cast(data); + const int index = gtk_combo_box_get_active( + GTK_COMBO_BOX(state.settings_protocol)); + const auto protocol = protocolFromIndex(index); + const auto& mesh = meshConfigForProtocol(state.services.config(), protocol); + if (state.settings_lora_region != nullptr) + { + gtk_combo_box_set_active(GTK_COMBO_BOX(state.settings_lora_region), + meshtasticRegionIndex(mesh.region)); + } + if (state.settings_lora_use_preset != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_lora_use_preset), + mesh.use_preset); + } + if (state.settings_lora_modem_preset != nullptr) + { + gtk_combo_box_set_active( + GTK_COMBO_BOX(state.settings_lora_modem_preset), + meshtasticPresetIndex(mesh.modem_preset)); + } + if (state.settings_lora_freq != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_lora_freq), + displayFrequencyMhz(mesh)); + } + if (state.settings_lora_bw != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_lora_bw), + displayBandwidthKHz(mesh, protocol)); + } + if (state.settings_lora_sf != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_lora_sf), + displaySpreadFactor(mesh, protocol)); + } + if (state.settings_lora_cr != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_lora_cr), + displayCodingRate(mesh, protocol)); + } + if (state.settings_lora_tx != nullptr) + { + setMeshtasticTxPowerControl(state.settings_lora_tx, mesh); + } + if (state.settings_hop_limit != nullptr) + { + gtk_spin_button_set_value(GTK_SPIN_BUTTON(state.settings_hop_limit), + mesh.hop_limit); + } + if (state.settings_tx_enabled != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_tx_enabled), + mesh.tx_enabled); + } + if (state.settings_lora_channel_num != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_lora_channel_num), + mesh.channel_num); + } + if (state.settings_lora_freq_offset != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_lora_freq_offset), + mesh.frequency_offset_mhz); + } + if (state.settings_lora_override_duty != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_lora_override_duty), + mesh.override_duty_cycle); + } + if (state.settings_lora_ignore_mqtt != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_lora_ignore_mqtt), + mesh.ignore_mqtt); + } + if (state.settings_lora_ok_to_mqtt != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_lora_ok_to_mqtt), + mesh.config_ok_to_mqtt); + } + if (state.settings_meshcore_region_preset != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_region_preset), + mesh.meshcore_region_preset); + } + if (state.settings_meshcore_channel_slot != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_channel_slot), + mesh.meshcore_channel_slot); + } + if (state.settings_meshcore_channel_name != nullptr) + { + gtk_editable_set_text(GTK_EDITABLE(state.settings_meshcore_channel_name), + mesh.meshcore_channel_name); + } + if (state.settings_meshcore_client_repeat != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_meshcore_client_repeat), + mesh.meshcore_client_repeat); + } + if (state.settings_meshcore_rx_delay != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_rx_delay), + mesh.meshcore_rx_delay_base); + } + if (state.settings_meshcore_airtime != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_airtime), + mesh.meshcore_airtime_factor); + } + if (state.settings_meshcore_flood_max != nullptr) + { + gtk_spin_button_set_value( + GTK_SPIN_BUTTON(state.settings_meshcore_flood_max), + mesh.meshcore_flood_max); + } + if (state.settings_meshcore_multi_acks != nullptr) + { + gtk_switch_set_active(GTK_SWITCH(state.settings_meshcore_multi_acks), + mesh.meshcore_multi_acks); + } + updateSettingsProtocolVisibility(state); +} + +void onSettingsMeshtasticRegionChanged(GtkComboBox*, gpointer data) +{ + auto& state = *static_cast(data); + if (state.settings_lora_region == nullptr || + state.settings_lora_tx == nullptr) + { + return; + } + + auto mesh = state.services.config().meshtastic_config; + mesh.region = meshtasticRegionFromIndex( + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_lora_region))); + mesh.tx_power = static_cast( + gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_lora_tx))); + setMeshtasticTxPowerControl(state.settings_lora_tx, mesh); +} + +void onSettingsApplyClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + auto& config = state.services.config(); + + copyBounded(config.node_name, + sizeof(config.node_name), + gtk_editable_get_text(GTK_EDITABLE(state.settings_node_name))); + copyBounded(config.short_name, + sizeof(config.short_name), + gtk_editable_get_text(GTK_EDITABLE(state.settings_short_name))); + + const auto protocol = protocolFromIndex( + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_protocol))); + config.mesh_protocol = protocol; + auto& mesh = meshConfigForProtocol(config, protocol); + const auto selected_region = meshtasticRegionFromIndex( + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_lora_region))); + mesh.region = selected_region; + mesh.use_preset = + gtk_switch_get_active(GTK_SWITCH(state.settings_lora_use_preset)); + mesh.modem_preset = meshtasticPresetFromIndex(gtk_combo_box_get_active( + GTK_COMBO_BOX(state.settings_lora_modem_preset))); + mesh.override_frequency_mhz = static_cast( + gtk_spin_button_get_value(GTK_SPIN_BUTTON(state.settings_lora_freq))); + mesh.meshcore_freq_mhz = mesh.override_frequency_mhz; + const float bandwidth_khz = static_cast( + gtk_spin_button_get_value(GTK_SPIN_BUTTON(state.settings_lora_bw))); + const auto spread_factor = static_cast( + gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_lora_sf))); + const auto coding_rate = static_cast( + gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_lora_cr))); + mesh.bandwidth_khz = bandwidth_khz; + mesh.spread_factor = spread_factor; + mesh.coding_rate = coding_rate; + mesh.meshcore_bw_khz = bandwidth_khz; + mesh.meshcore_sf = spread_factor; + mesh.meshcore_cr = coding_rate; + mesh.tx_power = static_cast(std::clamp( + gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(state.settings_lora_tx)), + static_cast(::app::AppConfig::kTxPowerMinDbm), + meshtasticRegionTxPowerLimitDbm(mesh.region))); + mesh.hop_limit = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_hop_limit)), + 1, + 7)); + mesh.tx_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_tx_enabled)); + mesh.channel_num = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_lora_channel_num)), + 0, + 65535)); + mesh.frequency_offset_mhz = static_cast( + gtk_spin_button_get_value( + GTK_SPIN_BUTTON(state.settings_lora_freq_offset))); + mesh.override_duty_cycle = + gtk_switch_get_active(GTK_SWITCH(state.settings_lora_override_duty)); + mesh.ignore_mqtt = + gtk_switch_get_active(GTK_SWITCH(state.settings_lora_ignore_mqtt)); + mesh.config_ok_to_mqtt = + gtk_switch_get_active(GTK_SWITCH(state.settings_lora_ok_to_mqtt)); + mesh.meshcore_region_preset = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_meshcore_region_preset)), + 0, + 255)); + mesh.meshcore_channel_slot = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_meshcore_channel_slot)), + 0, + 255)); + copyBounded(mesh.meshcore_channel_name, + sizeof(mesh.meshcore_channel_name), + gtk_editable_get_text( + GTK_EDITABLE(state.settings_meshcore_channel_name))); + mesh.meshcore_client_repeat = + gtk_switch_get_active(GTK_SWITCH(state.settings_meshcore_client_repeat)); + mesh.meshcore_rx_delay_base = static_cast( + gtk_spin_button_get_value( + GTK_SPIN_BUTTON(state.settings_meshcore_rx_delay))); + mesh.meshcore_airtime_factor = static_cast( + gtk_spin_button_get_value( + GTK_SPIN_BUTTON(state.settings_meshcore_airtime))); + mesh.meshcore_flood_max = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_meshcore_flood_max)), + 0, + 255)); + mesh.meshcore_multi_acks = + gtk_switch_get_active(GTK_SWITCH(state.settings_meshcore_multi_acks)); + + config.primary_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_primary_enabled)); + config.secondary_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_secondary_enabled)); + config.primary_uplink_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_primary_uplink)); + config.primary_downlink_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_primary_downlink)); + config.secondary_uplink_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_secondary_uplink)); + config.secondary_downlink_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_secondary_downlink)); + + config.chat_channel = static_cast(std::clamp( + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_chat_channel)), + 0, + 1)); + config.chat_policy.enable_relay = + gtk_switch_get_active(GTK_SWITCH(state.settings_relay_enabled)); + config.chat_policy.ack_for_broadcast = + gtk_switch_get_active(GTK_SWITCH(state.settings_ack_broadcast)); + config.chat_policy.ack_for_squad = + gtk_switch_get_active(GTK_SWITCH(state.settings_ack_squad)); + config.chat_policy.max_tx_retries = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_tx_retries)), + 0, + 5)); + config.chat_policy.max_channels = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_max_channels)), + 1, + 3)); + mesh.enable_relay = config.chat_policy.enable_relay; + + config.gps_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_gps_enabled)); + config.gps_interval_ms = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_gps_interval)), + 1000, + 3600000)); + config.gps_mode = static_cast(std::clamp( + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_gps_mode)), + 0, + 2)); + config.gps_strategy = static_cast(std::clamp( + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_gps_strategy)), + 0, + 2)); + config.external_nmea_output_hz = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_external_nmea_hz)), + 0, + 10)); + config.external_nmea_sentence_mask = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_external_nmea_mask)), + 0, + 255)); + + const int map_source = + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_map_source)); + state.map_model.setSource( + ::platform::linux_runtime::sanitize_map_base_source( + static_cast(std::clamp(map_source, 0, 2)))); + state.map_model.setZoom(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_map_zoom))); + state.map_model.setContourEnabled( + gtk_switch_get_active(GTK_SWITCH(state.settings_map_contour))); + state.map_model.setContourUltraFineEnabled(gtk_switch_get_active( + GTK_SWITCH(state.settings_map_contour_ultra_fine))); + state.map_model.setEarthdataToken(gtk_editable_get_text( + GTK_EDITABLE(state.settings_map_earthdata_token))); + config.map_track_enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_map_track)); + state.map_model.setShowMqttNodes( + gtk_switch_get_active(GTK_SWITCH(state.settings_map_mqtt_nodes))); + config.map_track_interval = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_map_track_interval)), + 1, + 99)); + config.map_track_format = static_cast(std::clamp( + gtk_combo_box_get_active(GTK_COMBO_BOX(state.settings_map_track_format)), + 0, + 2)); + + LinuxMqttUiSettings mqtt{}; + mqtt.enabled = + gtk_switch_get_active(GTK_SWITCH(state.settings_mqtt_enabled)); + mqtt.name = gtk_editable_get_text(GTK_EDITABLE(state.settings_mqtt_name)); + mqtt.host = gtk_editable_get_text(GTK_EDITABLE(state.settings_mqtt_host)); + mqtt.port = std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_mqtt_port)), + 1, + 65535); + mqtt.username = + gtk_editable_get_text(GTK_EDITABLE(state.settings_mqtt_username)); + mqtt.password = + gtk_editable_get_text(GTK_EDITABLE(state.settings_mqtt_password)); + mqtt.topic = gtk_editable_get_text(GTK_EDITABLE(state.settings_mqtt_topic)); + mqtt.tls = gtk_switch_get_active(GTK_SWITCH(state.settings_mqtt_tls)); + mqtt.client_id = + gtk_editable_get_text(GTK_EDITABLE(state.settings_mqtt_client_id)); + mqtt.clean_session = + gtk_switch_get_active(GTK_SWITCH(state.settings_mqtt_clean_session)); + mqtt.qos = std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_mqtt_qos)), + 0, + 2); + saveMqttSettings(mqtt); + ::platform::linux_runtime::PacketLogEntry mqtt_log{}; + mqtt_log.source = ::platform::linux_runtime::PacketLogSource::Mqtt; + mqtt_log.direction = + ::platform::linux_runtime::PacketLogDirection::System; + mqtt_log.title = "MQTT settings saved"; + mqtt_log.summary = std::string(mqtt.enabled ? "Enabled" : "Disabled") + + " / " + mqtt.host + ":" + + std::to_string(mqtt.port) + " / " + mqtt.topic; + mqtt_log.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "source", + .text = mqtt.name, + }); + mqtt_log.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "transport", + .text = std::string(mqtt.tls ? "tls" : "tcp") + " qos " + + std::to_string(mqtt.qos), + }); + ::platform::linux_runtime::append_packet_log(std::move(mqtt_log)); + + config.net_duty_cycle = + gtk_switch_get_active(GTK_SWITCH(state.settings_net_duty_cycle)); + config.net_channel_util = static_cast( + std::clamp(gtk_spin_button_get_value_as_int( + GTK_SPIN_BUTTON(state.settings_net_channel_util)), + 0, + 100)); + config.privacy_encrypt_mode = static_cast(std::clamp( + gtk_combo_box_get_active( + GTK_COMBO_BOX(state.settings_privacy_encrypt_mode)), + 0, + 2)); + + state.services.applyUserInfo(); + state.services.applyMeshConfig(); + state.services.applyPositionConfig(); + state.services.applyChatDefaults(); + state.services.applyNetworkLimits(); + state.services.applyPrivacyConfig(); + state.services.saveConfig(); + state.map_failed_tiles.clear(); + state.map_fetch_status.clear(); + showSettingsNotice(state, "Settings saved."); + refreshUi(state); +} + +void onSettingsReloadClicked(GtkButton*, gpointer data) +{ + auto& state = *static_cast(data); + populateSettingsControls(state); +} +static void refreshSettingsPage(GtkUConsoleAppState& state, + const UConsoleDashboardSnapshot& dashboard, + const MapWorkspaceSnapshot& map_snapshot) +{ + if (state.settings_status == nullptr) + { + return; + } + + if (!state.settings_notice.empty() && state.settings_notice_ticks > 0) + { + setLabel(state.settings_status, state.settings_notice); + --state.settings_notice_ticks; + return; + } + state.settings_notice.clear(); + + const std::string status = + dashboard.self_node + " / " + dashboard.mesh_protocol + " / " + + map_snapshot.source_label + " z" + std::to_string(map_snapshot.zoom); + setLabel(state.settings_status, status); +} + +static void showSettingsPage(GtkUConsoleAppState& state) +{ + populateSettingsControls(state); +} + +void refreshSettingsLogic(GtkUConsoleAppState& state, + const GtkUConsoleRefreshSnapshot& snapshot) +{ + refreshSettingsPage(state, snapshot.dashboard, snapshot.map); +} + +GtkUConsolePageLifecycle makeSettingsPageLifecycle() +{ + return {.name = "settings", + .title = "Settings", + .onLaunch = launchSettingsLayout, + .onShow = showSettingsPage, + .onHide = nullptr, + .onRefresh = refreshSettingsLogic, + .onDestroy = nullptr}; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_shell.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_shell.cpp new file mode 100644 index 00000000..1826833e --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_shell.cpp @@ -0,0 +1,343 @@ +#include "platform/gtk/gtk_uconsole_shell.h" + +#include + +#include "platform/gtk/gtk_uconsole_layout_spec.h" +#include "platform/gtk/gtk_uconsole_pages.h" +#include "platform/gtk/gtk_uconsole_widgets.h" + +namespace trailmate::uconsole::gtk +{ + +namespace +{ + +const GtkUConsolePageLifecycle* findPage(const GtkUConsoleAppState& state, + const std::string& name) +{ + for (const auto& page : state.page_lifecycle) + { + if (name == page.name) + { + return &page; + } + } + return nullptr; +} + +GtkUConsolePageLifecycle* findPage(GtkUConsoleAppState& state, + const std::string& name) +{ + for (auto& page : state.page_lifecycle) + { + if (name == page.name) + { + return &page; + } + } + return nullptr; +} + +void setActiveNav(GtkUConsoleAppState& state, const char* page) +{ + const std::string current(page ? page : ""); + auto apply = [¤t](GtkWidget* button, const char* name) + { + if (button == nullptr) + { + return; + } + if (current == name) + { + gtk_widget_add_css_class(button, "nav-button-active"); + } + else + { + gtk_widget_remove_css_class(button, "nav-button-active"); + } + }; + + apply(state.nav_overview, "overview"); + apply(state.nav_chat, "chat"); + apply(state.nav_map, "map"); + apply(state.nav_hardware, "hardware"); + apply(state.nav_data, "data"); + apply(state.nav_logs, "logs"); + apply(state.nav_settings, "settings"); +} + +void onOverviewClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "overview"); +} + +void onChatClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "chat"); +} + +void onMapClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "map"); +} + +void onHardwareClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "hardware"); +} + +void onDataClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "data"); +} + +void onLogsClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "logs"); +} + +void onSettingsClicked(GtkButton*, gpointer data) +{ + showPage(*static_cast(data), "settings"); +} + +GtkWidget* buildMenuBar(GtkUConsoleAppState& state) +{ + GtkWidget* bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12); + gtk_widget_add_css_class(bar, "menu-bar"); + gtk_widget_set_hexpand(bar, TRUE); + + gtk_box_append(GTK_BOX(bar), makeLabel("Trail Mate", "menu-title")); + + state.nav_overview = gtk_button_new_with_label("Overview"); + gtk_widget_add_css_class(state.nav_overview, "menu-button"); + g_signal_connect(state.nav_overview, "clicked", + G_CALLBACK(onOverviewClicked), &state); + gtk_box_append(GTK_BOX(bar), state.nav_overview); + + state.nav_chat = gtk_button_new(); + gtk_widget_add_css_class(state.nav_chat, "menu-button"); + GtkWidget* chat_nav_child = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); + gtk_box_append(GTK_BOX(chat_nav_child), makeLabel("Chat")); + state.nav_chat_badge = makeLabel("", "menu-badge"); + gtk_label_set_xalign(GTK_LABEL(state.nav_chat_badge), 0.5F); + gtk_widget_set_visible(state.nav_chat_badge, FALSE); + gtk_box_append(GTK_BOX(chat_nav_child), state.nav_chat_badge); + gtk_button_set_child(GTK_BUTTON(state.nav_chat), chat_nav_child); + g_signal_connect(state.nav_chat, "clicked", G_CALLBACK(onChatClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_chat); + + state.nav_map = gtk_button_new_with_label("Map"); + gtk_widget_add_css_class(state.nav_map, "menu-button"); + g_signal_connect(state.nav_map, "clicked", G_CALLBACK(onMapClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_map); + + state.nav_hardware = gtk_button_new_with_label("Hardware"); + gtk_widget_add_css_class(state.nav_hardware, "menu-button"); + g_signal_connect(state.nav_hardware, "clicked", + G_CALLBACK(onHardwareClicked), &state); + gtk_box_append(GTK_BOX(bar), state.nav_hardware); + + state.nav_data = gtk_button_new_with_label("Data"); + gtk_widget_add_css_class(state.nav_data, "menu-button"); + g_signal_connect(state.nav_data, "clicked", G_CALLBACK(onDataClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_data); + + state.nav_logs = gtk_button_new_with_label("Logs"); + gtk_widget_add_css_class(state.nav_logs, "menu-button"); + g_signal_connect(state.nav_logs, "clicked", G_CALLBACK(onLogsClicked), + &state); + gtk_box_append(GTK_BOX(bar), state.nav_logs); + + state.nav_settings = gtk_button_new_with_label("Settings"); + gtk_widget_add_css_class(state.nav_settings, "menu-button"); + g_signal_connect(state.nav_settings, "clicked", + G_CALLBACK(onSettingsClicked), &state); + gtk_box_append(GTK_BOX(bar), state.nav_settings); + + GtkWidget* spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_set_hexpand(spacer, TRUE); + gtk_box_append(GTK_BOX(bar), spacer); + + GtkWidget* hint = makeLabel("uConsole Linux", "chip"); + gtk_box_append(GTK_BOX(bar), hint); + return bar; +} + +GtkWidget* buildStatusBar(GtkUConsoleAppState& state) +{ + GtkWidget* bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_add_css_class(bar, "statusbar"); + gtk_widget_set_hexpand(bar, TRUE); + gtk_widget_set_vexpand(bar, FALSE); + gtk_widget_set_halign(bar, GTK_ALIGN_FILL); + gtk_widget_set_valign(bar, GTK_ALIGN_FILL); + gtk_widget_set_size_request(bar, -1, layout_spec::kGlobalStatusBarHeight); + + state.status_aio2 = makeLabel("AIO2: -", "status-chip"); + state.status_lora = makeLabel("LoRa: -", "status-chip"); + state.status_gps = makeLabel("GPS: -", "status-chip"); + state.status_node = makeLabel("Node: -", "status-chip"); + state.status_unread = makeLabel("Unread: 0", "status-chip"); + + gtk_box_append(GTK_BOX(bar), state.status_aio2); + gtk_box_append(GTK_BOX(bar), state.status_lora); + gtk_box_append(GTK_BOX(bar), state.status_gps); + + GtkWidget* spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_set_hexpand(spacer, TRUE); + gtk_box_append(GTK_BOX(bar), spacer); + + gtk_box_append(GTK_BOX(bar), state.status_node); + gtk_box_append(GTK_BOX(bar), state.status_unread); + return bar; +} + +} // namespace + +void showPage(GtkUConsoleAppState& state, const char* page_name) +{ + const std::string next_name(page_name ? page_name : "overview"); + auto* next = findPage(state, next_name); + if (next == nullptr) + { + return; + } + + const std::string previous_name = state.active_page; + if (!previous_name.empty() && previous_name != next_name) + { + if (auto* previous = findPage(state, previous_name); + previous != nullptr && previous->onHide != nullptr) + { + previous->onHide(state); + } + } + + gtk_stack_set_visible_child_name(GTK_STACK(state.stack), next->name); + state.active_page = next->name; + setActiveNav(state, next->name); + if (previous_name != next_name && next->onShow != nullptr) + { + next->onShow(state); + } + refreshUi(state); +} + +GtkWidget* buildRoot(GtkUConsoleAppState& state) +{ + GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_hexpand(root, TRUE); + gtk_widget_set_vexpand(root, TRUE); + gtk_box_append(GTK_BOX(root), buildMenuBar(state)); + + GtkWidget* body = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_add_css_class(body, "body"); + gtk_widget_set_hexpand(body, TRUE); + gtk_widget_set_vexpand(body, TRUE); + gtk_widget_set_overflow(body, GTK_OVERFLOW_HIDDEN); + + state.page_lifecycle = buildGtkUConsolePageRegistry(); + state.stack = gtk_stack_new(); + gtk_widget_set_hexpand(state.stack, TRUE); + gtk_widget_set_vexpand(state.stack, TRUE); + for (const auto& page : state.page_lifecycle) + { + if (page.onLaunch == nullptr) + { + continue; + } + gtk_stack_add_named(GTK_STACK(state.stack), page.onLaunch(state), + page.name); + } + gtk_box_append(GTK_BOX(body), state.stack); + + GtkWidget* body_viewport = gtk_scrolled_window_new(); + gtk_widget_set_hexpand(body_viewport, TRUE); + gtk_widget_set_vexpand(body_viewport, TRUE); + gtk_widget_set_size_request(body_viewport, 1, 1); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_viewport), + GTK_POLICY_EXTERNAL, + GTK_POLICY_EXTERNAL); + gtk_scrolled_window_set_min_content_height( + GTK_SCROLLED_WINDOW(body_viewport), + 1); + gtk_scrolled_window_set_min_content_width( + GTK_SCROLLED_WINDOW(body_viewport), + 1); + gtk_scrolled_window_set_propagate_natural_height( + GTK_SCROLLED_WINDOW(body_viewport), + FALSE); + gtk_scrolled_window_set_propagate_natural_width( + GTK_SCROLLED_WINDOW(body_viewport), + FALSE); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_viewport), body); + + gtk_box_append(GTK_BOX(root), body_viewport); + gtk_box_append(GTK_BOX(root), buildStatusBar(state)); + showPage(state, "overview"); + return root; +} + +void refreshUi(GtkUConsoleAppState& state) +{ + const GtkUConsoleRefreshSnapshot snapshot{ + .dashboard = state.dashboard_model.snapshot(), + .map = state.map_model.snapshot()}; + setStatusChip(state.status_aio2, findHardware(snapshot.dashboard, "AIO2")); + setStatusChip(state.status_lora, findHardware(snapshot.dashboard, "LoRa")); + setStatusChip(state.status_gps, findHardware(snapshot.dashboard, "GPS")); + setLabel(state.status_node, "Node: " + snapshot.dashboard.self_node); + setBadgeCount(state.nav_chat_badge, snapshot.dashboard.unread_count); + setLabel(state.status_unread, + "Unread: " + std::to_string(snapshot.dashboard.unread_count)); + + if (const auto* page = findPage(state, state.active_page); + page != nullptr && page->onRefresh != nullptr) + { + page->onRefresh(state, snapshot); + } +} + +gboolean onRefresh(gpointer data) +{ + auto& state = *static_cast(data); + state.services.tick(); + refreshUi(state); + return G_SOURCE_CONTINUE; +} + +void shutdownGtkUConsoleApp(GtkUConsoleAppState& state) +{ + if (state.shutdown_complete) + { + return; + } + state.shutdown_complete = true; + + if (state.refresh_source != 0) + { + g_source_remove(state.refresh_source); + state.refresh_source = 0; + } + for (auto it = state.page_lifecycle.rbegin(); + it != state.page_lifecycle.rend(); + ++it) + { + if (it->onDestroy != nullptr) + { + it->onDestroy(state); + } + } + state.services.shutdown(); +} + +void onWindowDestroy(GtkWidget*, gpointer data) +{ + shutdownGtkUConsoleApp(*static_cast(data)); +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_shell.h b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_shell.h new file mode 100644 index 00000000..25cff6c2 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_shell.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "platform/gtk/gtk_uconsole_app_state.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* buildRoot(GtkUConsoleAppState& state); +void showPage(GtkUConsoleAppState& state, const char* page); +void refreshUi(GtkUConsoleAppState& state); +gboolean onRefresh(gpointer data); +void onWindowDestroy(GtkWidget*, gpointer data); +void shutdownGtkUConsoleApp(GtkUConsoleAppState& state); + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_style.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_style.cpp new file mode 100644 index 00000000..3c9b6e1c --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_style.cpp @@ -0,0 +1,922 @@ +#include "platform/gtk/gtk_uconsole_style.h" + +#include + +namespace trailmate::uconsole::gtk +{ + +constexpr const char* kCss = R"CSS( +window { + background: #e8ebe5; + color: #1b211e; +} +.menu-bar { + background: #1d2221; + color: #f6f8f4; + padding: 3px 8px; + min-height: 28px; + border-bottom: 1px solid #111514; +} +.menu-title { + color: #f8faf7; + font-weight: 700; + padding: 0 6px; +} +.menu-button { + background: transparent; + color: #dce3de; + border: 1px solid transparent; + border-radius: 4px; + padding: 3px 8px; +} +.menu-badge { + background: #dfeee7; + color: #0e322b; + border-radius: 999px; + padding: 0 6px; + font-size: 11px; + font-weight: 700; +} +.chip { + border-radius: 6px; + padding: 3px 8px; + color: #eef5f1; + background: #3a4f4a; +} +.mini-chip { + border-radius: 999px; + padding: 1px 6px; + color: #eef5f1; + background: #3a4f4a; + font-size: 11px; + font-weight: 700; +} +.body { + background: #e8ebe5; + padding: 6px; +} +.nav-button { + background: #f7f9f6; + color: #202926; + border: 1px solid #c8d1c7; + border-radius: 5px; + padding: 4px 9px; +} +.nav-button-active { + background: #dfeee7; + color: #12241e; + border-color: #6f9f8d; + font-weight: 700; +} +.workbench { + background: #e8ebe5; +} +.panel { + background: #fbfcfa; + border: 1px solid #cbd4ca; + border-radius: 6px; + padding: 8px; +} +.pane { + background: #fbfcfa; + border: 1px solid #cbd4ca; + border-radius: 6px; + padding: 8px; +} +.pane-primary { + background: #ffffff; +} +.rail-pane { + background: #f4f7f2; +} +.inspector-pane { + background: #f8faf6; +} +.pane-heading { + color: #202926; + font-weight: 700; +} +.pane-caption { + color: #64706a; + font-size: 12px; +} +.panel-attention { + background: #fff7eb; + border-color: #c47a25; +} +.overview-grid, +.detail-grid { + padding: 0; +} +.overview-panel-title { + color: #202926; + font-weight: 700; +} +.overview-summary-panel { + background: #ffffff; +} +.overview-recent-panel { + background: #f7f9f6; +} +.summary-title { + font-size: 17px; + font-weight: 700; +} +.summary-detail { + color: #5e6a65; + font-size: 12px; +} +.location-map { + background: #dce4dd; + border: 1px solid #b4c1b7; + border-radius: 5px; + padding: 4px; +} +.location-picture { + border-radius: 4px; +} +.gnss-skyplot { + background: #f7faf5; + border: 1px solid #c5d0c7; + border-radius: 6px; +} +.gnss-satellite-list { + padding: 0; +} +.gnss-sat-row { + background: #ffffff; + border: 1px solid #d6ded5; + border-left: 4px solid #6d7771; + border-radius: 5px; + padding: 4px 6px; +} +.gnss-sat-used { + background: #f6fbf7; +} +.gnss-signal-good { + border-left-color: #2f7a54; +} +.gnss-signal-fair { + border-left-color: #bf8b22; +} +.gnss-signal-weak { + border-left-color: #b5523e; +} +.gnss-signal-idle { + border-left-color: #7a8580; +} +.gnss-sat-title { + color: #202926; + font-weight: 700; + font-size: 11px; +} +.gnss-sat-meta { + color: #58645f; + font-size: 11px; +} +.metric-strip { + background: #dfe5dd; + border: 1px solid #c8d2c8; + border-radius: 6px; + padding: 5px; +} +.metric-card { + background: #fbfcfa; + border: 1px solid #cbd4ca; + border-radius: 5px; + padding: 7px 8px; +} +.metric-alert { + background: #fff4e4; + border-color: #c47a25; +} +.metric-value { + font-size: 20px; + font-weight: 700; +} +.metric-label { + color: #66726d; + font-size: 12px; +} +.timeline-list { + padding: 0; +} +.overview-timeline-panel { + background: #f6f7f3; +} +.timeline-filter { + min-width: 82px; +} +.timeline-jump-button { + border-radius: 5px; + padding: 3px 9px; +} +.timeline-row { + background: #ffffff; + border: 1px solid #d5ddd4; + border-left: 4px solid #2f7768; + border-radius: 5px; + padding: 6px 8px; +} +.timeline-row-team { + border-left-color: #8458a8; +} +.timeline-row-direct { + background: #ffffff; +} +.timeline-row-broadcast { + background: #fbfcfa; +} +.timeline-row-outgoing { + border-right: 3px solid #4b7a5c; +} +.timeline-kind-position { + border-left-color: #2671a5; +} +.timeline-kind-telemetry { + border-left-color: #996f16; +} +.timeline-kind-node { + border-left-color: #3f7c45; +} +.timeline-kind-message { + border-left-color: #2f685e; +} +.timeline-kind-system { + border-left-color: #626e68; +} +.timeline-time, +.timeline-badge-team, +.timeline-badge-mesh, +.timeline-badge-direct, +.timeline-badge-broadcast, +.timeline-badge-kind { + border-radius: 999px; + padding: 1px 6px; + font-size: 11px; + font-weight: 700; +} +.timeline-time { + background: #e7ece8; + color: #2e3834; +} +.timeline-badge-team { + background: #efe5f6; + color: #50306b; +} +.timeline-badge-mesh { + background: #dfeee7; + color: #163d35; +} +.timeline-badge-direct { + background: #e3edf6; + color: #204a68; +} +.timeline-badge-broadcast { + background: #f3ead4; + color: #604816; +} +.timeline-badge-kind { + background: #edf0ed; + color: #3c4641; +} +.timeline-row-alert { + background: #fff5e8; + border-left-color: #b7651f; +} +.recent-contact-list { + padding: 0; +} +.recent-contact-row { + background: #ffffff; + border: 1px solid #d5ddd4; + border-radius: 6px; + padding: 7px 8px; +} +.recent-contact-unread { + border-left: 4px solid #2f685e; +} +.recent-contact-chat { + border-radius: 5px; + padding: 2px 8px; +} +.detail-panel { + min-width: 260px; +} +.row { + background: #ffffff; + border: 1px solid #d5ddd4; + border-radius: 5px; + padding: 7px 8px; +} +.row-active { + background: #e6f0e9; + border-color: #6f9f8d; +} +.row-title { + font-weight: 700; + color: #202926; +} +.row-meta { + color: #66726d; + font-size: 12px; +} +.message-out { + background: #edf7f1; +} +.message-in { + background: #ffffff; +} +.message-failed { + background: #fff0ee; +} +.chat-root { + background: #e8ebe5; +} +.chat-rail { + background: #f5f7f3; + padding: 7px; +} +.chat-sort { + min-width: 84px; +} +.chat-thread-list { + background: transparent; +} +.chat-thread-list row { + background: transparent; + padding: 2px 0; +} +.chat-thread-row { + background: #ffffff; + border: 1px solid #d5ddd4; + border-radius: 6px; + padding: 7px 8px; +} +.chat-thread-button { + background: transparent; + border: none; + padding: 0; +} +.chat-thread-active { + background: #e7f0ea; + border-color: #6f9f8d; +} +.chat-thread-team { + border-left: 4px solid #8458a8; +} +.chat-thread-broadcast { + border-left: 4px solid #b08b2d; +} +.chat-thread-title { + color: #202926; + font-weight: 700; +} +.chat-thread-preview { + color: #2d3733; +} +.chat-thread-unread { + background: #2f685e; + color: #f5fbf7; + border-radius: 999px; + padding: 1px 6px; + font-size: 11px; + font-weight: 700; +} +.chat-thread-unread-source { + color: #1f5c51; + font-size: 12px; + font-weight: 700; +} +.chat-thread-facts { + color: #3e5e58; + font-size: 12px; + font-weight: 700; +} +.chat-group { + background: transparent; +} +.chat-group-list { + padding: 5px 0 0 0; +} +.chat-main { + background: #f9fbf8; + border: 1px solid #cbd4ca; + border-radius: 6px; + padding: 0; +} +.chat-titlebar { + background: #ffffff; + border-bottom: 1px solid #d5ddd4; + border-radius: 6px 6px 0 0; + padding: 7px 10px; +} +.chat-action-row { + padding: 0; +} +.chat-action-button { + border-radius: 5px; + padding: 3px 8px; +} +.chat-title-line { + color: #19231f; + font-size: 15px; + font-weight: 700; +} +.chat-transcript { + background: #eef2ed; + padding: 8px 0; +} +.chat-transcript row { + background: transparent; + border: none; + padding: 0; +} +.chat-message-shell { + background: transparent; + border: none; + padding: 2px 8px; +} +.chat-message-row { + background: transparent; + padding: 0; +} +.chat-bubble { + border: 1px solid #d1dbd3; + border-radius: 7px; + padding: 6px 8px; +} +.chat-bubble-in { + background: #ffffff; +} +.chat-bubble-out { + background: #dceee5; + border-color: #9dc3b5; +} +.chat-bubble-failed { + background: #fff0ee; + border-color: #d59a90; +} +.chat-sender { + color: #51605a; + font-size: 11px; + font-weight: 700; +} +.chat-text { + color: #1d2521; +} +.chat-message-meta { + color: #6a746f; + font-size: 11px; +} +.chat-composer-shell { + background: #ffffff; + border-top: 1px solid #d5ddd4; + border-radius: 0 0 6px 6px; + padding: 7px 9px 6px 9px; +} +.chat-composer { + padding: 0; +} +.chat-entry { + min-height: 30px; +} +.chat-action-status { + color: #5d6862; + font-size: 11px; +} +.chat-send { + border-radius: 6px; + padding: 5px 13px; +} +.chat-node-panel { + background: #f4f7f2; + padding: 7px; +} +.chat-node-list { + padding: 0; +} +.chat-node-card { + background: #ffffff; + border: 1px solid #d5ddd4; + border-left: 3px solid #2f685e; + border-radius: 6px; + padding: 7px 8px; +} +.chat-node-mqtt { + border-left-color: #7e4aa3; +} +.chat-node-position { + color: #1f5c51; + font-size: 12px; + font-weight: 700; +} +.chat-node-actions { + padding: 3px 0 0 0; +} +.chat-node-action { + border-radius: 5px; + padding: 2px 6px; + font-size: 11px; +} +.node-info-dialog { + background: #f3f6f1; +} +.node-info-dialog-body { + padding: 12px; +} +.node-info-title { + color: #17231f; + font-size: 18px; + font-weight: 700; +} +.node-info-section { + background: #ffffff; + border: 1px solid #d5ddd4; + border-radius: 6px; + padding: 8px; +} +.node-info-section-title { + color: #202926; + font-weight: 700; +} +.node-info-row { + padding: 2px 0; +} +.node-info-key { + color: #66726d; + font-size: 12px; + font-weight: 700; +} +.node-info-value { + color: #1f2925; + font-size: 12px; +} +.node-info-value-attention { + color: #8a3f00; + font-size: 12px; + font-weight: 700; +} +.node-info-map-stage { + background: #89968e; + border: 1px solid #b7c1b8; + border-radius: 6px; +} +.node-info-map-grid { + background: #89968e; +} +.node-info-map-tile { + background: transparent; + border: none; + padding: 0; +} +.node-info-map-tile-pending { + background: #c8d0c8; +} +.node-info-marker-node, +.node-info-marker-self { + border-radius: 999px; + padding: 2px 7px; + font-size: 11px; + font-weight: 700; +} +.node-info-marker-node { + background: rgba(255, 248, 221, 0.96); + color: #493711; + border: 2px solid #c28f2c; +} +.node-info-marker-self { + background: rgba(224, 247, 239, 0.96); + color: #0e3e37; + border: 2px solid #1d685e; +} +.node-info-map-id, +.node-info-map-lon, +.node-info-map-lat, +.node-info-distance { + background: rgba(28, 35, 32, 0.82); + color: #eef4ef; + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; + font-weight: 700; +} +.node-info-distance { + background: rgba(255, 255, 255, 0.92); + color: #18312b; + border: 1px solid rgba(20, 66, 58, 0.34); +} +.node-info-map-panel { + background: rgba(28, 35, 32, 0.86); + border: 1px solid rgba(238, 244, 239, 0.18); + border-radius: 6px; + padding: 6px; +} +.node-info-map-protocol, +.node-info-map-rssi, +.node-info-map-snr, +.node-info-map-seen { + color: #eef4ef; + font-size: 12px; + font-weight: 700; +} +.node-info-map-rssi { + color: #f3df9b; +} +.node-info-map-snr { + color: #a6d5ef; +} +.node-info-map-seen { + color: #cfd9d2; +} +.node-info-map-empty { + color: #50605a; +} +.empty-state { + color: #66726d; + padding: 12px; +} +.hardware-grid { + padding: 0; +} +.hardware-card { + background: #fbfcfa; + border: 1px solid #cbd4ca; + border-radius: 5px; + padding: 7px 8px; +} +.hardware-card-alert { + background: #fff4e4; + border-color: #c47a25; +} +.hardware-state { + font-size: 16px; + font-weight: 700; +} +.hardware-state-alert { + color: #8a3f00; +} +.statusbar { + background: #1d2221; + color: #e8eee9; + padding: 4px 8px; + min-height: 24px; +} +.status-chip { + border-radius: 4px; + padding: 2px 7px; + background: #2d3633; + color: #dce4df; +} +.status-alert { + background: #713828; + color: #fff4ed; +} +.status-ok { + background: #2f685e; + color: #ecf4ef; +} +.map-canvas { + background: #89968e; +} +.map-side-panel, +.map-tools-panel { + background: #f8faf6; + padding: 6px 6px 32px 6px; +} +.map-tool-section { + background: #ffffff; + border: 1px solid #d5ddd4; + border-radius: 6px; + padding: 6px; +} +.map-tool-title { + color: #202926; + font-weight: 700; + font-size: 12px; +} +.map-grid { + background: #89968e; + padding: 0; +} +.map-contour-grid { + background: transparent; + padding: 0; +} +.tile-cell { + background: transparent; + border: none; + border-radius: 0; + padding: 0; +} +.map-contour-cell { + background: transparent; + border: none; + padding: 0; +} +.map-tile-pending { + background: #c8d0c8; +} +.map-overlay-panel { + background: rgba(28, 35, 32, 0.90); + color: #eef4ef; + border: 1px solid rgba(238, 244, 239, 0.20); + border-radius: 6px; + padding: 7px; +} +.map-overlay-panel .row-title, +.map-overlay-panel .pane-heading { + color: #f6faf7; +} +.map-overlay-panel .row-meta { + color: #cbd8d0; +} +.map-tool-row { + padding: 0; +} +.map-tool-row switch { + min-width: 44px; +} +.map-side-panel button, +.map-tools-panel button { + padding: 3px 6px; +} +.map-marker { + background: rgba(224, 247, 239, 0.92); + color: #0e3e37; + border: 2px solid #1d685e; + border-radius: 999px; + padding: 1px 6px; + font-weight: 700; +} +.map-marker-mqtt { + background: rgba(249, 235, 255, 0.92); + color: #44205d; + border: 2px solid #8b4fb2; + border-radius: 999px; + padding: 1px 6px; + font-weight: 700; +} +.map-marker-local { + background: rgba(255, 248, 221, 0.92); + color: #493711; + border: 2px solid #c28f2c; + border-radius: 999px; + padding: 1px 6px; + font-weight: 700; +} +.map-marker-measure { + background: rgba(255, 255, 255, 0.96); + color: #19231f; + border: 2px solid #1c5f91; + border-radius: 999px; + padding: 1px 6px; + font-weight: 700; +} +.map-node-marker-button { + min-height: 0; + padding: 1px 6px; +} +.map-node-bubble { + background: rgba(255, 255, 255, 0.96); + color: #1b211e; + border: 1px solid rgba(42, 54, 48, 0.26); + border-left: 4px solid #2f685e; + border-radius: 6px; + padding: 7px; + min-width: 166px; +} +.map-context-menu { + background: #fbfcfa; + border: 1px solid #aebaae; + border-radius: 6px; + padding: 6px; +} +.source-button-active { + background: #dfeee7; + color: #15251f; + border-color: #6f9f8d; +} +.settings-section { + background: #fbfcfa; + border: 1px solid #cbd4ca; + border-radius: 6px; + padding: 8px; +} +.settings-row { + background: #ffffff; + border: 1px solid #d7ded5; + border-radius: 5px; + padding: 6px 8px; +} +.settings-control { + min-width: 172px; +} +.settings-switch { + min-width: 48px; +} +.settings-inline-control { + min-width: 172px; +} +.settings-inline-control > entry, +.settings-inline-control > passwordentry { + min-width: 172px; +} +.settings-actions { + background: #f4f7f2; + border: 1px solid #cbd4ca; + border-radius: 6px; + padding: 5px 7px; +} +.settings-body { + padding: 0; +} +.settings-sidebar { + min-width: 144px; + background: #f4f7f2; + border: 1px solid #cbd4ca; + border-radius: 6px; +} +.settings-status { + color: #315f57; + font-size: 12px; +} +.log-toolbar { + background: #f4f7f2; + border: 1px solid #cbd4ca; + border-radius: 6px; + padding: 5px 7px; +} +.log-entry { + background: #ffffff; + border: 1px solid #d5ddd4; + border-radius: 5px; + padding: 7px 8px; +} +.log-entry-header { + border-spacing: 7px; +} +.log-time { + color: #52615a; + font-family: monospace; + font-size: 12px; +} +.log-source, +.log-direction { + background: #e7efe7; + color: #26352e; + border: 1px solid #c8d5c7; + border-radius: 4px; + padding: 1px 5px; + font-family: monospace; + font-size: 12px; +} +.log-segments { + padding: 3px 0; +} +.log-hex { + font-family: monospace; + color: #303b37; + background: #f1f4ef; + border-radius: 4px; + padding: 5px 6px; +} +.log-segment-header { + color: #155c8a; + font-family: monospace; + font-weight: 700; +} +.log-segment-body { + color: #2b6a34; + font-family: monospace; +} +.log-segment-checksum { + color: #8c4a13; + font-family: monospace; + font-weight: 700; +} +.log-segment-meta { + color: #5f6671; + font-family: monospace; +} +.log-segment-error { + color: #9b2f22; + font-family: monospace; + font-weight: 700; +} +button.send { + border-radius: 6px; + padding: 6px 13px; +} +)CSS"; +void installCss() +{ + GtkCssProvider* provider = gtk_css_provider_new(); + gtk_css_provider_load_from_data(provider, kCss, -1); + gtk_style_context_add_provider_for_display( + gdk_display_get_default(), GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(provider); +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_style.h b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_style.h new file mode 100644 index 00000000..515e3b47 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_style.h @@ -0,0 +1,8 @@ +#pragma once + +namespace trailmate::uconsole::gtk +{ + +void installCss(); + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_widgets.cpp b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_widgets.cpp new file mode 100644 index 00000000..693c0d2c --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_widgets.cpp @@ -0,0 +1,291 @@ +#include "platform/gtk/gtk_uconsole_widgets.h" + +#include +#include + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* makeLabel(const char* text, + const char* css_class, + bool wrap) +{ + GtkWidget* label = gtk_label_new(text ? text : ""); + gtk_label_set_xalign(GTK_LABEL(label), 0.0F); + gtk_label_set_yalign(GTK_LABEL(label), 0.5F); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END); + if (wrap) + { + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_wrap_mode(GTK_LABEL(label), PANGO_WRAP_WORD_CHAR); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_NONE); + } + if (css_class != nullptr) + { + gtk_widget_add_css_class(label, css_class); + } + return label; +} + +void constrainLabelWidth(GtkWidget* label, int width_chars) +{ + if (label == nullptr || width_chars <= 0) + { + return; + } + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_wrap_mode(GTK_LABEL(label), PANGO_WRAP_WORD_CHAR); + gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_NONE); + gtk_label_set_width_chars(GTK_LABEL(label), width_chars); + gtk_label_set_max_width_chars(GTK_LABEL(label), width_chars); + gtk_widget_set_hexpand(label, FALSE); +} + +void setLabel(GtkWidget* label, const std::string& text) +{ + if (label != nullptr) + { + gtk_label_set_text(GTK_LABEL(label), text.c_str()); + } +} + +void setLabel(GtkWidget* label, const char* text) +{ + if (label != nullptr) + { + gtk_label_set_text(GTK_LABEL(label), text ? text : ""); + } +} + +GtkWidget* makeBox(GtkOrientation orientation, int spacing) +{ + GtkWidget* box = gtk_box_new(orientation, spacing); + gtk_widget_set_hexpand(box, TRUE); + return box; +} + +void clearBox(GtkWidget* box) +{ + if (box == nullptr) return; + while (GtkWidget* child = gtk_widget_get_first_child(box)) + { + gtk_box_remove(GTK_BOX(box), child); + } +} + +void clearListBox(GtkWidget* list) +{ + if (list == nullptr) return; + while (GtkWidget* child = gtk_widget_get_first_child(list)) + { + gtk_list_box_remove(GTK_LIST_BOX(list), child); + } +} + +void clearGrid(GtkWidget* grid) +{ + if (grid == nullptr) return; + while (GtkWidget* child = gtk_widget_get_first_child(grid)) + { + gtk_grid_remove(GTK_GRID(grid), child); + } +} + +void clearFixed(GtkWidget* fixed) +{ + if (fixed == nullptr) return; + while (GtkWidget* child = gtk_widget_get_first_child(fixed)) + { + gtk_fixed_remove(GTK_FIXED(fixed), child); + } +} + +GtkWidget* makePanel() +{ + GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_add_css_class(panel, "panel"); + gtk_widget_set_hexpand(panel, TRUE); + return panel; +} + +GtkWidget* makeRowBox(bool active) +{ + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_add_css_class(row, "row"); + if (active) + { + gtk_widget_add_css_class(row, "row-active"); + } + gtk_widget_set_hexpand(row, TRUE); + return row; +} + +GtkWidget* makeListRow(GtkWidget* child, std::size_t index) +{ + GtkWidget* row = gtk_list_box_row_new(); + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), child); + g_object_set_data(G_OBJECT(row), "trailmate-index", + GUINT_TO_POINTER(static_cast(index))); + return row; +} + +std::string formatBytes(std::uint64_t bytes) +{ + if (bytes >= 1024ULL * 1024ULL) + { + return std::to_string(bytes / (1024ULL * 1024ULL)) + " MB"; + } + if (bytes >= 1024ULL) + { + return std::to_string(bytes / 1024ULL) + " KB"; + } + return std::to_string(bytes) + " B"; +} + +GtkWidget* makeWorkbench(GtkOrientation orientation, int spacing) +{ + GtkWidget* root = gtk_box_new(orientation, spacing); + gtk_widget_add_css_class(root, "workbench"); + gtk_widget_set_hexpand(root, TRUE); + gtk_widget_set_vexpand(root, TRUE); + return root; +} + +GtkWidget* makeMetricCard(const std::string& label, + const std::string& value, + const std::string& detail, + bool attention) +{ + GtkWidget* card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_add_css_class(card, "metric-card"); + if (attention) + { + gtk_widget_add_css_class(card, "metric-alert"); + } + gtk_widget_set_hexpand(card, TRUE); + + gtk_box_append(GTK_BOX(card), makeLabel(label.c_str(), "metric-label")); + gtk_box_append(GTK_BOX(card), makeLabel(value.c_str(), "metric-value")); + if (!detail.empty()) + { + gtk_box_append(GTK_BOX(card), makeLabel(detail.c_str(), "row-meta", + true)); + } + return card; +} + +const HardwareStatusItem* findHardware(const UConsoleDashboardSnapshot& snapshot, + const char* name) +{ + for (const auto& item : snapshot.hardware) + { + if (item.name == name) + { + return &item; + } + } + return nullptr; +} + +std::string hardwareText(const HardwareStatusItem* item) +{ + if (item == nullptr) + { + return "-"; + } + return item->name + ": " + item->state; +} + +void setStatusChip(GtkWidget* label, const HardwareStatusItem* item) +{ + if (label == nullptr) + { + return; + } + setLabel(label, hardwareText(item)); + gtk_widget_remove_css_class(label, "status-alert"); + gtk_widget_remove_css_class(label, "status-ok"); + if (item != nullptr && item->attention) + { + gtk_widget_add_css_class(label, "status-alert"); + } + else + { + gtk_widget_add_css_class(label, "status-ok"); + } +} + +void setAttentionClass(GtkWidget* widget, bool attention) +{ + if (widget == nullptr) + { + return; + } + if (attention) + { + gtk_widget_add_css_class(widget, "panel-attention"); + } + else + { + gtk_widget_remove_css_class(widget, "panel-attention"); + } +} +void setBadgeCount(GtkWidget* badge, int count) +{ + if (badge == nullptr) + { + return; + } + if (count <= 0) + { + gtk_widget_set_visible(badge, FALSE); + setLabel(badge, ""); + return; + } + if (count > 99) + { + setLabel(badge, "99+"); + } + else + { + setLabel(badge, std::to_string(count)); + } + gtk_widget_set_visible(badge, TRUE); +} +GtkWidget* buildDetailsWorkspace(const char* title, + const char* subtitle, + GtkWidget** out_box) +{ + (void)title; + (void)subtitle; + *out_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_hexpand(*out_box, TRUE); + gtk_widget_set_vexpand(*out_box, TRUE); + + GtkWidget* scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), *out_box); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), + GTK_POLICY_AUTOMATIC, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_hexpand(scroll, TRUE); + gtk_widget_set_vexpand(scroll, TRUE); + + GtkWidget* root = makeWorkbench(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_append(GTK_BOX(root), scroll); + return root; +} +GtkWidget* buildDetailRow(const std::string& title, + const std::string& detail, + bool attention) +{ + GtkWidget* row = makeRowBox(); + if (attention) + { + gtk_widget_add_css_class(row, "hardware-card-alert"); + } + gtk_box_append(GTK_BOX(row), makeLabel(title.c_str(), "row-title")); + gtk_box_append(GTK_BOX(row), makeLabel(detail.c_str(), "row-meta", true)); + return row; +} + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_widgets.h b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_widgets.h new file mode 100644 index 00000000..f9e68c15 --- /dev/null +++ b/apps/linux_uconsole/src/platform/gtk/gtk_uconsole_widgets.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#include + +#include "platform/gtk/gtk_uconsole_app_state.h" + +namespace trailmate::uconsole::gtk +{ + +GtkWidget* makeLabel(const char* text, + const char* css_class = nullptr, + bool wrap = false); +void constrainLabelWidth(GtkWidget* label, int width_chars); +void setLabel(GtkWidget* label, const std::string& text); +void setLabel(GtkWidget* label, const char* text); +GtkWidget* makeBox(GtkOrientation orientation, int spacing); +void clearBox(GtkWidget* box); +void clearListBox(GtkWidget* list); +void clearGrid(GtkWidget* grid); +void clearFixed(GtkWidget* fixed); +GtkWidget* makePanel(); +GtkWidget* makeRowBox(bool active = false); +GtkWidget* makeListRow(GtkWidget* child, std::size_t index); +GtkWidget* makeWorkbench(GtkOrientation orientation, int spacing); +GtkWidget* makeMetricCard(const std::string& label, + const std::string& value, + const std::string& detail, + bool attention = false); +GtkWidget* buildDetailsWorkspace(const char* title, + const char* subtitle, + GtkWidget** out_box); +GtkWidget* buildDetailRow(const std::string& title, + const std::string& detail, + bool attention = false); +std::string formatBytes(std::uint64_t bytes); +const HardwareStatusItem* findHardware(const UConsoleDashboardSnapshot& snapshot, + const char* name); +void setStatusChip(GtkWidget* label, const HardwareStatusItem* item); +void setAttentionClass(GtkWidget* widget, bool attention); +void setBadgeCount(GtkWidget* badge, int count); + +} // namespace trailmate::uconsole::gtk diff --git a/apps/linux_uconsole/src/targets/uconsole_main.cpp b/apps/linux_uconsole/src/targets/uconsole_main.cpp new file mode 100644 index 00000000..48004b6e --- /dev/null +++ b/apps/linux_uconsole/src/targets/uconsole_main.cpp @@ -0,0 +1,184 @@ +#include +#include +#include +#include +#include + +#include "platform/gtk/gtk_uconsole_app.h" +#if defined(TRAIL_MATE_UCONSOLE_HAS_SDL) +#include "platform/desktop/sdl_window_presenter.h" +#endif +#if defined(TRAIL_MATE_UCONSOLE_HAS_LEGACY_SURFACE) +#include "platform/device/linux_framebuffer_platform.h" +#include "uconsole/uconsole_desktop_shell.h" +#endif + +namespace +{ + +std::string envString(const char* name, const char* fallback) +{ + if (const char* value = std::getenv(name)) + { + if (*value != '\0') return value; + } + return fallback; +} + +int envInt(const char* name, int fallback) +{ + if (const char* value = std::getenv(name)) + { + if (*value != '\0') + { + try + { + return std::stoi(value); + } + catch (...) + { + return fallback; + } + } + } + return fallback; +} + +enum class Backend +{ + GtkWindow, + SdlWindow, + Framebuffer, +}; + +struct LaunchOptions +{ + Backend backend = Backend::GtkWindow; + std::string framebuffer = "/dev/fb0"; + int width = 1180; + int height = 600; + int window_scale = 1; + bool fullscreen = false; +}; + +LaunchOptions parseOptions(int argc, char** argv) +{ + LaunchOptions options{}; + options.framebuffer = envString("TRAIL_MATE_FBDEV", "/dev/fb0"); + + for (int index = 1; index < argc; ++index) + { + const std::string_view current{argv[index]}; + if (current == "--fbdev") + { + options.backend = Backend::Framebuffer; + if ((index + 1) < argc) + { + options.framebuffer = argv[++index]; + } + } + else if (current == "--sdl") + { + options.backend = Backend::SdlWindow; + } + } + + const int default_width = + options.backend == Backend::Framebuffer ? 1280 + : options.backend == Backend::GtkWindow ? 1180 + : 1280; + const int default_height = + options.backend == Backend::Framebuffer ? 720 + : options.backend == Backend::GtkWindow ? 600 + : 720; + options.width = envInt("TRAIL_MATE_UCONSOLE_WIDTH", default_width); + options.height = envInt("TRAIL_MATE_UCONSOLE_HEIGHT", default_height); + options.window_scale = envInt("TRAIL_MATE_UCONSOLE_WINDOW_SCALE", 1); + + for (int index = 1; index < argc; ++index) + { + const std::string_view current{argv[index]}; + if (current == "--fbdev" && (index + 1) < argc) + { + options.framebuffer = argv[++index]; + } + else if (current == "--sdl") + { + options.backend = Backend::SdlWindow; + } + else if (current == "--width" && (index + 1) < argc) + { + options.width = std::stoi(argv[++index]); + } + else if (current == "--height" && (index + 1) < argc) + { + options.height = std::stoi(argv[++index]); + } + else if (current == "--window-scale" && (index + 1) < argc) + { + options.window_scale = std::stoi(argv[++index]); + } + else if (current == "--fullscreen") + { + options.fullscreen = true; + } + } + + return options; +} + +} // namespace + +int main(int argc, char** argv) +{ + try + { + const LaunchOptions options = parseOptions(argc, argv); + if (options.backend == Backend::Framebuffer) + { +#if defined(TRAIL_MATE_UCONSOLE_HAS_LEGACY_SURFACE) + trailmate::uconsole::UConsoleShellOptions shell{}; + shell.width = options.width; + shell.height = options.height; + trailmate::cardputer_zero::platform::device:: + LinuxFramebufferPlatform device{options.framebuffer}; + trailmate::uconsole::runUConsoleShell(device, shell); +#else + throw std::runtime_error( + "framebuffer backend is not compiled into this package; run trailmate-uconsole for the GTK UI"); +#endif + } + else if (options.backend == Backend::GtkWindow) + { + return trailmate::uconsole::gtk::runGtkUConsoleApp( + {.width = options.width, + .height = options.height, + .fullscreen = options.fullscreen, + .title = "Trail Mate uConsole"}); + } + else + { +#if defined(TRAIL_MATE_UCONSOLE_HAS_SDL) + trailmate::uconsole::desktop::SdlWindowPresenter window{ + {.width = options.width, + .height = options.height, + .scale = options.window_scale, + .fullscreen = options.fullscreen, + .title = "Trail Mate uConsole"}}; + trailmate::uconsole::UConsoleShellOptions shell{}; + shell.width = options.width; + shell.height = options.height; + trailmate::uconsole::runUConsoleShell(window, shell); +#else + throw std::runtime_error( + "SDL backend is not compiled into this package; run trailmate-uconsole for the GTK UI"); +#endif + } + return 0; + } + catch (const std::exception& ex) + { + std::cerr << "uConsole startup failed: " << ex.what() << '\n'; + return 1; + } +} diff --git a/apps/linux_uconsole/tests/uconsole_chat_dedup_smoke.cpp b/apps/linux_uconsole/tests/uconsole_chat_dedup_smoke.cpp new file mode 100644 index 00000000..8f44323c --- /dev/null +++ b/apps/linux_uconsole/tests/uconsole_chat_dedup_smoke.cpp @@ -0,0 +1,184 @@ +#include "chat/domain/chat_model.h" +#include "chat/infra/store/ram_store.h" +#include "chat/ports/i_mesh_adapter.h" +#include "chat/usecase/chat_service.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +class FakeMeshAdapter final : public ::chat::IMeshAdapter +{ + public: + void pushIncoming(::chat::NodeId from, + ::chat::MessageId msg_id, + const std::string& text) + { + ::chat::MeshIncomingText incoming{}; + incoming.channel = ::chat::ChannelId::PRIMARY; + incoming.from = from; + incoming.to = 0xFFFFFFFFUL; + incoming.msg_id = msg_id; + incoming.text = text; + incoming.timestamp = 1; + incoming.hop_limit = 3; + incoming.encrypted = true; + incoming_.push_back(incoming); + } + + bool sendText(::chat::ChannelId, + const std::string&, + ::chat::MessageId* out_msg_id, + ::chat::NodeId = 0) override + { + if (out_msg_id != nullptr) + { + *out_msg_id = 0; + } + return false; + } + + bool pollIncomingText(::chat::MeshIncomingText* out) override + { + if (incoming_.empty()) + { + return false; + } + if (out != nullptr) + { + *out = incoming_.front(); + } + incoming_.pop_front(); + return true; + } + + bool sendAppData(::chat::ChannelId, + std::uint32_t, + const std::uint8_t*, + std::size_t, + ::chat::NodeId = 0, + bool = false, + ::chat::MessageId = 0, + bool = false) override + { + return false; + } + + bool pollIncomingData(::chat::MeshIncomingData*) override + { + return false; + } + + void applyConfig(const ::chat::MeshConfig&) override {} + + bool isReady() const override + { + return true; + } + + bool pollIncomingRawPacket(std::uint8_t*, std::size_t&, std::size_t) override + { + return false; + } + + private: + std::deque<::chat::MeshIncomingText> incoming_{}; +}; + +} // namespace + +int expect(bool condition, const char* message) +{ + if (condition) + { + return 0; + } + std::cerr << message << '\n'; + return 1; +} + +int main() +{ + ::chat::ChatModel model; + FakeMeshAdapter adapter; + ::chat::RamStore store; + ::chat::ChatService service(model, + adapter, + store, + ::chat::MeshProtocol::Meshtastic); + const ::chat::ConversationId broadcast(::chat::ChannelId::PRIMARY, + 0, + ::chat::MeshProtocol::Meshtastic); + + adapter.pushIncoming(0x1234ABCDU, 0x42U, "test"); + adapter.pushIncoming(0x1234ABCDU, 0x42U, "test"); + adapter.pushIncoming(0x1234ABCDU, 0x42U, "test"); + service.processIncoming(); + + auto messages = store.loadRecent(broadcast, 10); + if (int rc = expect(messages.size() == 1U, + "duplicate incoming text was stored more than once")) + { + return rc; + } + if (int rc = expect(messages.front().from == 0x1234ABCDU, + "stored message sender changed")) + { + return rc; + } + if (int rc = expect(messages.front().msg_id == 0x42U, + "stored message id changed")) + { + return rc; + } + if (int rc = expect(messages.front().text == "test", + "stored message text changed")) + { + return rc; + } + if (int rc = expect(store.getUnread(broadcast) == 1, + "duplicate incoming text inflated unread count")) + { + return rc; + } + + adapter.pushIncoming(0x1234ABCDU, 0x43U, "next"); + adapter.pushIncoming(0x0000BEEFU, 0x42U, "same id from another node"); + service.processIncoming(); + + messages = store.loadRecent(broadcast, 10); + if (int rc = expect(messages.size() == 3U, + "distinct incoming identities were incorrectly suppressed")) + { + return rc; + } + if (int rc = expect(messages[1].msg_id == 0x43U, + "new message id from same node was suppressed")) + { + return rc; + } + if (int rc = expect(messages[2].from == 0x0000BEEFU, + "same message id from another node was suppressed")) + { + return rc; + } + if (int rc = expect(messages[2].msg_id == 0x42U, + "message id from another node changed")) + { + return rc; + } + if (int rc = expect(store.getUnread(broadcast) == 3, + "unread count does not match unique incoming messages")) + { + return rc; + } + + return 0; +} diff --git a/apps/linux_uconsole/tests/uconsole_chat_sqlite_store_smoke.cpp b/apps/linux_uconsole/tests/uconsole_chat_sqlite_store_smoke.cpp new file mode 100644 index 00000000..1dab0072 --- /dev/null +++ b/apps/linux_uconsole/tests/uconsole_chat_sqlite_store_smoke.cpp @@ -0,0 +1,163 @@ +#include "chat/linux_sqlite_chat_store.h" +#include "chat/ports/i_mesh_adapter.h" +#include "chat/usecase/chat_service.h" + +#include +#include +#include +#include +#include + +namespace +{ + +void set_env_var(const char* name, const std::string& value) +{ + setenv(name, value.c_str(), 1); +} + +class FakeMeshAdapter final : public ::chat::IMeshAdapter +{ + public: + void pushIncoming(::chat::NodeId from, + ::chat::MessageId msg_id, + const std::string& text) + { + ::chat::MeshIncomingText incoming{}; + incoming.channel = ::chat::ChannelId::PRIMARY; + incoming.from = from; + incoming.to = 0xFFFFFFFFUL; + incoming.msg_id = msg_id; + incoming.timestamp = 1; + incoming.text = text; + incoming.hop_limit = 3; + incoming.encrypted = false; + incoming_.push_back(incoming); + } + + bool sendText(::chat::ChannelId, + const std::string&, + ::chat::MessageId* out_msg_id, + ::chat::NodeId = 0) override + { + if (out_msg_id != nullptr) + { + *out_msg_id = 0; + } + return false; + } + + bool pollIncomingText(::chat::MeshIncomingText* out) override + { + if (incoming_.empty()) + { + return false; + } + if (out != nullptr) + { + *out = incoming_.front(); + } + incoming_.pop_front(); + return true; + } + + bool sendAppData(::chat::ChannelId, + std::uint32_t, + const std::uint8_t*, + std::size_t, + ::chat::NodeId = 0, + bool = false, + ::chat::MessageId = 0, + bool = false) override + { + return false; + } + + bool pollIncomingData(::chat::MeshIncomingData*) override + { + return false; + } + + void applyConfig(const ::chat::MeshConfig&) override {} + + bool isReady() const override + { + return true; + } + + bool pollIncomingRawPacket(std::uint8_t*, std::size_t&, std::size_t) + override + { + return false; + } + + private: + std::deque<::chat::MeshIncomingText> incoming_{}; +}; + +} // namespace + +int main() +{ + const auto root = + std::filesystem::temp_directory_path() / + "trailmate_uconsole_chat_sqlite_store_smoke"; + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root / "settings", ec); + std::filesystem::create_directories(root / "sd", ec); + std::filesystem::create_directories(root / "cache", ec); + + set_env_var("TRAIL_MATE_SETTINGS_ROOT", (root / "settings").string()); + set_env_var("TRAIL_MATE_SD_ROOT", (root / "sd").string()); + set_env_var("TRAIL_MATE_CACHE_ROOT", (root / "cache").string()); + + const ::chat::ConversationId broadcast(::chat::ChannelId::PRIMARY, + 0, + ::chat::MeshProtocol::Meshtastic); + + { + ::chat::ChatModel model; + FakeMeshAdapter adapter; + trailmate::linux_app::LinuxSqliteChatStore store; + ::chat::ChatService service(model, + adapter, + store, + ::chat::MeshProtocol::Meshtastic); + + adapter.pushIncoming(0x1234ABCDU, 0x42U, "persisted test"); + service.processIncoming(); + assert(store.getUnread(broadcast) == 1); + } + + { + trailmate::linux_app::LinuxSqliteChatStore store; + auto messages = store.loadRecent(broadcast, 8); + assert(messages.size() == 1U); + assert(messages.front().from == 0x1234ABCDU); + assert(messages.front().msg_id == 0x42U); + assert(messages.front().text == "persisted test"); + assert(store.getUnread(broadcast) == 1); + + auto conversations = store.loadConversationPage(0, 8, nullptr); + assert(conversations.size() == 1U); + assert(conversations.front().id == broadcast); + assert(conversations.front().unread == 1); + assert(conversations.front().preview == "persisted test"); + + store.setUnread(broadcast, 0); + } + + { + trailmate::linux_app::LinuxSqliteChatStore store; + assert(store.getUnread(broadcast) == 0); + auto messages = store.loadRecent(broadcast, 8); + assert(messages.size() == 1U); + store.clearAll(); + assert(store.loadRecent(broadcast, 8).empty()); + } + + std::filesystem::remove_all(root, ec); + return 0; +} diff --git a/apps/linux_uconsole/tests/uconsole_chat_workspace_smoke.cpp b/apps/linux_uconsole/tests/uconsole_chat_workspace_smoke.cpp new file mode 100644 index 00000000..28d97ab7 --- /dev/null +++ b/apps/linux_uconsole/tests/uconsole_chat_workspace_smoke.cpp @@ -0,0 +1,103 @@ +#include "app/linux_app_services.h" +#include "chat/domain/contact_types.h" +#include "chat/usecase/contact_service.h" +#include "uconsole/uconsole_chat_workspace_model.h" + +#include +#include +#include +#include + +namespace +{ + +void set_env_var(const char* name, const std::string& value) +{ + setenv(name, value.c_str(), 1); +} + +} // namespace + +int main() +{ + const auto root = + std::filesystem::temp_directory_path() / + "trailmate_uconsole_chat_workspace_smoke"; + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root / "settings", ec); + std::filesystem::create_directories(root / "sd", ec); + std::filesystem::create_directories(root / "cache", ec); + + set_env_var("TRAIL_MATE_SETTINGS_ROOT", (root / "settings").string()); + set_env_var("TRAIL_MATE_SD_ROOT", (root / "sd").string()); + set_env_var("TRAIL_MATE_CACHE_ROOT", (root / "cache").string()); + set_env_var("TRAIL_MATE_LORA_DISABLE", "1"); + set_env_var("TRAIL_MATE_GPS_VALID", "0"); + + trailmate::linux_app::LinuxAppServices services; + trailmate::uconsole::UConsoleChatWorkspaceModel model(services); + + ::chat::contacts::NodeUpdate node{}; + node.short_name = "AAEC"; + node.long_name = "lilygo-AAEC"; + node.has_last_seen = true; + node.last_seen = 123456U; + node.has_protocol = true; + node.protocol = + static_cast(::chat::contacts::NodeProtocolType::Meshtastic); + node.has_channel = true; + node.channel = 0; + node.has_hops_away = true; + node.hops_away = 2; + node.has_snr = true; + node.snr = 6.5F; + node.has_rssi = true; + node.rssi = -72.0F; + node.has_position = true; + node.position.valid = true; + node.position.latitude_i = 312304000; + node.position.longitude_i = 1214737000; + services.contacts().applyNodeUpdate(0x0C16AAECU, node); + + const auto snapshot = + model.snapshot(12, 20, trailmate::uconsole::ChatThreadSortMode::LastSeen); + bool found_node_thread = false; + std::size_t node_index = 0; + for (std::size_t index = 0; index < snapshot.conversations.size(); ++index) + { + const auto& item = snapshot.conversations[index]; + if (item.id.peer == 0x0C16AAECU) + { + found_node_thread = true; + node_index = index; + assert(item.group == "Nearby"); + assert(item.title == "AAEC"); + assert(item.preview.empty()); + assert(item.facts.find("hops 2") != std::string::npos); + } + } + assert(found_node_thread); + + assert(model.selectConversationAt(node_index, + 12, + trailmate::uconsole::ChatThreadSortMode:: + LastSeen)); + const auto selected = + model.snapshot(12, 20, trailmate::uconsole::ChatThreadSortMode::LastSeen); + assert(selected.active_conversation.peer == 0x0C16AAECU); + assert(!selected.nodes.empty()); + assert(selected.nodes.front().node_id == 0x0C16AAECU); + assert(selected.nodes.front().title == "AAEC"); + + const auto details = model.nodeDetails(0x0C16AAECU); + assert(details.found); + assert(details.has_position); + assert(details.lat > 31.0); + assert(details.lon > 121.0); + assert(!details.sections.empty()); + + std::filesystem::remove_all(root, ec); + return 0; +} diff --git a/apps/linux_uconsole/tests/uconsole_map_workspace_smoke.cpp b/apps/linux_uconsole/tests/uconsole_map_workspace_smoke.cpp new file mode 100644 index 00000000..3aa2b20b --- /dev/null +++ b/apps/linux_uconsole/tests/uconsole_map_workspace_smoke.cpp @@ -0,0 +1,231 @@ +#include "app/linux_app_services.h" +#include "chat/infra/meshtastic/mt_radio_config.h" +#include "chat/usecase/contact_service.h" +#include "meshtastic/config.pb.h" +#include "platform/ui/settings_store.h" +#include "uconsole/uconsole_map_workspace_model.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +void set_env_var(const char* name, const std::string& value) +{ + setenv(name, value.c_str(), 1); +} + +} // namespace + +int main() +{ + const auto root = + std::filesystem::temp_directory_path() / + "trailmate_uconsole_map_workspace_smoke"; + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root / "settings", ec); + std::filesystem::create_directories(root / "sd", ec); + std::filesystem::create_directories(root / "cache", ec); + + set_env_var("TRAIL_MATE_SETTINGS_ROOT", (root / "settings").string()); + set_env_var("TRAIL_MATE_SD_ROOT", (root / "sd").string()); + set_env_var("TRAIL_MATE_CACHE_ROOT", (root / "cache").string()); + set_env_var("TRAIL_MATE_GPS_VALID", "0"); + set_env_var("TRAIL_MATE_MAP_LAT", ""); + set_env_var("TRAIL_MATE_MAP_LNG", ""); + + ::platform::ui::settings_store::clear_namespace("uconsole_map"); + + trailmate::linux_app::LinuxAppServices services; + trailmate::uconsole::UConsoleMapWorkspaceModel model(services); + const auto snapshot = model.snapshot(); + + assert(snapshot.has_center); + assert(!snapshot.has_fix); + assert(snapshot.using_default_center); + assert(!snapshot.has_configured_center); + assert(snapshot.zoom == 2); + assert(std::abs(snapshot.lat) < 0.000001); + assert(std::abs(snapshot.lon) < 0.000001); + assert(snapshot.source_label == "OSM"); + assert(snapshot.columns == 5U); + assert(snapshot.rows == 3U); + assert(snapshot.center_tile_index == 7U); + assert(snapshot.tiles.size() == 15U); + for (const auto& tile : snapshot.tiles) + { + assert(tile.id.source == ::platform::linux_runtime::MapBaseSource::Osm); + assert(tile.id.z == 2); + } + + model.panByDisplayDelta(100.0, + 0.0, + 500, + 300, + snapshot.lat, + snapshot.lon, + snapshot.zoom, + true); + const auto panned = model.snapshot(); + assert(panned.has_manual_center); + assert(!panned.using_default_center); + assert(panned.zoom == 2); + assert(panned.lon < -40.0); + + model.clearManualCenter(); + const auto recentered = model.snapshot(); + assert(!recentered.has_manual_center); + assert(recentered.using_default_center); + + set_env_var("TRAIL_MATE_MAP_LAT", "31.2304"); + set_env_var("TRAIL_MATE_MAP_LNG", "121.4737"); + model.setZoom(20); + assert(model.snapshot().zoom == 18); + model.setZoom(0); + assert(model.snapshot().zoom == 1); + model.setZoom(12); + + model.setContourEnabled(false); + const auto contours_off = model.snapshot(); + assert(!contours_off.contour_enabled); + assert(contours_off.contour_tiles.empty()); + + model.setContourEnabled(true); + model.setContourUltraFineEnabled(false); + model.setEarthdataToken(" test-earthdata-token "); + assert(model.earthdataToken() == "test-earthdata-token"); + assert(model.earthdataTokenConfigured()); + const auto contours_missing = model.snapshot(); + assert(contours_missing.contour_enabled); + assert(!contours_missing.contour_ultra_fine_enabled); + assert(contours_missing.earthdata_token_configured); + assert(contours_missing.contour_profiles.size() == 2U); + assert(contours_missing.contour_profiles[0] == "major-100"); + assert(contours_missing.contour_profiles[1] == "minor-50"); + assert(contours_missing.contour_tiles.size() == + contours_missing.tiles.size() * + contours_missing.contour_profiles.size()); + assert(contours_missing.contour_available_count == 0U); + assert(contours_missing.contour_missing_count == + contours_missing.contour_tiles.size()); + + const auto contour_tile_path = contours_missing.contour_tiles.front().path; + std::filesystem::create_directories(contour_tile_path.parent_path(), ec); + { + std::ofstream out(contour_tile_path, std::ios::binary); + out << "png"; + } + const auto contours_available = model.snapshot(); + assert(contours_available.contour_available_count == 1U); + assert(contours_available.contour_missing_count + 1U == + contours_available.contour_tiles.size()); + assert(contours_available.contour_tiles.front().available); + assert(contours_available.contour_tiles.front().path == contour_tile_path); + + model.setContourUltraFineEnabled(true); + model.setZoom(17); + const auto contours_ultra = model.snapshot(); + assert(contours_ultra.contour_ultra_fine_enabled); + assert(contours_ultra.contour_profiles.size() == 2U); + assert(contours_ultra.contour_profiles[0] == "major-25"); + assert(contours_ultra.contour_profiles[1] == "minor-5"); + model.setContourEnabled(false); + assert(model.snapshot().contour_tiles.empty()); + model.setZoom(12); + + ::chat::contacts::NodeUpdate mqtt_node{}; + mqtt_node.short_name = "MQ01"; + mqtt_node.long_name = "MQTT Node"; + mqtt_node.has_last_seen = true; + mqtt_node.last_seen = 123456U; + mqtt_node.has_via_mqtt = true; + mqtt_node.via_mqtt = true; + mqtt_node.has_position = true; + mqtt_node.position.valid = true; + mqtt_node.position.latitude_i = 312304000; + mqtt_node.position.longitude_i = 1214737000; + services.contacts().applyNodeUpdate(0x1234ABCDU, mqtt_node); + + model.setShowMqttNodes(true); + const auto mqtt_visible = model.snapshot(); + assert(mqtt_visible.show_mqtt_nodes); + assert(mqtt_visible.visible_mqtt_node_count == 1U); + assert(mqtt_visible.hidden_mqtt_node_count == 0U); + assert(mqtt_visible.nodes.size() == 1U); + assert(mqtt_visible.nodes[0].via_mqtt); + assert(mqtt_visible.nodes[0].x_fraction >= 0.0); + assert(mqtt_visible.nodes[0].x_fraction <= 1.0); + assert(mqtt_visible.nodes[0].y_fraction >= 0.0); + assert(mqtt_visible.nodes[0].y_fraction <= 1.0); + + const auto click_point = + model.coordinateAtDisplayPoint(mqtt_visible, 240.0, 120.0, 500, 300); + assert(click_point.valid); + assert(std::isfinite(click_point.lat)); + assert(std::isfinite(click_point.lon)); + const int clicked_zoom = mqtt_visible.zoom; + model.zoomInAt(click_point.lat, click_point.lon); + const auto zoomed_in = model.snapshot(); + assert(zoomed_in.has_manual_center); + assert(zoomed_in.zoom == clicked_zoom + 1); + assert(std::abs(zoomed_in.lat - click_point.lat) < 0.000001); + assert(std::abs(zoomed_in.lon - click_point.lon) < 0.000001); + model.zoomOutAt(click_point.lat, click_point.lon); + const auto zoomed_out = model.snapshot(); + assert(zoomed_out.zoom == clicked_zoom); + assert(std::abs(zoomed_out.lat - click_point.lat) < 0.000001); + assert(std::abs(zoomed_out.lon - click_point.lon) < 0.000001); + model.setZoom(12); + + ::chat::contacts::NodeUpdate lora_node{}; + lora_node.short_name = "LORA"; + lora_node.long_name = "LoRa Node"; + lora_node.has_last_seen = true; + lora_node.last_seen = 123457U; + lora_node.has_via_mqtt = true; + lora_node.via_mqtt = false; + lora_node.has_position = true; + lora_node.position.valid = true; + lora_node.position.latitude_i = 312305000; + lora_node.position.longitude_i = 1214747000; + lora_node.has_hops_away = true; + lora_node.hops_away = 1; + services.contacts().applyNodeUpdate(0x2234ABCDU, lora_node); + + model.setShowMqttNodes(false); + const auto mqtt_hidden = model.snapshot(); + assert(!mqtt_hidden.show_mqtt_nodes); + assert(mqtt_hidden.visible_mqtt_node_count == 0U); + assert(mqtt_hidden.hidden_mqtt_node_count == 1U); + assert(mqtt_hidden.nodes.size() == 1U); + assert(!mqtt_hidden.nodes[0].via_mqtt); + assert(mqtt_hidden.nodes[0].node_id == 0x2234ABCDU); + + ::chat::MeshConfig mt_config{}; + mt_config.region = static_cast( + meshtastic_Config_LoRaConfig_RegionCode_CN); + mt_config.use_preset = true; + mt_config.modem_preset = static_cast( + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + mt_config.tx_power = 0; + const auto radio = ::chat::meshtastic::deriveRadioConfig(mt_config); + assert(radio.region_code == meshtastic_Config_LoRaConfig_RegionCode_CN); + assert(radio.sync_word == ::chat::meshtastic::kMeshtasticLoraSyncWord); + assert(radio.preamble_len == ::chat::meshtastic::kMeshtasticLoraPreambleLen); + assert(radio.crc_len == ::chat::meshtastic::kMeshtasticLoraCrcLen); + assert(radio.sf == 11U); + assert(radio.cr_denom == 5U); + assert(radio.freq_mhz >= 470.0f); + assert(radio.freq_mhz <= 510.0f); + assert(std::abs(radio.freq_mhz - 433.175f) > 1.0f); + + std::filesystem::remove_all(root, ec); + return 0; +} diff --git a/apps/linux_uconsole/tests/uconsole_meshtastic_node_payload_smoke.cpp b/apps/linux_uconsole/tests/uconsole_meshtastic_node_payload_smoke.cpp new file mode 100644 index 00000000..7012ada6 --- /dev/null +++ b/apps/linux_uconsole/tests/uconsole_meshtastic_node_payload_smoke.cpp @@ -0,0 +1,354 @@ +#include "chat/infra/meshtastic/mt_node_payload.h" +#include "meshtastic/config.pb.h" +#include "pb_encode.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +int expect(bool condition, const char* message) +{ + if (condition) + { + return 0; + } + std::cerr << message << '\n'; + return 1; +} + +bool encodeNodeInfoPayload(const meshtastic_NodeInfo& node, + meshtastic_Data* out) +{ + if (out == nullptr) + { + return false; + } + + *out = meshtastic_Data_init_default; + out->portnum = meshtastic_PortNum_NODEINFO_APP; + + pb_ostream_t stream = + pb_ostream_from_buffer(out->payload.bytes, sizeof(out->payload.bytes)); + if (!pb_encode(&stream, meshtastic_NodeInfo_fields, &node)) + { + return false; + } + out->payload.size = stream.bytes_written; + return true; +} + +bool encodeUserPayload(const meshtastic_User& user, meshtastic_Data* out) +{ + if (out == nullptr) + { + return false; + } + + *out = meshtastic_Data_init_default; + out->portnum = meshtastic_PortNum_NODEINFO_APP; + + pb_ostream_t stream = + pb_ostream_from_buffer(out->payload.bytes, sizeof(out->payload.bytes)); + if (!pb_encode(&stream, meshtastic_User_fields, &user)) + { + return false; + } + out->payload.size = stream.bytes_written; + return true; +} + +bool encodePositionPayload(const meshtastic_Position& position, + meshtastic_Data* out) +{ + if (out == nullptr) + { + return false; + } + + *out = meshtastic_Data_init_default; + out->portnum = meshtastic_PortNum_POSITION_APP; + + pb_ostream_t stream = + pb_ostream_from_buffer(out->payload.bytes, sizeof(out->payload.bytes)); + if (!pb_encode(&stream, meshtastic_Position_fields, &position)) + { + return false; + } + out->payload.size = stream.bytes_written; + return true; +} + +} // namespace + +int main() +{ + ::chat::meshtastic::NodePayloadDecodeContext context{}; + context.fallback_node_id = 0xAABBCCDDU; + context.snr = 7.5F; + context.rssi = -81.25F; + context.timestamp = 123456U; + context.hops_away = 2; + context.channel = 1; + context.via_mqtt = false; + + meshtastic_NodeInfo node = meshtastic_NodeInfo_init_default; + node.num = 0x01020304U; + node.has_user = true; + std::strncpy(node.user.short_name, + "ABCD", + sizeof(node.user.short_name) - 1); + std::strncpy(node.user.long_name, + "Alpha Bravo", + sizeof(node.user.long_name) - 1); + node.user.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + node.user.hw_model = meshtastic_HardwareModel_TBEAM; + const std::array mac{ + {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}}; + std::memcpy(node.user.macaddr, mac.data(), mac.size()); + node.user.public_key.size = 32; + for (std::size_t index = 0; index < node.user.public_key.size; ++index) + { + node.user.public_key.bytes[index] = + static_cast(index + 1U); + } + node.has_position = true; + node.position.has_latitude_i = true; + node.position.latitude_i = 312304000; + node.position.has_longitude_i = true; + node.position.longitude_i = 1214737000; + node.position.has_altitude = true; + node.position.altitude = 33; + node.position.timestamp = 123400U; + node.position.precision_bits = 24; + node.position.HDOP = 88; + node.has_device_metrics = true; + node.device_metrics.has_battery_level = true; + node.device_metrics.battery_level = 86; + node.device_metrics.has_voltage = true; + node.device_metrics.voltage = 4.12F; + node.channel = 1; + node.via_mqtt = true; + node.has_hops_away = true; + node.hops_away = 3; + node.is_ignored = true; + node.is_key_manually_verified = true; + + meshtastic_Data node_data = meshtastic_Data_init_default; + if (int rc = expect(encodeNodeInfoPayload(node, &node_data), + "failed to encode NodeInfo test payload")) + { + return rc; + } + + ::chat::meshtastic::DecodedNodePayload decoded_node{}; + if (int rc = expect(::chat::meshtastic::decodeNodeInfoPayload( + node_data, context, &decoded_node), + "shared decoder rejected full NodeInfo payload")) + { + return rc; + } + if (int rc = expect(decoded_node.node_id == node.num, + "NodeInfo num was not preferred over header sender")) + { + return rc; + } + if (int rc = expect(decoded_node.has_user && decoded_node.short_name == "ABCD", + "NodeInfo short name was not decoded")) + { + return rc; + } + if (int rc = expect(decoded_node.long_name == "Alpha Bravo", + "NodeInfo long name was not decoded")) + { + return rc; + } + if (int rc = expect(decoded_node.has_position && + decoded_node.position.latitude_i == + node.position.latitude_i && + decoded_node.position.longitude_i == + node.position.longitude_i, + "NodeInfo embedded position was not decoded")) + { + return rc; + } + if (int rc = expect(decoded_node.via_mqtt, + "NodeInfo via_mqtt flag was not decoded")) + { + return rc; + } + if (int rc = expect(decoded_node.has_device_metrics && + decoded_node.device_metrics.battery_level == 86, + "NodeInfo device metrics were not decoded")) + { + return rc; + } + if (int rc = expect(decoded_node.has_public_key && + decoded_node.public_key[0] == 1U && + decoded_node.public_key[31] == 32U, + "NodeInfo public key was not decoded")) + { + return rc; + } + + context.snr = std::numeric_limits::quiet_NaN(); + context.timestamp = 0; + node.snr = 6.25F; + node.last_heard = 777U; + if (int rc = expect(encodeNodeInfoPayload(node, &node_data), + "failed to encode NodeInfo fallback payload")) + { + return rc; + } + ::chat::meshtastic::DecodedNodePayload decoded_fallback{}; + if (int rc = expect(::chat::meshtastic::decodeNodeInfoPayload( + node_data, context, &decoded_fallback), + "shared decoder rejected NodeInfo fallback payload")) + { + return rc; + } + if (int rc = expect(std::fabs(decoded_fallback.snr - 6.25F) < 0.001F && + decoded_fallback.timestamp == 777U, + "NodeInfo snr/last_heard fallback was not decoded")) + { + return rc; + } + context.snr = 7.5F; + context.timestamp = 123456U; + + const auto update = decoded_node.toNodeUpdate(); + if (int rc = expect(update.has_position && update.position.valid, + "NodeUpdate projection lost position")) + { + return rc; + } + if (int rc = expect(update.has_via_mqtt && update.via_mqtt, + "NodeUpdate projection lost via_mqtt")) + { + return rc; + } + if (int rc = expect(update.has_snr && update.has_rssi && + update.has_hops_away && update.has_channel && + update.has_role && update.has_hw_model, + "NodeUpdate projection did not preserve known facts")) + { + return rc; + } + + ::chat::meshtastic::DecodedNodePayload unknown_projection{}; + unknown_projection.snr = std::numeric_limits::quiet_NaN(); + unknown_projection.rssi = std::numeric_limits::quiet_NaN(); + unknown_projection.hops_away = 0xFF; + unknown_projection.channel = 0xFF; + unknown_projection.role = 0xFF; + const auto unknown_update = unknown_projection.toNodeUpdate(); + if (int rc = expect(!unknown_update.has_last_seen && + !unknown_update.has_snr && + !unknown_update.has_rssi && + !unknown_update.has_hops_away && + !unknown_update.has_channel && + !unknown_update.has_role && + !unknown_update.has_hw_model && + !unknown_update.has_public_key, + "NodeUpdate projection marked unknown facts as present")) + { + return rc; + } + + meshtastic_User legacy_user = meshtastic_User_init_default; + std::strncpy(legacy_user.short_name, + "LG", + sizeof(legacy_user.short_name) - 1); + std::strncpy(legacy_user.long_name, + "Legacy User", + sizeof(legacy_user.long_name) - 1); + legacy_user.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + legacy_user.hw_model = meshtastic_HardwareModel_T_ECHO; + + meshtastic_Data legacy_data = meshtastic_Data_init_default; + if (int rc = expect(encodeUserPayload(legacy_user, &legacy_data), + "failed to encode legacy User test payload")) + { + return rc; + } + + context.via_mqtt = true; + ::chat::meshtastic::DecodedNodePayload decoded_legacy{}; + if (int rc = expect(::chat::meshtastic::decodeNodeInfoPayload( + legacy_data, context, &decoded_legacy), + "shared decoder rejected legacy User payload")) + { + return rc; + } + if (int rc = expect(decoded_legacy.node_id == context.fallback_node_id, + "legacy User did not keep header sender as node id")) + { + return rc; + } + if (int rc = expect(decoded_legacy.has_user && + decoded_legacy.short_name == "LG" && + decoded_legacy.long_name == "Legacy User", + "legacy User names were not decoded")) + { + return rc; + } + if (int rc = expect(decoded_legacy.via_mqtt, + "legacy User did not inherit header via_mqtt")) + { + return rc; + } + + meshtastic_Data invalid_user_data = meshtastic_Data_init_default; + invalid_user_data.portnum = meshtastic_PortNum_NODEINFO_APP; + invalid_user_data.payload.size = 1; + invalid_user_data.payload.bytes[0] = 0; + ::chat::meshtastic::DecodedNodePayload invalid_node{}; + if (int rc = expect(!::chat::meshtastic::decodeNodeInfoPayload( + invalid_user_data, context, &invalid_node), + "shared decoder accepted an empty NodeInfo/User payload")) + { + return rc; + } + + meshtastic_Position position = meshtastic_Position_init_zero; + position.has_latitude_i = true; + position.latitude_i = 223344550; + position.has_longitude_i = true; + position.longitude_i = 1142233440; + position.has_altitude_hae = true; + position.altitude_hae = 99; + + meshtastic_Data position_data = meshtastic_Data_init_default; + if (int rc = expect(encodePositionPayload(position, &position_data), + "failed to encode Position test payload")) + { + return rc; + } + + ::chat::meshtastic::DecodedPositionPayload decoded_position{}; + if (int rc = expect(::chat::meshtastic::decodePositionPayload( + position_data, + 0x0BADCAFEU, + 999U, + &decoded_position), + "shared decoder rejected Position payload")) + { + return rc; + } + if (int rc = expect(decoded_position.node_id == 0x0BADCAFEU && + decoded_position.position.valid && + decoded_position.position.has_altitude && + decoded_position.position.altitude == 99 && + decoded_position.position.timestamp == 999U, + "Position payload projection was wrong")) + { + return rc; + } + + return 0; +} diff --git a/apps/linux_uconsole/tools/sx1262_probe.cpp b/apps/linux_uconsole/tools/sx1262_probe.cpp new file mode 100644 index 00000000..9582686d --- /dev/null +++ b/apps/linux_uconsole/tools/sx1262_probe.cpp @@ -0,0 +1,159 @@ +#include "platform/linux/sx126x_radio.h" + +#include +#include +#include +#include +#include + +namespace +{ + +void print_config(const platform::linux_runtime::Sx126xRadioConfig& config) +{ + std::printf("spi=%s\n", config.spi_device.c_str()); + std::printf("gpiochip=%s\n", config.gpiochip.c_str()); + std::printf("power_gpio=%d\n", config.power_gpio); + std::printf("reset_gpio=%d\n", config.reset_gpio); + std::printf("busy_gpio=%d\n", config.busy_gpio); + std::printf("irq_gpio=%d\n", config.irq_gpio); + std::printf("spi_speed_hz=%lu\n", + static_cast(config.spi_speed_hz)); + std::printf("dio2_as_rf_switch=%s\n", + config.dio2_as_rf_switch ? "true" : "false"); + std::printf("dio3_tcxo_1v8=%s\n", + config.dio3_tcxo_1v8 ? "true" : "false"); +} + +void print_lora_config(const platform::linux_runtime::Sx126xLoRaConfig& config) +{ + std::printf("lora_freq_mhz=%.3f\n", static_cast(config.freq_mhz)); + std::printf("lora_bw_khz=%.1f\n", static_cast(config.bw_khz)); + std::printf("lora_sf=%u\n", static_cast(config.sf)); + std::printf("lora_cr=4/%u\n", static_cast(config.cr)); + std::printf("lora_tx_power_dbm=%d\n", static_cast(config.tx_power_dbm)); + std::printf("lora_preamble=%u\n", static_cast(config.preamble_len)); + std::printf("lora_sync_word=0x%02X\n", static_cast(config.sync_word)); + std::printf("lora_crc_len=%u\n", static_cast(config.crc_len)); +} + +void print_stats(const platform::linux_runtime::Sx126xRadioStats& stats) +{ + std::printf("online=%s\n", stats.online ? "true" : "false"); + std::printf("rx_packets=%lu\n", static_cast(stats.rx_packets)); + std::printf("tx_packets=%lu\n", static_cast(stats.tx_packets)); + std::printf("rx_crc_errors=%lu\n", + static_cast(stats.rx_crc_errors)); + std::printf("rx_header_errors=%lu\n", + static_cast(stats.rx_header_errors)); + std::printf("rx_timeouts=%lu\n", + static_cast(stats.rx_timeouts)); + std::printf("rx_invalid_lengths=%lu\n", + static_cast(stats.rx_invalid_lengths)); + std::printf("rx_read_errors=%lu\n", + static_cast(stats.rx_read_errors)); + std::printf("last_irq_flags=0x%04lX\n", + static_cast(stats.last_irq_flags)); +} + +void usage() +{ + std::printf("usage: trailmate-sx1262-probe [--configure] [--rx-seconds N]\n"); +} + +} // namespace + +int main(int argc, char** argv) +{ + bool configure = false; + int rx_seconds = 0; + + for (int i = 1; i < argc; ++i) + { + if (std::strcmp(argv[i], "--configure") == 0) + { + configure = true; + } + else if (std::strcmp(argv[i], "--rx-seconds") == 0 && i + 1 < argc) + { + rx_seconds = std::atoi(argv[++i]); + configure = true; + } + else if (std::strcmp(argv[i], "--help") == 0) + { + usage(); + return 0; + } + else + { + usage(); + return 1; + } + } + + const auto radio_config = + platform::linux_runtime::Sx126xRadio::defaultConfigFromEnvironment(); + const auto lora_config = + platform::linux_runtime::Sx126xRadio::defaultLoRaConfigFromEnvironment(); + auto& radio = platform::linux_runtime::Sx126xRadio::instance(); + + std::printf("Trail Mate SX1262 probe\n"); + print_config(radio_config); + std::printf("hardware_candidate=%s\n", + platform::linux_runtime::Sx126xRadio::hardwareCandidatePresent() + ? "true" + : "false"); + + if (!radio.acquire(radio_config)) + { + std::printf("acquire=false\n"); + std::printf("last_error=%s\n", radio.lastError()); + return 2; + } + + std::printf("acquire=true\n"); + + if (configure) + { + print_lora_config(lora_config); + if (!radio.configureLoRa(lora_config)) + { + std::printf("configure=false\n"); + std::printf("last_error=%s\n", radio.lastError()); + radio.release(); + return 3; + } + std::printf("configure=true\n"); + } + + if (rx_seconds > 0) + { + if (!radio.startReceive()) + { + std::printf("rx_start=false\n"); + std::printf("last_error=%s\n", radio.lastError()); + radio.release(); + return 4; + } + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(rx_seconds); + while (std::chrono::steady_clock::now() < deadline) + { + platform::linux_runtime::Sx126xPacket packet{}; + if (radio.pollReceive(&packet)) + { + std::printf("rx_packet len=%lu rssi=%.1f snr=%.1f\n", + static_cast(packet.size), + static_cast(packet.rssi_dbm), + static_cast(packet.snr_db)); + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + + print_stats(radio.stats()); + std::printf("last_error=%s\n", radio.lastError()); + radio.release(); + return 0; +} diff --git a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp index 941a3c3b..ff9b9c6d 100644 --- a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp +++ b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp @@ -286,6 +286,59 @@ bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count return has_snapshot; } +GpsDiagnosticsSnapshot diagnostics() +{ + GpsDiagnosticsSnapshot snapshot{}; + snapshot.supported = ::boards::gat562_mesh_evb_pro::Gat562Board::instance().hasGPSHardware(); + snapshot.enabled = is_enabled(); + snapshot.powered = is_powered(); + snapshot.ready = ::boards::gat562_mesh_evb_pro::Gat562Board::instance().isGPSReady(); + + GpsState data = get_data(); + snapshot.has_fix = data.valid; + snapshot.satellites = data.satellites; + + GnssStatus status{}; + std::size_t sat_count = 0; + ::gps::GnssSatInfo sats[::gps::kMaxGnssSats]{}; + if (get_gnss_snapshot(sats, ::gps::kMaxGnssSats, &sat_count, &status)) + { + snapshot.sats_in_view = status.sats_in_view; + snapshot.sats_in_use = status.sats_in_use; + } + + if (!snapshot.supported) + { + snapshot.code = ::gps::GpsDiagnosticCode::Disabled; + } + else if (!snapshot.enabled) + { + snapshot.code = ::gps::GpsDiagnosticCode::NotEnabled; + } + else if (!snapshot.powered) + { + snapshot.code = ::gps::GpsDiagnosticCode::PowerOff; + } + else if (!snapshot.ready) + { + snapshot.code = ::gps::GpsDiagnosticCode::TransportNotReady; + } + else if (!snapshot.has_fix) + { + snapshot.code = ::gps::GpsDiagnosticCode::NoFix; + } + else + { + snapshot.code = ::gps::GpsDiagnosticCode::OK; + } + return snapshot; +} + +void set_receiver_init_config(const GpsReceiverInitConfig& config) +{ + (void)config; +} + uint32_t last_motion_ms() { return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().gpsLastMotionMs(); diff --git a/boards/gat562_mesh_evb_pro/src/sx1262_radio_packet_io.cpp b/boards/gat562_mesh_evb_pro/src/sx1262_radio_packet_io.cpp index 6b538238..af1bbf32 100644 --- a/boards/gat562_mesh_evb_pro/src/sx1262_radio_packet_io.cpp +++ b/boards/gat562_mesh_evb_pro/src/sx1262_radio_packet_io.cpp @@ -3,21 +3,19 @@ #include "boards/gat562_mesh_evb_pro/board_profile.h" #include "boards/gat562_mesh_evb_pro/gat562_board.h" #include "chat/infra/meshcore/mc_region_presets.h" -#include "chat/infra/meshtastic/mt_region.h" +#include "chat/infra/meshtastic/mt_radio_config.h" #include #include #include #include -#include namespace boards::gat562_mesh_evb_pro { namespace { -constexpr uint8_t kMeshtasticSyncWord = 0x2B; constexpr uint8_t kMeshCoreSyncWord = 0x12; constexpr uint16_t kDefaultPreambleLen = 16; constexpr uint8_t kDefaultCrcLen = 2; @@ -33,106 +31,19 @@ float normalizeBandwidthKhz(float bw_khz) return bw_khz; } -void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset preset, - bool wide_lora, - float& bw_khz, - uint8_t& sf, - uint8_t& cr_denom) -{ - switch (preset) - { - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - bw_khz = 125.0f; - sf = 12; - cr_denom = 8; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - bw_khz = 250.0f; - sf = 11; - cr_denom = 5; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - bw_khz = 250.0f; - sf = 10; - cr_denom = 8; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - bw_khz = 250.0f; - sf = 9; - cr_denom = 5; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - bw_khz = 250.0f; - sf = 8; - cr_denom = 8; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - default: - bw_khz = wide_lora ? 500.0f : 250.0f; - sf = wide_lora ? 7 : 8; - cr_denom = 5; - break; - } -} - Sx1262RadioPacketIo::AppliedRadioConfig deriveMeshtasticRadioConfig(const ::chat::MeshConfig& config) { Sx1262RadioPacketIo::AppliedRadioConfig out{}; - auto region_code = static_cast(config.region); - if (region_code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) - { - region_code = meshtastic_Config_LoRaConfig_RegionCode_CN; - } - - const ::chat::meshtastic::RegionInfo* region = ::chat::meshtastic::findRegion(region_code); - if (!region) - { - region = ::chat::meshtastic::findRegion(meshtastic_Config_LoRaConfig_RegionCode_CN); - } - - float bw_khz = 250.0f; - uint8_t sf = 11; - uint8_t cr_denom = 5; - if (config.use_preset && region) - { - modemPresetToParams(static_cast(config.modem_preset), - region->wide_lora, - bw_khz, - sf, - cr_denom); - } - else - { - bw_khz = normalizeBandwidthKhz(config.bandwidth_khz); - sf = std::clamp(config.spread_factor, 5, 12); - cr_denom = std::clamp(config.coding_rate, 5, 8); - if (region) - { - if (bw_khz < 7.8f) bw_khz = 7.8f; - if (!region->wide_lora && bw_khz > 500.0f) bw_khz = 500.0f; - if (region->wide_lora && bw_khz > 1625.0f) bw_khz = 1625.0f; - } - } - - float freq_mhz = ::chat::meshtastic::estimateFrequencyMhz(config.region, config.modem_preset); - if (config.override_frequency_mhz > 0.0f) - { - freq_mhz = config.override_frequency_mhz; - } - freq_mhz += config.frequency_offset_mhz; - - out.freq_mhz = freq_mhz; - out.bw_khz = bw_khz; - out.sf = sf; - out.cr = cr_denom; - out.tx_power = std::clamp(config.tx_power == 0 ? 17 : config.tx_power, -9, 20); - if (region && region->power_limit_dbm > 0 && out.tx_power > static_cast(region->power_limit_dbm)) - { - out.tx_power = static_cast(region->power_limit_dbm); - } - out.preamble_len = kDefaultPreambleLen; - out.sync_word = kMeshtasticSyncWord; - out.crc_len = kDefaultCrcLen; + const ::chat::meshtastic::RadioConfig radio = + ::chat::meshtastic::deriveRadioConfig(config); + out.freq_mhz = radio.freq_mhz; + out.bw_khz = radio.bw_khz; + out.sf = radio.sf; + out.cr = radio.cr_denom; + out.tx_power = std::clamp(radio.tx_power_dbm, -9, 20); + out.preamble_len = radio.preamble_len; + out.sync_word = radio.sync_word; + out.crc_len = radio.crc_len; return out; } diff --git a/boards/tdeck/include/boards/tdeck/platform_esp_board_runtime.h b/boards/tdeck/include/boards/tdeck/platform_esp_board_runtime.h index 3e585bbf..e3bf2d5e 100644 --- a/boards/tdeck/include/boards/tdeck/platform_esp_board_runtime.h +++ b/boards/tdeck/include/boards/tdeck/platform_esp_board_runtime.h @@ -9,11 +9,9 @@ namespace platform::esp::boards::detail inline void initializeBoard(bool waking_from_sleep) { -#if HAS_GPS - ::boards::tdeck::board.begin(); -#else + // Defer GPS UART open until AppConfig is loaded so saved baud/profile policy is + // applied on the first and only GPS init. ::boards::tdeck::board.begin(NO_HW_GPS); -#endif if (waking_from_sleep) { diff --git a/boards/tdeck/include/boards/tdeck/tdeck_board.h b/boards/tdeck/include/boards/tdeck/tdeck_board.h index d293601c..6ce10fa6 100644 --- a/boards/tdeck/include/boards/tdeck/tdeck_board.h +++ b/boards/tdeck/include/boards/tdeck/tdeck_board.h @@ -104,6 +104,12 @@ class TDeckBoard : public BoardBase, uint8_t crc_len) override; // GpsBoard + void setGPSReceiverInitConfig(const gps::GpsReceiverInitConfig& config) override + { + gps_init_config_ = config; + gps_receiver_init_configured_ = true; + } + gps::GpsReceiverProtocol getGPSReceiverProtocol() const override { return gps_receiver_protocol_; } bool initGPS() override; void deinitGPS() override; void setGPSOnline(bool online) override @@ -139,6 +145,9 @@ class TDeckBoard : public BoardBase, private: uint32_t devices_probe_ = 0; + gps::GpsReceiverInitConfig gps_init_config_{}; + bool gps_receiver_init_configured_ = false; + gps::GpsReceiverProtocol gps_receiver_protocol_ = gps::GpsReceiverProtocol::Unknown; uint8_t brightness_ = 8; uint8_t keyboard_brightness_ = 127; uint8_t rotation_ = 0; diff --git a/boards/tdeck/src/tdeck_board.cpp b/boards/tdeck/src/tdeck_board.cpp index 9fbbdc97..93465343 100644 --- a/boards/tdeck/src/tdeck_board.cpp +++ b/boards/tdeck/src/tdeck_board.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,12 @@ constexpr float kTDeckRadioCurrentLimitMa = 140.0f; constexpr uint8_t kTDeckBacklightStepMax = 16; constexpr uint32_t kTDeckBacklightOffDelayMs = 3; constexpr uint32_t kTDeckBacklightWakeDelayUs = 30; +// LilyGo's T-Deck GPSShield reference starts the L76K/CASIC path at 9600. +constexpr uint32_t kGpsDefaultBaud = 9600; +constexpr uint8_t kGpsProfileAuto = 0; +constexpr uint8_t kGpsProfileNmeaPassive = 1; +constexpr uint8_t kGpsProfileUbxLegacy = 2; +constexpr uint8_t kGpsProfileUbxModern = 3; uint8_t s_backlight_level = 0; #if DEVICE_MAX_BRIGHTNESS_LEVEL > 0 @@ -205,6 +212,22 @@ int read_battery_percent_adc_fallback() { return battery_percent_from_mv(read_battery_mv_adc_fallback()); } + +bool is_supported_gps_baud(uint32_t baud) +{ + switch (baud) + { + case 4800: + case 9600: + case 19200: + case 38400: + case 57600: + case 115200: + return true; + default: + return false; + } +} } // namespace TDeckBoard::TDeckBoard() @@ -225,22 +248,70 @@ TDeckBoard* TDeckBoard::getInstance() bool TDeckBoard::initGPS() { - // Board init owns the physical UART. Receiver probing/configuration is runtime-owned. + // Board init owns the physical UART. Receiver behavior/configuration is runtime-owned. + const uint32_t manual_baud = gps_init_config_.baud; + const bool explicit_ubx_profile = gps_init_config_.profile == kGpsProfileUbxLegacy || + gps_init_config_.profile == kGpsProfileUbxModern; + uint32_t selected_baud = kGpsDefaultBaud; + const char* selected_source = "legacy_default"; + const char* signature = "not_probed"; + gps::GpsReceiverProtocol protocol = gps::GpsReceiverProtocol::Unknown; + + if (is_supported_gps_baud(manual_baud)) + { + selected_baud = manual_baud; + selected_source = "manual_direct"; + protocol = explicit_ubx_profile ? gps::GpsReceiverProtocol::Ubx : gps::GpsReceiverProtocol::Unknown; + signature = explicit_ubx_profile ? "ubx_profile" : "not_probed"; + } + else if (gps_receiver_init_configured_ && gps_init_config_.profile == kGpsProfileAuto) + { + selected_baud = kGpsDefaultBaud; + selected_source = "auto_direct"; + } + else + { + // Keep Auto passive here; receiver-specific commands belong in runtime policy, + // after the module/protocol has been selected. + selected_baud = kGpsDefaultBaud; + if (gps_init_config_.profile == kGpsProfileNmeaPassive) + { + protocol = gps::GpsReceiverProtocol::Nmea; + signature = "nmea_profile"; + selected_source = "nmea_passive"; + } + else if (gps_init_config_.profile == kGpsProfileUbxLegacy || + gps_init_config_.profile == kGpsProfileUbxModern) + { + protocol = gps::GpsReceiverProtocol::Ubx; + signature = "ubx_profile"; + selected_source = gps_init_config_.profile == kGpsProfileUbxLegacy ? "ubx_legacy" : "ubx_modern"; + } + } + Serial1.end(); - Serial1.begin(38400, SERIAL_8N1, GPS_RX, GPS_TX); + delay(10); + Serial1.begin(selected_baud, SERIAL_8N1, GPS_RX, GPS_TX); + const esp_err_t rx_pull_rc = gpio_set_pull_mode(static_cast(GPS_RX), GPIO_PULLUP_ONLY); delay(100); gps_.attach(&Serial1); setGPSOnline(true); - Serial.printf("[TDeckBoard] GPS UART ready baud=%lu rx=%d tx=%d\n", - 38400UL, + gps_receiver_protocol_ = protocol; + Serial.printf("[TDeckBoard] GPS UART ready baud=%lu rx=%d tx=%d probe=%s signature=%s protocol=%s rx_pullup=%d\n", + static_cast(selected_baud), GPS_RX, - GPS_TX); + GPS_TX, + selected_source, + signature, + gps::gpsReceiverProtocolName(gps_receiver_protocol_), + rx_pull_rc == ESP_OK ? 1 : 0); return true; } void TDeckBoard::deinitGPS() { Serial1.end(); + gps_receiver_protocol_ = gps::GpsReceiverProtocol::Unknown; setGPSOnline(false); } diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index acab2fbb..2c0b9a3e 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -1,8 +1,8 @@ # TrailMateLinuxSources.cmake # -# Shared source lists and helper functions for the Cardputer Zero Linux line. -# Both apps/linux_sim and apps/linux_rpi include this file so that a single -# source list drives the common, core-module, and ui_shell targets. +# Shared source lists and helper functions for the Trail Mate Linux line. +# apps/linux_sim, apps/linux_rpi, and apps/linux_uconsole include this file so +# that a single source list drives common app services and core-module targets. # # Usage from an app CMakeLists.txt: # @@ -77,9 +77,12 @@ _trailmate_set_linux_paths() set(TRAIL_MATE_LINUX_COMMON_SOURCES # app facade + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/linux_app_services.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/linux_app_facade.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/linux_demo_world.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/chat/linux_noop_mesh_adapter.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/chat/linux_raw_lora_mesh_adapter.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/chat/linux_sqlite_chat_store.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/demo_app.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/demo_app_runner.cpp" # core primitives @@ -88,6 +91,11 @@ set(TRAIL_MATE_LINUX_COMMON_SOURCES # platform/linux shared infrastructure (P4) "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/runtime_paths.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/env_config.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/map_contour_tile_generator.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/map_diagnostics.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/map_tile_cache.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/runtime_packet_log.cpp" + "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/linux/sx126x_radio.cpp" # platform::ui::* runtime implementations "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/device_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/firmware_update_runtime.cpp" @@ -110,11 +118,31 @@ set(TRAIL_MATE_LINUX_COMMON_SOURCES "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/contact_store_core.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/mesh_protocol_utils.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshcore/mc_region_presets.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/compression/unishox2.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_codec_pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_node_payload.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_packet_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_protocol_helpers.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_radio_config.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_region.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/node_store_blob_format.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/node_store_core.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/contact_service.cpp" + # Meshtastic nanopb runtime + generated descriptors used by Linux LoRa RX/TX. + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb/pb_common.c" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb/pb_decode.c" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb/pb_encode.c" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/channel.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/config.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/device_ui.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/mesh.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/module_config.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/portnums.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/telemetry.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/xmodem.pb.cpp" # modules/core_team "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_chat.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_location_marker.cpp" @@ -128,6 +156,8 @@ set(TRAIL_MATE_LINUX_COMMON_SOURCES "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/usecase/team_pairing_coordinator.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/usecase/team_service.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/usecase/team_track_sampler.cpp" + # modules/core_gps + "${TRAIL_MATE_REPO_ROOT}/modules/core_gps/src/usecase/gnss_skyplot_presenter.cpp" # modules/core_hostlink "${TRAIL_MATE_REPO_ROOT}/modules/core_hostlink/src/hostlink_session.cpp" # modules/core_sys @@ -264,9 +294,6 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/system_notification.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/toast/toast_widget.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/top_bar.cpp" - # region presets - "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshcore/mc_region_presets.cpp" - "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/meshtastic/mt_region.cpp" ) # --------------------------------------------------------------------------- @@ -312,6 +339,10 @@ function(trailmate_apply_linux_common_warnings target_name) endfunction() function(trailmate_add_linux_common target_name) + find_package(CURL REQUIRED) + find_package(SQLite3 REQUIRED) + find_package(OpenSSL QUIET) + add_library(${target_name} ${TRAIL_MATE_LINUX_COMMON_SOURCES} ) @@ -322,7 +353,20 @@ function(trailmate_add_linux_common target_name) target_compile_definitions(${target_name} PUBLIC TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 ) - target_link_libraries(${target_name} PUBLIC Threads::Threads) + target_link_libraries(${target_name} + PUBLIC + Threads::Threads + CURL::libcurl + SQLite::SQLite3 + ) + if(OpenSSL_FOUND) + target_compile_definitions(${target_name} + PUBLIC TRAIL_MATE_HAS_OPENSSL=1 + ) + target_link_libraries(${target_name} + PUBLIC OpenSSL::Crypto + ) + endif() if(WIN32) target_link_libraries(${target_name} PUBLIC ws2_32) endif() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d585d6f..f5a34ab4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -123,6 +123,7 @@ trail-mate/ linux_sim/ linux_rpi/ linux_unoq/ + linux_uconsole/ modules/ core_sys/ @@ -146,6 +147,7 @@ trail-mate/ common/ rpi/ unoq/ + uconsole/ docs/ tools/ @@ -409,6 +411,26 @@ Purpose: The platform differences here should stay in `platform/linux/*`, not leak back into shared modules. +## `apps/linux_uconsole` + +Purpose: + +- provide the desktop-class Linux handheld shell for uConsole/AIO2-class targets +- preserve a separate interaction model from compact Cardputer Zero shells +- consume Linux app services and presentation models instead of compact LVGL pages +- leave room for Linux-native search, diagnostics, data/package management, and background-job workflows +- use SQLite-backed local state and Linux-native map tile cache/indexing where + those capabilities are outside MCU constraints + +AIO2 support belongs below this app shell in platform/runtime adapters. The UI +may report AIO2 capability status, but AIO2 must not become the product +navigation or layout boundary. + +The current Linux service composition entrypoint is `LinuxAppServices` under +`platform/linux/common`. Compact LVGL shells use `MinimalLinuxAppFacade` as an +adapter over those services; uConsole shells should depend on +`LinuxAppServices` and presentation models directly. + --- ## Migration principles diff --git a/docs/devices/gps-settings-guide.md b/docs/devices/gps-settings-guide.md new file mode 100644 index 00000000..8a008570 --- /dev/null +++ b/docs/devices/gps-settings-guide.md @@ -0,0 +1,478 @@ +# GPS Settings Guide + +This guide explains the GPS settings shown in the device Settings app. It is a +user-facing companion to `docs/specs/gps.md`: the spec defines runtime semantics, +while this guide explains which settings to change and which settings to leave +alone. + +## Quick Recommendations + +For most users, start with this profile: + +| Setting | Recommended value | Why | +| --- | --- | --- | +| GPS Enabled | `ON` | Allows the runtime to power and poll GPS. | +| Receiver Baud | `Auto` | Lets the board use its default compatible baud. | +| Probe Window | `900 ms` | Balanced startup detection window. | +| Receiver Profile | `Auto` | Avoids assuming a receiver family. | +| RXM Init | `Auto` | Lets the runtime decide whether receiver power-mode commands are safe. | +| GNSS Init | `Auto` | Lets the runtime decide whether constellation commands are safe. | +| NMEA Init | `Auto` | Lets the runtime decide whether internal NMEA setup is safe. | +| Location Mode | `High Accuracy` | Best default when power is available. | +| Satellite Systems | `GPS+BDS+GAL` | Good default for multi-GNSS receivers. | +| Position Strategy | `Continuous` | Keeps GPS available for map, tracker, and team features. | +| Update Interval | `1s` or `5s` | Use `1s` for live tracking; use `5s` for lower update volume. | +| Altitude Reference | `Sea Level` | Most user-facing altitude displays expect MSL-style altitude. | +| Coordinate Format | `DD` | Decimal degrees are the safest default for maps and sharing. | +| NMEA Export | `OFF` | Leave off unless an external tool needs NMEA output. | +| NMEA Sentences | `GGA+RMC+GSA+GSV` | Full diagnostic set if export is enabled. | + +For ordinary T-Deck with an external GPS or GPS shield, prefer conservative +receiver settings until the module is proven: + +| Setting | Safer value | +| --- | --- | +| Receiver Baud | `9600` if Auto does not find the module | +| Receiver Profile | `NMEA Passive` for generic NMEA/CASIC/L76K-style modules | +| RXM Init | `Skip` unless the receiver is known to support the command | +| GNSS Init | `Skip` unless the receiver is known to support the command | +| NMEA Init | `Skip` unless the receiver is known to support the command | + +## Mental Model + +The GPS settings fall into three separate groups. Do not treat every setting as +an accuracy knob. + +1. Transport and receiver compatibility. + + These settings decide how the firmware opens and initializes the receiver: + baud rate, probe timing, receiver family profile, and whether receiver-specific + setup commands may be sent. + +2. Runtime behavior. + + These settings decide how the GPS runtime behaves once the receiver is usable: + enabled state, collection interval, power strategy, location mode, and + constellation selection. + +3. Presentation and external export. + + These settings decide how position data is displayed or exported. They do not + make the internal GPS parser more accurate. + +The key distinction: + +- `GPS Enabled` means user intent. +- `Ready` in diagnostics means the transport path is open. +- A valid fix means the receiver has produced a usable position. +- NMEA export is for external consumers and is separate from the internal GPS + stream used by the firmware. + +## Setting Reference + +### GPS Enabled + +Controls whether the GPS runtime is allowed to power and poll the receiver. + +Use `ON` when you want maps, tracker, team position, time sync, or sky plot data. +Use `OFF` to stop live GPS collection and power down the receiver when the board +supports doing so. + +This is not the same as fix state. `GPS Enabled = ON` does not guarantee that the +receiver is connected, speaking, or fixed. + +### Receiver Baud + +Controls the UART baud rate used to talk to the GPS receiver. + +Options: + +- `Auto` +- `9600` +- `38400` +- `115200` +- `57600` +- `19200` +- `4800` + +Use `Auto` first. Use a fixed baud when the module documentation states a known +rate or Auto opens the transport but the receiver never produces valid NMEA. + +For ordinary T-Deck GPS shield or L76K/CASIC-style modules, `9600` is the safest +manual starting point. + +Changing this setting may require restarting GPS or rebooting before the physical +UART is reopened with the new rate. + +### Probe Window + +Controls the short startup window used for receiver detection or compatibility +probing. + +Options: + +- `250 ms` +- `500 ms` +- `900 ms` +- `1600 ms` + +Use `900 ms` as the default. Shorter windows start faster but can miss slow or +cold receivers. Longer windows help slow modules but increase startup time. + +If the receiver occasionally appears after boot but not consistently, try +`1600 ms`. + +### Receiver Profile + +Tells the runtime how conservative it should be with receiver-specific behavior. + +Options: + +- `Auto` +- `NMEA Passive` +- `u-blox Legacy` +- `u-blox Modern` + +Use `Auto` for normal operation. Use `NMEA Passive` for generic NMEA modules, +CASIC/L76K-style modules, or unknown GPS modules where the safest behavior is to +listen for NMEA and avoid vendor-specific commands. + +Use `u-blox Legacy` or `u-blox Modern` only when the installed receiver is known +to be u-blox and you want the firmware to allow UBX configuration commands. +Selecting a u-blox profile for a non-u-blox receiver can prevent useful GPS +traffic or create confusing diagnostics. + +### RXM Init + +Controls whether the firmware may send receiver power-mode configuration. + +Options: + +- `Auto` +- `Skip` +- `Send` + +Use `Auto` normally. Use `Skip` for unknown, generic NMEA, CASIC/L76K-style, or +problematic external modules. Use `Send` only when the receiver is known to +support the command path selected by the active receiver profile. + +On ordinary T-Deck, `Auto` is intentionally conservative and skips UBX +configuration unless the receiver/protocol profile allows it. + +### GNSS Init + +Controls whether the firmware may send constellation configuration to the +receiver. + +Options: + +- `Auto` +- `Skip` +- `Send` + +Use `Auto` normally. Use `Skip` if the receiver is generic NMEA, unknown, or if +changing Satellite Systems causes GPS traffic to stop. Use `Send` only for known +receivers that support the configured command family. + +This setting gates whether `Satellite Systems` can be pushed into the receiver. +If it is skipped, the UI preference may be saved but the receiver can continue +using its own internal constellation configuration. + +### NMEA Init + +Controls whether the firmware may send internal NMEA message-rate configuration +to the receiver. + +Options: + +- `Auto` +- `Skip` +- `Send` + +Use `Auto` normally. Use `Skip` for unknown or NMEA-passive receivers. Use `Send` +only for receivers that support the selected command family. + +This setting is about receiver configuration, not the external NMEA export +feature. + +### Location Mode + +Selects the desired receiver behavior profile. + +Options: + +- `High Accuracy` +- `Power Save` +- `Fix Only` + +Use `High Accuracy` when you care about live maps, tracking, or stable team +position updates. + +Use `Power Save` when battery life matters more than update responsiveness. Some +receiver/configuration combinations may ignore this setting. + +Use `Fix Only` when the product goal is to obtain a position occasionally rather +than keep a continuously warm receiver. + +On u-blox-style configuration paths, power-save behavior may be disabled when +GLONASS is selected because that combination is not supported by the current +receiver command logic. + +### Satellite Systems + +Selects the GNSS constellations the firmware should request from compatible +receivers. + +Options: + +- `GPS+BDS+GAL` +- `GPS` +- `GPS+BDS` +- `GPS+GAL` +- `GPS+BDS+GAL+GLO` + +Use `GPS+BDS+GAL` as the default. It gives broad sky coverage without enabling +every possible constellation. + +Use `GPS` for older or simpler receivers, or when you want the most conservative +configuration. + +Use `GPS+BDS+GAL+GLO` only when the receiver supports it and you do not need +power-save receiver mode. GLONASS can conflict with some power-save paths. + +This setting only changes the receiver when GNSS initialization is allowed and +supported. It does not create satellites on a receiver that lacks that GNSS +capability. + +### Position Strategy + +Controls when GPS should stay powered. + +Options: + +- `Continuous` +- `Motion Wake` +- `Low Power Off` + +Use `Continuous` for normal navigation, map following, track recording, and team +location sharing. + +Use `Motion Wake` when the board has a supported motion sensor and you want GPS +to stay active while moving, then power down after the motion idle timeout. + +Use `Low Power Off` when you want to keep GPS off for battery saving. In this +mode, diagnostics may show `GPSD_POWER_OFF` even though `GPS Enabled` is still +on. + +Team mode can force GPS on while team features need live position. + +### Update Interval + +Controls the collection or publish interval for GPS observations. + +Options: + +- `1s` +- `2s` +- `5s` +- `10s` + +Use `1s` for live navigation and detailed tracks. Use `5s` or `10s` when battery +life and smaller logs matter more. + +This is not the UART poll interval. The firmware may still read the receiver +more frequently internally. This setting controls how often GPS observations are +published or sampled by runtime policy. + +Low battery power tiers may force a longer effective interval than the UI value. + +### Altitude Reference + +Controls how altitude should be interpreted or displayed. + +Options: + +- `Sea Level` +- `Ellipsoid` + +Use `Sea Level` for most user-facing altitude displays. Use `Ellipsoid` only when +you know your workflow expects raw ellipsoid height rather than mean sea level +style altitude. + +### Coordinate Format + +Controls how coordinates are displayed in UI surfaces that honor this setting. + +Options: + +- `DD` +- `DMS` +- `UTM` + +Use `DD` for decimal degrees. It is the most compatible format for maps, links, +and sharing. + +Use `DMS` when you need degrees-minutes-seconds notation. Use `UTM` for grid +navigation workflows. + +This setting does not change the GPS receiver or the internal coordinate system. + +### NMEA Export + +Controls user-visible NMEA output for external consumers. + +Options: + +- `OFF` +- `1Hz` +- `5Hz` + +Leave this `OFF` unless another tool needs NMEA output. Turning it on is not +required for the internal GPS runtime, map, tracker, or sky plot. + +NMEA export is separate from the internal GPS stream. Do not use it as a privacy +control and do not assume disabling export disables internal GPS parsing. + +### NMEA Sentences + +Selects which sentence group to export when NMEA Export is enabled. + +Options: + +- `GGA+RMC+GSA+GSV` +- `RMC+GSA+GSV` +- `GGA+RMC` + +Use `GGA+RMC+GSA+GSV` when debugging or feeding tools that need satellite +diagnostics. Use `GGA+RMC` for compact position-only consumers. Use +`RMC+GSA+GSV` when altitude/quality from GGA is not needed. + +This setting does not decide which internal receiver sentences the firmware +needs for its own GPS features. + +### Diagnostics + +Opens a snapshot of GPS health. + +Important fields: + +- `Code`: high-level diagnostic result such as `GPSD_OK`, `GPSD_NO_FIX`, + `GPSD_NO_UART_TRAFFIC`, or `GPSD_POWER_OFF`. +- `Supported`: whether the board build supports GPS. +- `Enabled`: user intent from GPS Enabled. +- `Powered`: whether the runtime currently has GPS powered. +- `Ready`: whether the transport path is open. +- `Fix`: whether a valid position is available. +- `Sats`, `View`, `Use`: satellite counts when known. +- `Chars` and `Recent`: receiver/parser traffic counters. +- `Last RX`: age of the last receiver byte. +- `Poll`: internal receiver poll interval. +- `Publish`: effective GPS collection interval. + +Do not read `Ready=1` as "GPS module is healthy." On UART-backed boards it can +mean only that the UART transport is open. + +## Recipes + +### Ordinary T-Deck With LilyGo GPS Shield Or Generic NMEA Module + +Start with: + +- GPS Enabled: `ON` +- Receiver Baud: `9600` +- Probe Window: `900 ms` +- Receiver Profile: `NMEA Passive` +- RXM Init: `Skip` +- GNSS Init: `Skip` +- NMEA Init: `Skip` +- Location Mode: `High Accuracy` +- Satellite Systems: `GPS+BDS+GAL` +- Position Strategy: `Continuous` +- Update Interval: `1s` or `5s` + +This profile listens for receiver output without assuming the module is u-blox. +It is the safest first configuration for user-replaceable GPS modules. + +If valid NMEA appears and the module documentation confirms u-blox support, you +can try a u-blox profile and `Auto`/`Send` init policies later. Change one +setting at a time. + +### Known u-blox Receiver + +Start with: + +- Receiver Profile: `u-blox Modern` for modern u-blox modules, or + `u-blox Legacy` for older modules. +- RXM Init: `Auto` +- GNSS Init: `Auto` +- NMEA Init: `Auto` +- Satellite Systems: choose the constellations supported by the module. + +Use `Send` only when Auto is too conservative and you are sure the receiver +supports the command. If GPS traffic stops after changing these settings, return +the relevant init policy to `Skip` and reboot. + +### Battery-Saving Tracker + +Start with: + +- Position Strategy: `Motion Wake` if the board has a supported motion sensor. +- Update Interval: `5s` or `10s`. +- Location Mode: `Power Save`. +- NMEA Export: `OFF`. + +If the board has no supported motion sensor, Motion Wake may behave like +Continuous or may not provide the expected savings. + +### Debugging No Fix + +Use this sequence: + +1. Set GPS Enabled to `ON`. +2. Set Position Strategy to `Continuous`. +3. Set Receiver Baud to `Auto`; if no traffic appears, try the documented module + baud, commonly `9600`. +4. Set Receiver Profile to `NMEA Passive`. +5. Set RXM Init, GNSS Init, and NMEA Init to `Skip`. +6. Open Diagnostics. +7. Wait outdoors or near a window with antenna sky view. + +Interpret the result: + +| Diagnostic pattern | Meaning | +| --- | --- | +| `Powered=0` | GPS is off due to strategy, board support, or runtime power policy. | +| `Ready=0` | Transport is not open or the board has not prepared GPS. | +| `Ready=1`, `Chars=0`, `Last RX=never` | UART is open but no receiver traffic has been seen. Check baud, wiring, power, and reset. | +| `Recent` rises but no fix | Receiver is speaking but has not fixed yet, or the stream is noise. Check raw logs and sky view. | +| Valid NMEA appears but no fix | Receiver is alive; wait longer, move outdoors, or check antenna. | +| Random `[GPS][RAW_BURST]` appears after LoRa TX | See the T-Deck GPS UART / LoRa TX noise known issue document. | + +## T-Deck UART Noise Note + +On ordinary T-Deck, LoRa TX can induce bytes on the external GPS UART RX path if +the GPS TX line is floating or weakly driven. GPS settings can reduce parser +confusion, but they cannot remove the physical noise from `GPIO44 / UART0_RX`. + +For this issue: + +- Keep Receiver Profile at `NMEA Passive` unless the receiver is proven u-blox. +- Keep RXM Init, GNSS Init, and NMEA Init at `Skip` while debugging unknown + modules. +- Use Diagnostics and serial logs to distinguish real GPS data from UART noise. +- If changing LoRa TX power changes GPS UART byte counts, treat it as a hardware + coupling symptom, not as a GPS accuracy setting. + +See `docs/devices/lilygo-tdeck-gps-uart-lora-noise.md` for the hardware +verification procedure. + +## What Not To Do + +- Do not enable u-blox profiles for unknown GPS modules just to "try harder." +- Do not set all init policies to `Send` unless the receiver supports them. +- Do not treat GPS Enabled as a guarantee of a physical module or a fix. +- Do not treat NMEA Export as required for internal GPS operation. +- Do not use Update Interval to diagnose UART noise. +- Do not change several compatibility settings at once. Change one setting, + reboot or restart GPS if needed, then check Diagnostics. + diff --git a/docs/devices/lilygo-tdeck-gps-uart-lora-noise.md b/docs/devices/lilygo-tdeck-gps-uart-lora-noise.md new file mode 100644 index 00000000..a104bada --- /dev/null +++ b/docs/devices/lilygo-tdeck-gps-uart-lora-noise.md @@ -0,0 +1,510 @@ +# LilyGo T-Deck GPS UART / LoRa TX Noise Known Issue + +This document records a known hardware-facing issue observed on the ordinary +LilyGo T-Deck, plus the current software mitigation in this repository. + +For general GPS Settings guidance, see +`docs/devices/gps-settings-guide.md`. + +Scope: + +- Applies to ordinary `tdeck`. +- Does not describe `tdeck_pro`. +- Concerns the external GPS / GPS shield UART path on `GPIO43/GPIO44`. +- The issue was diagnosed from real-device logs and the ordinary T-Deck + schematic in May 2026. + +## Summary + +The ordinary T-Deck exposes `UART0_TX/UART0_RX` on external connectors. In this +repository those lines are also used as the GPS UART: + +- `GPS_TX = GPIO43` +- `GPS_RX = GPIO44` +- MCU schematic labels: `U0TXD / UART0_TX` and `U0RXD / UART0_RX` + +LoRa uses separate pins and does not logically share the GPS UART: + +- LoRa SPI / control: `CS=9`, `BUSY=13`, `RST=17`, `DIO1=45`, + `SCK=40`, `MOSI=41`, `MISO=38` + +Observed behavior shows that LoRa transmission can cause real bytes to appear on +the GPS UART RX path. These bytes are not valid NMEA and are not UBX frames. The +current leading explanation is that `GPIO44 / UART0_RX / GPS_RX` is either +floating or weakly driven by the attached GPS/shield path, so LoRa TX RF or power +transients are sampled by the UART peripheral as serial frames. + +This is not primarily a TinyGPS memory corruption symptom. Logs with the +additional diagnostics show that the TinyGPS character counter and the UART +read counter rise together. + +## Critical Distinctions + +Keep these concepts separate during future debugging: + +- `GPS ready=1`: the board runtime has opened the UART and marked the transport + path ready. On ordinary T-Deck this does not prove that a GPS module is powered, + attached, or speaking. +- `read_bytes` / `read_10s`: bytes actually read from the UART stream. +- `chars_total` / `chars_10s`: bytes accepted by TinyGPS in older diagnostics, + or NMEA candidate bytes after the software mitigation described below. +- Valid GPS stream: NMEA sentences beginning with `$`, or a recognized binary + receiver protocol if deliberately supported. +- LoRa TX event: radio transmission on the SX1262 path. It does not write to the + GPS UART in software, but it can disturb a floating or weakly driven RX line. + +## Observed Symptoms + +The recurring symptom set: + +- GPS service starts and reports ready. +- No fix is acquired. +- No satellites are visible or used. +- Early raw bytes appear on the GPS UART, but they are not NMEA. +- After long idle periods, sending a LoRa message causes GPS UART traffic to + resume. +- The resumed traffic is random-looking, has no NMEA comma structure, and has no + UBX sync pattern. +- The induced UART byte count is TX-power dependent in real-device testing: + `22 dBm` produced hundreds of UART bytes after a LoRa transmit, while `4 dBm` + produced none during the same monitoring window. + +Representative startup log: + +```text +[GPS Task] Loop 2: GPS ready=1, valid=0, mutex_ok=1 +[GPS][RAW] reason=first64_no_nmea len=64 hex=B1 B9 4E 8E 8E AA B0 56 4E 8E 00 80 B6 AE 4E 48 8E B6 6A 76 A7 03 8E A0 6A F6 76 8E 00 A0 B6 F6 76 49 8A A0 B6 F6 76 5E 00 A0 B6 76 76 5E 8E 2A B6 76 56 5E 8E A0 B6 76 56 49 8E 80 B6 76 56 4E +[GPS][RAW_BURST] n=1 reason=non_nmea_burst64 len=64 bytes=64 gap_ms=0 age_ms=62 printable=23 comma=0 high=33 zero=5 ubx_sync=0 hex=8E 8E 39 2F 56 4E 8E 8E A0 76 56 4E 40 00 A0 76 5E 8E 8E 00 B0 B6 48 87 8E 8E B2 B6 76 2B 03 00 AA B6 F6 76 3A 03 80 B6 B6 76 56 1B AC 8E A0 B4 B6 76 85 B6 76 76 5E 8E 8E B9 76 C8 48 00 00 B4 +[GPS Task] GPS loop processed 512 characters this cycle (total: 512, read_bytes=512) +``` + +Representative idle health log before a LoRa transmit: + +```text +[GPS] health ready=1 powered=1 state=nofix sats=0 view=0 use=0 chars_total=676 chars_10s=0 read_10s=0 poll_ms=250 collection_ms=60000 loops=439 +``` + +Representative LoRa transmit followed by GPS UART noise: + +```text +[MT][TX] queue text id=30FEAC58 dest=FFFFFFFF logical_ch=0 len=4 +[MT][TX_ROUTE] id=30FEAC58 dest=FFFFFFFF port=1 logical_ch=0 wire_ch=8 path=CHANNEL payload=20 +[GPS][RAW_BURST] n=2 reason=non_nmea_burst64 len=64 bytes=64 gap_ms=112996 age_ms=2 printable=26 comma=0 high=35 zero=2 ubx_sync=0 hex=49 B9 76 56 48 8E 8E B9 2F 5E 4E 48 00 80 35 27 5E 8E 00 80 B6 42 87 48 8E 80 B4 B6 AF 02 8E 80 B4 B6 B9 8E 40 80 B6 B6 76 76 8E A0 B4 F6 76 5E 45 DB B6 B6 76 C8 48 80 6A F6 76 5E 48 B0 B6 76 +[GPS Task] GPS loop processed 92 characters this cycle (total: 768, read_bytes=92) +[GPS Task] GPS loop processed 210 characters this cycle (total: 978, read_bytes=210) +[GPS Task] GPS loop processed 128 characters this cycle (total: 1106, read_bytes=128) +[GPS] health ready=1 powered=1 state=nofix sats=0 view=0 use=0 chars_total=1106 chars_10s=430 read_10s=430 poll_ms=250 collection_ms=60000 loops=479 +``` + +The important evidence is `chars_10s=430 read_10s=430`. The UART read count and +the GPS character counter rose together. That means the firmware read real bytes +from the UART path. + +### TX Power Correlation Test + +A later A/B test changed only the LoRa TX power class during the same style of +manual message transmission. + +At `22 dBm`, one outgoing LoRa text packet was followed by GPS UART reads: + +```text +[GPS] health ready=1 powered=1 state=nofix sats=0 view=0 use=0 chars_total=267 chars_10s=0 read_10s=0 poll_ms=250 collection_ms=60000 loops=279 +[MT][TX] queue text id=064BE2C3 dest=FFFFFFFF logical_ch=0 len=4 +[MT][TX_ROUTE] id=064BE2C3 dest=FFFFFFFF port=1 logical_ch=0 wire_ch=8 path=CHANNEL payload=20 +[GPS Task] GPS loop processed 59 characters this cycle (total: 326, read_bytes=59) +[GPS Task] GPS loop processed 212 characters this cycle (total: 538, read_bytes=212) +[GPS Task] GPS loop processed 159 characters this cycle (total: 697, read_bytes=159) +[GPS] health ready=1 powered=1 state=nofix sats=0 view=0 use=0 chars_total=697 chars_10s=430 read_10s=430 poll_ms=250 collection_ms=60000 loops=319 +``` + +The per-loop UART reads sum to `430` bytes (`59 + 212 + 159`), and the next +health line confirms `read_10s=430`. + +At `4 dBm`, the same style of LoRa transmit did not produce GPS UART reads in +the observed window: + +```text +[GPS] health ready=1 powered=1 state=nofix sats=0 view=0 use=0 chars_total=697 chars_10s=0 read_10s=0 poll_ms=250 collection_ms=60000 loops=840 +[MT][TX] queue text id=064BE2C4 dest=FFFFFFFF logical_ch=0 len=4 +[MT][TX_ROUTE] id=064BE2C4 dest=FFFFFFFF port=1 logical_ch=0 wire_ch=8 path=CHANNEL payload=20 +[GPS] health ready=1 powered=1 state=nofix sats=0 view=0 use=0 chars_total=697 chars_10s=0 read_10s=0 poll_ms=250 collection_ms=60000 loops=880 +[MT][RX] from=0C16AAEC to=FFFFFFFF id=064BE2C4 flags=0xE6 ch=8 next=0 relay=228 len=20 +[MT][IMPLICIT_ACK] observed self-broadcast id=064BE2C4 relay=000000E4 next=00000000 ch=8 +[GPS] health ready=1 powered=1 state=nofix sats=0 view=0 use=0 chars_total=697 chars_10s=0 read_10s=0 poll_ms=250 collection_ms=60000 loops=960 +``` + +`chars_total` stayed at `697`, and both `chars_10s` and `read_10s` stayed at +`0`. This does not prove the exact coupling path by itself, but it strongly +supports the hardware-facing hypothesis: higher LoRa TX energy can disturb the +GPS UART RX input, while a much lower TX power may be below the threshold needed +to create sampled UART frames. + +The same boot also showed `chars_10s=267 read_10s=678` during startup noise. +That means the current lightweight NMEA gate can reject some noise, but random +bytes can still occasionally look like an NMEA candidate. For hardware +diagnosis, `read_10s` remains the most direct indicator of whether the MCU +actually received bytes on `UART0_RX / GPIO44`. + +## Hardware Analysis + +UART RX is a high-impedance input. A healthy attached GPS module should drive it +to a stable idle high level and only pull it low to send start bits. If the GPS +module is not connected, not powered, reset, in high impedance state, wired to +the wrong pin, or otherwise not driving its TX output, `GPIO44` can behave like +a floating input. + +During LoRa transmission, several physical effects can create edges on such a +line: + +- RF coupling from the SX1262 transmit path, antenna, or nearby conductors. +- 3V3 rail and ground transients caused by the higher TX current. +- Ground reference movement between the radio, MCU, external connector, and GPS + module. +- Long or poorly referenced jumper/shield wiring acting as an antenna. + +The UART peripheral does not know about GPS or NMEA. If it sees a falling edge +that resembles a start bit at the configured baud rate, it samples the following +bit periods and places a byte in the FIFO. Noise can therefore produce byte +patterns such as `49 B9 76 56 48 8E ...`. + +The captured noise does not match valid GPS stream structure: + +- NMEA should contain `$` starts and comma-separated ASCII fields. +- The burst logs show `comma=0`. +- UBX should contain `B5 62` sync. +- The burst logs show `ubx_sync=0`. +- The byte stream has many high-bit bytes, which is unlike normal NMEA. + +## Ruled-Out Or Lower-Priority Explanations + +### TinyGPS Counter Memory Corruption + +Lower priority for this specific symptom. + +The added diagnostics showed `read_10s` and `chars_10s` rising by the same +amount. If memory corruption were the primary cause, a stronger signal would be +`chars_10s > 0` while `read_10s = 0`, or `[GPS][COUNT_CHECK]` entries showing a +TinyGPS delta without matching UART reads. + +### LoRa Software Writing Into The GPS UART + +No evidence found. + +The ordinary T-Deck pin map separates LoRa SPI/control lines from +`GPIO43/GPIO44`. The observed bytes are read from the GPS UART stream after +radio TX, but the software LoRa path does not intentionally write to that stream. + +### u-blox-Specific Configuration Failure + +Not the leading explanation for the noise bursts. + +The ordinary T-Deck must not assume that the GPS receiver is u-blox. Current +runtime policy skips UBX configuration unless the detected protocol or explicit +profile permits it. The observed LoRa-correlated bytes also occur when receiver +config writes are not active. + +## Current Software Mitigations + +The repository currently applies these mitigations for ordinary T-Deck GPS +bring-up and diagnostics: + +1. GPS UART access is serialized. + + GPS initialization/configuration and the collector task must not read/write or + reopen the same physical UART concurrently. The GPS service uses a recursive + UART lock around init/config/open/close and parser loop access. + +2. Ordinary T-Deck does not blindly send UBX configuration. + + UBX-specific GNSS and NMEA configuration is skipped unless the receiver is + known or explicitly configured as u-blox. + +3. `GPIO44 / GPS_RX` enables an internal pull-up after `Serial1.begin`. + + UART idle is high. The weak pull-up is a safe diagnostic/mitigation for a + floating RX line, but it may not be strong enough to overcome RF or wiring + coupling. + +4. Diagnostics distinguish UART reads from parser input. + + The logs include: + + - per-loop `read_bytes` + - health-window `read_10s` + - `[GPS][COUNT_CHECK]` if UART reads and parser count diverge + - `[GPS][RAW_BURST]` for non-NMEA bursts after idle periods + +5. Non-NMEA noise is filtered before TinyGPS. + + The GPS loop only feeds TinyGPS when the incoming stream looks like an NMEA + candidate sentence beginning with `$`. Random UART noise is still counted as + UART traffic through `read_bytes` / `read_10s`, but it should no longer make + TinyGPS's `charsProcessed()` counter look like valid GPS parser progress. + +This software mitigation protects GPS state and diagnostics. It does not solve +the underlying hardware signal-integrity or connection issue. + +## Expected Logs After The Mitigation + +When LoRa TX still induces noise but the parser filter works, future logs should +look like this pattern: + +```text +[GPS][RAW_BURST] ... +[GPS Task] GPS loop processed 0 characters this cycle (total: , read_bytes=) +[GPS] health ... chars_10s=0 read_10s= ... +``` + +Interpretation: + +- `read_10s > 0`: the UART RX line still saw real bytes. +- `chars_10s = 0`: those bytes were rejected before TinyGPS because they were + not NMEA candidates. + +If future logs show this instead: + +```text +[GPS][COUNT_CHECK] read_bytes=0 tinygps_delta= +``` + +then reopen the memory-corruption hypothesis. + +## How To Check Your Own T-Deck + +Use this procedure when you want to know whether a specific ordinary T-Deck has +the LoRa-induced GPS UART noise issue. + +The short version: watch `read_10s`, send LoRa messages, and check whether GPS +UART reads appear only after radio transmission. + +### Prerequisites + +- Use an ordinary `tdeck` build with GPS diagnostics enabled. +- Open the serial monitor and keep the full log around each transmit event. +- Note the LoRa TX power used for each test. +- Record whether the GPS module/shield is attached, disconnected, or externally + pulled up. + +### Step 1: Establish An Idle Baseline + +Boot the device and wait for at least two GPS health intervals without manually +sending LoRa messages. + +The useful line is: + +```text +[GPS] health ... chars_10s= read_10s= ... +``` + +Expected idle result after startup settles: + +```text +[GPS] health ... chars_10s=0 read_10s=0 ... +``` + +Some boards may show startup noise before the line settles. That is still useful +evidence, but the LoRa-correlation test should start from a quiet idle window +where `read_10s=0`. + +### Step 2: Send A LoRa Message At Normal Or High TX Power + +Send one short LoRa message and capture the logs from the transmit line through +the next GPS health line. + +Relevant LoRa markers: + +```text +[MT][TX] queue text ... +[MT][TX_ROUTE] ... +``` + +A failing pattern is: + +```text +[MT][TX] queue text ... +[MT][TX_ROUTE] ... +[GPS Task] GPS loop processed characters this cycle (... read_bytes=) +[GPS] health ... chars_10s= read_10s= ... +``` + +If `read_10s` rises immediately after the transmit, the MCU really received bytes +on the GPS UART RX path. If those bytes are accompanied by `[GPS][RAW_BURST]` +with `comma=0`, many high-bit bytes, and `ubx_sync=0`, treat them as noise rather +than real GPS traffic. + +### Step 3: Repeat At Lower TX Power + +Reduce LoRa TX power and repeat the same message test. + +This pattern strongly supports the known issue: + +```text +High TX power: [GPS] health ... read_10s=430 ... +Low TX power: [GPS] health ... read_10s=0 ... +``` + +The exact byte count does not need to be `430`. The important signal is that +higher TX power produces GPS UART reads and lower TX power reduces or eliminates +them. + +### Step 4: Isolate The GPS/Shield Path + +If the symptom appears, run the same transmit test under these hardware states: + +1. GPS/shield disconnected. + + If `read_10s` still rises after LoRa TX, the board-side + `UART0_RX / GPIO44` input or connector path is susceptible while floating. + +2. GPS/shield connected and powered. + + If the issue appears only with the GPS/shield attached, inspect GPS TX idle + level, GPS power/reset state, wiring, and shield routing. + +3. `UART0_RX / GPIO44` temporarily pulled to `3V3` with `4.7k` to `10k`. + + If the pull-up prevents LoRa-induced `read_10s` increases, the RX input is + floating or weakly driven. + +### Step 5: Decide The Result + +Use this table as the quick interpretation guide: + +| Observation | Interpretation | +| --- | --- | +| `read_10s=0` before LoRa TX and remains `0` after several normal-power sends | This board/test setup does not show the issue in the observed conditions. | +| `read_10s` rises immediately after LoRa TX, with random `[RAW_BURST]` bytes | Likely LoRa-induced GPS UART noise. | +| High TX power produces `read_10s>0`, low TX power produces `read_10s=0` | Strong evidence of RF or supply-transient coupling into the GPS UART RX path. | +| GPS/shield disconnected and LoRa TX still produces `read_10s>0` | Strong evidence that the board-side RX path is vulnerable when floating. | +| External `4.7k` to `10k` pull-up makes the symptom disappear | Strong evidence that `UART0_RX / GPIO44` was floating or weakly driven. | +| `chars_10s>0` while `read_10s=0`, or `[GPS][COUNT_CHECK]` appears | Reopen the software counter or memory-corruption investigation. | +| Valid `$GNRMC`, `$GNGGA`, `$GNGSA`, or `$GNGSV` sentences appear with commas and checksums | This is GPS data, not the random UART-noise signature described here. | + +When reporting the result, include the `MT][TX]` lines, the health line before +the transmit, the first health line after the transmit, any `[GPS][RAW_BURST]` +lines, the TX power, and the GPS/shield wiring state. + +## Hardware Verification Checklist + +Use these tests to identify the physical cause: + +1. Test with no GPS/shield attached. + + Send a LoRa message and watch `read_10s`. If it rises, bare `GPIO44/UART0_RX` + is susceptible to LoRa TX when the line is floating. + +2. Add a stronger external pull-up on `UART0_RX / GPIO44`. + + A temporary `4.7k` to `10k` pull-up to `3V3` is a useful diagnostic. If LoRa + TX no longer produces UART bytes, the problem is a floating or weakly driven + RX line. + +3. Measure GPS TX idle voltage. + + With GPS attached and powered, the GPS module TX pin should idle high near + `3.3V`. If it is low, drifting, or high impedance, the MCU RX input is not + being driven correctly. + +4. Verify UART crossing. + + GPS module `TX` must connect to MCU `UART0_RX / GPIO44`; GPS module `RX` must + connect to MCU `UART0_TX / GPIO43`. + +5. Verify GPS module power, reset, and mode pins. + + If the GPS shield exposes `RESET`, `BL_CTRL`, or similar control pins, confirm + that they leave the module powered and its TX output active. + +6. Reduce LoRa TX power temporarily. + + If noise byte counts fall with lower TX power, RF or supply coupling is part + of the mechanism. + +7. Compare against known-good passive NMEA. + + A healthy GPS stream should produce logs like: + + ```text + [GPS][NMEA] sample1=$GNTXT,... + [GPS][NMEA] sample2=$GNRMC,... + [GPS][NMEA] sample3=$GNGGA,... + ``` + + It should not produce only high-bit raw bursts with `comma=0`. + +## Direct Hardware Monitoring Method + +The most direct verification method is to watch the GPS UART diagnostics while +manually changing only one hardware condition at a time. + +Serial log fields to watch: + +- `read_10s`: bytes actually read from `UART0_RX / GPIO44`. +- `chars_10s`: bytes accepted by TinyGPS/NMEA parsing after software filtering. +- `[GPS][RAW_BURST]`: a non-NMEA burst captured after an idle period. +- `[GPS][COUNT_CHECK]`: mismatch between UART reads and parser count. This should + be absent in the normal UART-noise case. + +Expected interpretation: + +- `read_10s > 0` after LoRa TX means the MCU really received UART bytes. +- `read_10s > 0` with `chars_10s = 0` means the UART line is still noisy, but the + parser filter is protecting TinyGPS. +- `read_10s = 0` after LoRa TX means no UART bytes were induced during that + monitoring window. +- `[RAW_BURST]` with `comma=0`, many high-bit bytes, and `ubx_sync=0` should be + treated as noise, not GPS data. + +Direct test procedure: + +1. Disconnect the GPS/shield and leave only the board running. + + Send a LoRa message. If `read_10s` still rises, the board-side + `UART0_RX / GPIO44` line or connector is susceptible to LoRa TX while the RX + input is floating. + +2. Temporarily pull `UART0_RX / GPIO44` up to `3V3` with a stronger resistor. + + Use a `4.7k` to `10k` pull-up for the test. If LoRa TX no longer increases + `read_10s`, the issue is effectively confirmed as a floating or weakly driven + RX line. The ESP32-S3 internal pull-up is weak and may not overcome RF or + supply coupling by itself. + +3. With the GPS module attached, measure the GPS TX idle level. + + The GPS TX pin should idle high near `3.3V`. If the idle level drifts, stays + low, or appears high impedance, then the MCU `UART0_RX / GPIO44` input is not + being stably driven by the GPS module. + +4. Verify that GPS TX/RX are crossed correctly. + + UART wiring must be crossed: + + ```text + GPS module TX -> MCU UART0_RX / GPIO44 + GPS module RX -> MCU UART0_TX / GPIO43 + ``` + + If TX/RX are reversed, MCU RX will not have a valid GPS TX driver and will be + more likely to sample noise during LoRa TX. + +5. Repeat the test with lower LoRa TX power. + + If the GPS noise byte count drops noticeably with lower TX power, RF coupling + or supply transients are contributing to the issue. This does not, by itself, + prove that the GPS line is floating, but it does prove a physical correlation + between LoRa TX intensity and UART noise. + +## Maintenance Notes + +- Do not treat ordinary T-Deck GPS readiness as equivalent to receiver presence. + `ready=1` can mean only that the UART transport is open. +- Do not regress to sending u-blox commands by default on ordinary T-Deck. The + attached receiver may be CASIC/L76K, generic NMEA, u-blox, or absent. +- Keep `read_bytes/read_10s` available while this hardware issue is unresolved. + They are the main distinction between UART-line noise and parser/counter + corruption. +- Software can filter noise and keep state clean, but the final fix for + LoRa-correlated UART bytes is hardware-side: stable GPS TX drive, correct + wiring, proper power/reset state, stronger pull-up where appropriate, and + better shielding/routing/grounding if needed. diff --git a/docs/specs/gps.md b/docs/specs/gps.md index 8a3334ff..0aef5cfe 100644 --- a/docs/specs/gps.md +++ b/docs/specs/gps.md @@ -25,6 +25,11 @@ flag. keeps the receiver powered, for example always on, motion aware, or fix only. It must never be used as the GPS enable flag. +`gps_receiver_init` is receiver transport and initialization compatibility +policy. It covers UART baud selection, short startup probing, receiver family +profile, and whether UBX initialization messages may be sent. It is not a +location mode and must not be confused with `gps_mode`. + `gps_powered` is hardware state. It reports whether the runtime currently has the receiver powered. @@ -75,6 +80,13 @@ or disable GPS. export policy. It must not disable receiver output required for internal GPS operation. +`diagnostics()` returns a current health snapshot. It is a non-blocking +observation of the runtime state, not a receiver identity probe and not a +long-running repair task. The snapshot must include a stable diagnostic code, +enable/power/readiness/fix state, satellite counts when known, UART character +counts when available, and the current poll and collection intervals. UI code may +present this snapshot, but it must not block the UI while waiting for GPS traffic. + ## Configuration Ownership GPS configuration belongs under the GPS domain: @@ -86,6 +98,7 @@ GPS configuration belongs under the GPS domain: - `gps_strategy` - `gps_alt_ref` - `gps_coord_format` +- `gps_receiver_init` - `motion_config` - `external_nmea_output_hz` - `external_nmea_sentence_mask` @@ -98,15 +111,30 @@ I/O or runtime control. On startup, a runtime must load configuration and apply it in this order: 1. Apply `gps_enabled`. -2. Apply collection interval. -3. Apply power strategy. -4. Apply GNSS receiver configuration. -5. Apply external NMEA export configuration. +2. Apply receiver initialization compatibility policy. +3. Apply collection interval. +4. Apply power strategy. +5. Apply GNSS receiver configuration. +6. Apply external NMEA export configuration. The internal receiver stream must be configured by the runtime itself whenever the receiver is powered. A default internal stream must include the minimum sentences required for location and satellite diagnostics. +Legacy GPS-only receivers such as u-blox 6 modules must not be forced through +modern multi-GNSS configuration messages when the board profile identifies that +class of hardware. Runtime configuration may still set receiver mode and NMEA +message rates, but unsupported constellation configuration must be skipped with a +diagnostic log rather than treated as a fatal readiness failure. + +Boards with user-replaceable GPS modules, such as T-Deck, must not assume a +single fixed receiver model or UART baud. They should provide automatic short +probing for common GPS UART speeds and expose manual compatibility settings so a +user can select baud and initialization policy when auto-detection is +insufficient. Auto mode must be conservative: it may listen for NMEA/UBX +signatures and select a transport, but it must not repeatedly send +module-specific UBX configuration to an unknown receiver. + Board startup must only prepare the physical transport and publish transport readiness. Receiver identity probes are diagnostics, not a condition for starting the runtime. Platform HAL code must not assume a fixed UART instance or GPIO set; diff --git a/docs/specs/meshtastic-node-payload-parsing.md b/docs/specs/meshtastic-node-payload-parsing.md new file mode 100644 index 00000000..4f153eef --- /dev/null +++ b/docs/specs/meshtastic-node-payload-parsing.md @@ -0,0 +1,79 @@ +# Meshtastic Node Payload Parsing Specification + +Status: baseline +Updated: 2026-05-10 + +This document defines where Meshtastic node-fact payload semantics live in +Trail Mate. + +## Confusion + +`NODEINFO_APP` is not just a UI contact update. + +`NODEINFO_APP` is not just a legacy `User` protobuf. + +`POSITION_APP` is not a platform radio driver detail. + +Linux, ESP Arduino, ESP-IDF, and nRF radio adapters must not each maintain their +own interpretation of NodeInfo, User, Position, device metrics, MQTT origin, or +public-key facts. + +## Boundary + +Meshtastic node-fact parsing is shared protocol semantics and belongs in +`modules/core_chat`. + +The shared parser owns: + +- full `meshtastic_NodeInfo` +- legacy `meshtastic_User` +- embedded `meshtastic_Position` inside `NodeInfo` +- standalone `POSITION_APP` +- `via_mqtt` +- device metrics +- ignored and key-verification flags +- public-key presence and key bytes + +Platform adapters own only transport context and projection: + +- sender node id fallback +- channel index or channel hash +- RSSI and SNR +- hop count +- receive timestamp +- duplicate suppression or retransmit policy +- publishing events or updating `ContactService` +- platform-specific persistence of keys or diagnostics + +## Required Entry Points + +All raw Meshtastic receive paths must use: + +- `chat::meshtastic::decodeNodeInfoPayload(...)` +- `chat::meshtastic::decodePositionPayload(...)` + +Current implementation file: + +- `modules/core_chat/src/infra/meshtastic/mt_node_payload.cpp` + +## Invalid Implementations + +The following are invalid in platform receive paths: + +- direct `pb_decode(... meshtastic_NodeInfo_fields ...)` +- direct `pb_decode(... meshtastic_User_fields ...)` +- direct `pb_decode(... meshtastic_Position_fields ...)` +- separate per-platform mapping from protobuf fields into contact facts +- parsing only legacy `User` on one platform while another platform parses full + `NodeInfo` + +These operations are valid outside platform receive semantics when they are +constructing local outbound payloads, serving BLE phone API data, or testing the +shared parser. + +## Acceptance Check + +A platform adapter is aligned with this specification when its receive path +passes `meshtastic_Data` plus transport context into the shared parser and only +projects the decoded result into its local event/store mechanism. + diff --git a/docs/specs/text-encoding-integrity.md b/docs/specs/text-encoding-integrity.md new file mode 100644 index 00000000..2109f0dd --- /dev/null +++ b/docs/specs/text-encoding-integrity.md @@ -0,0 +1,99 @@ +# Text Encoding Integrity Specification + +Status: baseline +Updated: 2026-05-11 + +This specification defines a repository-wide text integrity rule for Trail Mate. +It exists because AI/tool edits have repeatedly corrupted text into mojibake. +The failure is not limited to Chinese localization files. It can affect any text +file: C++ source, Python scripts, shell scripts, CMake files, packaging +metadata, Markdown docs, templates, `.wolf` memory, release notes, and generated +text assets. + +## 1. Baseline Rule + +All repository text files are UTF-8 unless a file explicitly documents another +encoding. Editing a file must preserve valid UTF-8 and must not introduce +mojibake, replacement characters, or corrupted punctuation. + +Encoding integrity is a correctness requirement. A change that compiles but +corrupts text is not complete. + +## 2. Current Confusions + +- "There is no Chinese in this file" does not make encoding risk disappear. +- "The compiler still accepts the file" does not prove the text is intact. +- "Only comments/docs changed" does not make corruption harmless. +- "Only punctuation changed" is still a defect when punctuation carries meaning, + as in version-policy prose, arrows, ranges, or command examples. +- "Generated by an agent" is not an excuse for bypassing the UTF-8 contract. + +## 3. Invalid Editing Paths + +The following edit paths are invalid unless the engineer has verified the +encoding behavior: + +- rewriting an entire existing text file to change a few lines; +- using shell redirection or ad hoc scripts that depend on platform default + encodings; +- copying text through a terminal or toolchain that silently changes Unicode + characters; +- normalizing line endings or file contents as a side effect of an unrelated + change; +- accepting mojibake inside `.wolf` memory, specs, or generated docs because the + file is "only for agents". + +## 4. Required Workflow + +Before editing: + +- identify whether the file already contains non-ASCII text, special + punctuation, or existing mojibake; +- choose a targeted patch instead of a whole-file rewrite whenever practical; +- preserve unrelated bytes outside the intended edit region. + +After editing: + +- scan all touched text files for replacement characters and common mojibake + artifacts; +- inspect any file that had non-ASCII content before the edit; +- fix encoding corruption before running broader implementation work; +- mention unresolved pre-existing mojibake separately instead of silently + treating it as part of the current change. + +## 5. Acceptance Checks + +A text edit is acceptable only when: + +- the intended content change is present; +- untouched text remains readable; +- no new replacement characters or mojibake artifacts were introduced; +- UTF-8 content still round-trips through the local tools used by the project; +- the diff does not contain unrelated line-ending or whole-file churn. + +## 6. Relationship to Version and Release Specs + +Version and release policy text is especially sensitive. A rule such as "the +version must flow outward from CMakeLists.txt through automated mechanisms" must +not be duplicated into independent version constants, and it also must not be +corrupted by encoding damage. Corrupting the punctuation or wording of such +policy text is a specification failure because future agents may misread the +release contract. + +No C++ source, Python script, shell script, packaging metadata, documentation +template, or `.wolf` memory file may define release semantics in a way that is +both independent from the canonical specification and vulnerable to silent text +corruption. + +## 7. Future Automation + +This specification should eventually be backed by automated checks: + +- repository scan for invalid UTF-8; +- scan for replacement characters and common mojibake sequences; +- pre-commit or CI guard for touched text files; +- targeted allowlist for third-party files where pre-existing encoding artifacts + are intentionally left untouched. + +Until that automation exists, every agent must perform the manual acceptance +checks above before declaring a text-editing task complete. diff --git a/docs/specs/uconsole-aio2-linux.md b/docs/specs/uconsole-aio2-linux.md index 79256de4..2dbbb661 100644 --- a/docs/specs/uconsole-aio2-linux.md +++ b/docs/specs/uconsole-aio2-linux.md @@ -170,8 +170,40 @@ The following cuts are explicitly invalid: building a rich uConsole UI. - The existing `MinimalLinuxAppFacade` may remain as a compatibility adapter for the current LVGL shared shell. +- `LinuxAppServices` is the current Linux app service composition boundary; + uConsole work should depend on it or presentation models above it, while + compact shells use `MinimalLinuxAppFacade` as a compatibility adapter. +- UI toolkit choice is a shell/platform decision, not an app-service boundary. +- Linux/uConsole local state is persisted through SQLite, not ad hoc per-file + key/value stores. +- Linux/uConsole map base tiles may be fetched online and cached locally using + the existing `maps/base/{osm,terrain,satellite}/{z}/{x}/{y}` layout, with + cache metadata stored in SQLite. +- Linux/uConsole contour lines are a map overlay, not a base map source. The + GTK map shell must read transparent PNG contour tiles from + `maps/contour/{major|minor}-{interval}/{z}/{x}/{y}.png`, with the same zoom + profile selection used by Trail Mate Center (`z8 major-500`, `z9 major-200`, + `z10 major-500/minor-100`, `z11 major-200/minor-50`, + `z12 major-100/minor-50`, `z13..14 major-100/minor-20`, + `z15..16 major-50/minor-10`, and `z17+ major-25` plus optional `minor-5`). +- Earthdata credentials are Linux/uConsole map data-source credentials. They + are persisted in SQLite settings, not in the cross-target `AppConfig` blob, + and must not be presented as a rendering toggle. +- A missing contour generation backend must be visible as missing cached + contour tiles or missing Earthdata credentials. The UI must not imply that + contour generation is working when only cached overlay rendering exists. +- BLE is not a Linux/uConsole product capability; Linux shells must report it + as unused/unsupported rather than exposing a disabled fake toggle. - A future uConsole shell should depend on presentation models/actions, not on the compact handheld page implementation. +- The current LVGL uConsole shell is a bring-up and fallback shell. It is not a + commitment that the long-term uConsole product UI must stay on LVGL. +- `UConsoleChatWorkspaceModel` is the first concrete uConsole presentation/action + slice; future GTK work should reuse this boundary before adding toolkit + objects. +- The current verified uConsole framebuffer reports `720x1280`; the fallback + LVGL shell should default to that physical orientation until a rotation-aware + display adapter exists. - The likely app shell name is `apps/linux_uconsole`. Using `apps/linux_unoq` for this target requires an explicit decision that UNO Q and uConsole/AIO2 are the same product target in this repository. @@ -263,6 +295,13 @@ Rule: - Sharing presentation state is encouraged. Forcing identical layout structure is not. +Packaging rule: + +- Linux device shells should provide standard Debian packages when targeting + Debian-family handheld environments. The package should install a normal + command under `/usr/bin` and keep launch/runtime options explicit rather than + hiding framebuffer, input, or capability assumptions in ad hoc scripts. + ### Layer E: AIO2 Platform Adapters Likely future locations: @@ -277,6 +316,19 @@ Responsibilities: - report honest capability status - keep driver/device concerns below the app service and presentation layers +Current AIO2 LoRa binding facts: + +- The HackerGadgets AIO2 SX1262 path is a board-level binding, not generic + Linux SPI auto-detect. +- The LoRa power gate is `GPIO16`; GPS power is `GPIO27`. +- The SX1262 control lines are `Reset=GPIO25`, `Busy=GPIO24`, and + `IRQ/DIO1=GPIO26`. +- The radio requires `DIO2_AS_RF_SWITCH=true` and `DIO3_TCXO_VOLTAGE=1.8`. +- The expected SPI endpoint is `spidev1.0`, which requires the + `dtoverlay=spi1-1cs` boot overlay. Treating an unrelated visible spidev node + such as `spidev4.0` as the LoRa endpoint is invalid unless the board overlay + explicitly proves that wiring. + Forbidden ownership: - screen composition @@ -305,6 +357,7 @@ Allowed examples: - search/indexing services over local stores - background import/export workers - advanced map package management +- online map tile fetch/cache workers with SQLite metadata - diagnostic log viewers - Linux host integration helpers @@ -349,6 +402,13 @@ Rules: - Shared core should hold common domain truth. Linux-native modules should hold Linux-only scale, indexing, background work, integration, and workflow extensions. +- Inbound chat message identity is shared domain truth. A platform adapter may + suppress repeated RF frames as a transport optimization, but it must not be + the only owner of "this chat message was already received" behavior. The + `ChatService` ingress boundary is responsible for preventing duplicate + `(protocol, channel, sender, peer, message id)` text messages from entering + the model/store, and unread aggregation must derive from shared conversation + metadata rather than toolkit-local state. ## 8. uConsole UI Product Rules @@ -361,7 +421,17 @@ Expected characteristics: - multi-pane layouts where useful - keyboard-first interaction paths - dense but readable information hierarchy -- stable side panels for status, team, device, or capability details +- compact menu-bar navigation instead of a large hero/header region +- persistent bottom status bar for AIO2, LoRa, GPS, storage, and background + work state +- stable side panels or in-workspace panels for team, device, or capability + details when they add useful density +- menu-bar workspaces must be real navigation targets. Hardware, Data, and + Settings may start as read-only state surfaces, but they must not be disabled + placeholder buttons. +- Overview, if present, is an operational dashboard: compact location/map + context, current hardware state, message status, team activity timeline, and + runtime details. It must not become a decorative landing page. - long-running task visibility for imports, sync, indexing, diagnostics, or package management - workflows that assume larger local storage and richer local datasets @@ -392,10 +462,223 @@ Non-goals: - marketing-style landing screens - oversized hero pages +- large header/status regions that consume the uConsole vertical workspace - decorative dashboard cards that reduce operational density - forcing every page into the current compact 12-entry app menu model -## 9. Migration Program +### GTK Workbench Information Architecture + +The production uConsole GTK shell uses a workbench layout, not independent +small-screen pages stacked into a larger window. + +Global surfaces: + +- the top menu bar is only for app identity and workspace navigation; +- the bottom status bar is only for live runtime state that should remain + visible everywhere; +- workspace bodies must not repeat large page titles, subtitles, or tutorial + prose; +- each workspace must express its primary task through one dominant pane plus + secondary rails or inspectors when useful. + +Normative GTK layout geometry: + +- Layout numbers are product constraints, not incidental implementation + guesses. GTK page code must reference the named constants in + `apps/linux_uconsole/src/platform/gtk/gtk_uconsole_layout_spec.h` instead of + inventing local hard-coded pane widths. +- Long runtime strings must never determine pane width. Coordinates, tile + status, cache counters, paths, failures, and protocol facts must wrap, + split into separate data rows, truncate, or move secondary detail to a + tooltip/log surface. +- The bottom status bar is a global surface and must remain visible on every + workspace. Workspace bodies reserve status-bar space; no page-level layout may + push the status bar below the visible window. +- Overview is three columns: compact GPS/location rail, dominant center column, + compact activity timeline rail. The center column is the only horizontally + expanding column. +- Overview GPS/location rail width is `208px`. The GPS/location rail must not + render page headers, prose, coordinate summaries, or cache summaries above the + map. The rail starts with compact location map context, then skyplot, then + satellite list. +- Overview location map maximum viewport is `200x128px` today and must remain + within a `200x200px` product envelope unless this specification is explicitly + changed first. +- Overview timeline rail width is `252px`. Timeline badges and event text must + wrap inside this rail rather than increasing the rail width. +- Chat is three columns: narrow conversation rail, dominant transcript/composer + column, narrow node/contact inspector rail. Conversation rail is `216px`; node + inspector rail is `220px`; only the transcript/composer column expands. +- Map is canvas-first. Left map controls rail and right map tools rail are equal + narrow rails at `152px` each. The left rail must never be wider than the + right rail. The map canvas is the only horizontally expanding area. +- Map side rails own their vertical overflow. If controls, tile status, contour + status, cache status, or tools exceed the visible height, the rail scrolls + internally at its fixed width; content must not disappear behind the bottom + status bar or force the global window/body to scroll. +- The map tile viewport must fill the available canvas. Fixed-aspect frames, + letterboxing, permanent grey bands, or any other decorative area between the + side rails and the rendered tile surface are invalid. Grey may appear only as + a transient missing/loading tile placeholder inside the tile grid. +- Map coordinates must be displayed as separate rows, such as `lat:` and `lon:`, + not as a single sentence. Tile/cache/download status must be rendered as + short multi-row data items, not as one long prose line. +- If a label, switch, button, or status field needs more room than its rail, + the fix is wrapping, a tooltip, or moving secondary detail to Logs/Data. + Shortening established domain labels such as `Terrain`, `Satellite`, region + codes, protocol names, or radio parameter names is invalid unless the + abbreviation is already standard in that domain. Increasing the rail width is + invalid without updating this specification first. +- The default uConsole GTK shell is designed for a landscape desktop-class + handheld workspace. Changes that make side rails visually dominate the map, + transcript, or overview center column are specification drift. + +Workspace rules: + +- Overview is an operational dashboard. It prioritizes location/map context, + hardware state, message state, team activity, and runtime details in that + order. It must not become a decorative landing page. +- Map is a canvas-first workspace. The map should fill the body, with compact + overlays for source/layer tools and status. Tile borders, card-like tile cells, + and large control panels are invalid. The map view has its own user-controlled + center; drag/pan changes the map view center and must recalculate tiles and + overlays from the model rather than moving only GTK widgets. Pointer-context + actions such as right-click "center here" and "zoom here" operate on the + coordinate under the pointer, not on the global map center. +- Chat is a four-part workbench: narrow thread rail, dominant transcript, + node/contact inspector, and compact compose surface. The inspector projects + real `ContactService` node facts such as NodeInfo names, source, signal, and + Position. It must not derive its own node model from chat rows. +- Hardware is a status matrix plus capability/driver detail surface. It must + distinguish hardware endpoint presence, driver binding, and runtime readiness. +- Data is a storage and cache operations surface. Counts and cache health are + primary; paths and roots are secondary details. +- Logs is a diagnostics surface. Packet direction, parsed fields, and raw hex + must be visually distinct without forcing the user to read long prose. +- Settings is a control plane. It uses grouped navigation and compact aligned + rows; it must not be a single long embedded-device settings scroll. Protocol + settings must be conditional: Meshtastic, MeshCore, and raw LoRa/RNode/LXMF + controls are not simultaneously valid user surfaces. Meshtastic region and + modem preset controls must use their protocol labels such as `CN`, `EU_433`, + `ANZ`, and `LongFast`; exposing their stored enum integers as the primary UI + is invalid. Region-specific radio constraints, such as TX power limits, must + be applied from the protocol region table rather than duplicated in the GTK + view. +- On Linux, the SX126x driver is platform-specific, but Meshtastic RF parameter + derivation is not. Frequency, bandwidth, spreading factor, coding rate, + preamble, sync word, and TX power limits must come from the same shared + Meshtastic radio-configuration helper used by the ESP environment. Linux may + map that shared result into `Sx126xLoRaConfig`, but it must not maintain a + separate Meshtastic RF model. +- Meshtastic node-fact parsing is shared protocol semantics. `NODEINFO_APP`, + legacy `User`, embedded `Position`, standalone `POSITION_APP`, `via_mqtt`, + device metrics, and public-key presence must be decoded by `core_chat` + helpers and then projected into platform events or `ContactService`. + Platform adapters may supply transport context such as sender, channel, RSSI, + SNR, and hop count, but they must not fork their own incompatible NodeInfo + parser. + +Visual hierarchy rules: + +- repeated cards are allowed only for repeated records such as messages, log + entries, hardware units, or timeline items; +- page sections are panes or rails, not nested decorative cards; +- controls use compact toolbars, grouped rows, and fixed control widths so the + layout does not jump as data refreshes; +- status colors should mark severity or source, not decorate every surface; +- empty, disabled, unsupported, and unbound states are honest product states and + must remain visible without mock data. + +## 9. UI Technology Assessment + +The UI technology is replaceable only if the service and presentation layers stay +free of toolkit objects. A uConsole shell may be implemented with LVGL, Qt, GTK, +Slint, or a web runtime, but none of those choices may define chat/contact/team +service ownership or presentation-model contracts. + +### LVGL + +LVGL remains useful for: + +- fast bring-up on a raw framebuffer +- CI smoke coverage with a small dependency surface +- compact Linux and MCU-adjacent UI parity checks +- fallback shells when no desktop session, compositor, GPU path, or package + stack is available + +LVGL should not be treated as the default long-term uConsole product UI when the +product starts requiring richer desktop behavior. It is weaker for dense tables, +complex text input, native keyboard shortcuts, accessibility, advanced +windowing, theming, inspectors, large searchable lists, and long-running +desktop-style workflows. + +Decision rule: + +- keep LVGL when the requirement is direct framebuffer, minimal dependencies, + compact-shell parity, or hardware bring-up; +- evaluate a native Linux UI stack when requirements include rich search, + editable tables/lists, multi-pane operational views, diagnostics, import/export + managers, larger local datasets, shortcut-heavy keyboard workflows, or desktop + accessibility/window integration. + +### GTK 4 + +GTK 4 is the preferred long-term candidate for a production uConsole +desktop-class UI when the target runs a conventional Linux user session with +Wayland or X11 available. + +Reasons: + +- it fits the user's desired desktop-software direction better than a compact + firmware-style shell +- it is native to the Linux desktop ecosystem +- it supports dense lists, search, forms, dialogs, keyboard shortcuts, and + conventional app workflows more naturally than LVGL +- it keeps the product direction aligned with Linux application behavior instead + of embedded-widget parity + +Costs: + +- direct C++ integration will need either plain C bindings, gtkmm, or a thin C + adapter layer +- deployment should assume a compositor/session unless a separate embedded GTK + strategy is proven +- it is not the right fallback for raw framebuffer bring-up + +### Slint + +Slint is a plausible lighter alternative if GTK deployment, compositor +requirements, or C++ binding strategy become too costly. It fits embedded Linux +and C++ integration better than many desktop toolkits, but its ecosystem and +widget depth are smaller. It is worth evaluating after the first real +presentation-model slice exists. + +### Qt 6 / QML + +Qt 6/QML remains technically strong for embedded Linux, GPU-backed UIs, and C++ +service bindings, but it is not the preferred direction for this project because +the user does not want the uConsole product UI to be Qt-based. It should only be +reopened if GTK and Slint both fail hard requirements. + +### Web Runtime + +A web UI, Tauri, or Electron-like stack gives high UI velocity and rich layout +capability. It should be considered only if storage, memory, startup time, +battery, packaging, and offline deployment budgets are acceptable for the actual +uConsole/AIO2 environment. + +### Current Recommendation + +- Keep the LVGL uConsole shell as the first buildable target and fallback. +- Do not deepen dependencies on compact LVGL pages or the compact app grid. +- Move real feature work through `LinuxAppServices` and presentation models so a + later GTK/Slint/web shell can reuse the same app surface. +- Evaluate GTK 4 first when the product requirements move from shell bring-up + into rich desktop workflows. +- Use Slint as the lighter second candidate if GTK's deployment or binding cost + is unacceptable. + +## 10. Migration Program Future implementation should proceed in this order. @@ -415,7 +698,7 @@ Future implementation should proceed in this order. 9. Add CI/build smoke for the new app shell only after the target structure is real enough to compile. -## 10. Acceptance Checks +## 11. Acceptance Checks The uConsole/AIO2 work is aligned with this specification only when these statements are becoming true: @@ -435,8 +718,19 @@ statements are becoming true: express scale, indexing, background jobs, and desktop-class workflows. - Capability state is honest: unsupported, simulated, and real hardware-backed behavior are distinguishable. +- Overview/dashboard surfaces show stored or runtime-backed data only. Empty + location, hardware, message, and team states are valid product states, not + placeholders to hide with mock data. +- Hardware state distinguishes endpoint presence from driver readiness. A + detected uConsole/AIO2 serial, SPI, or I2C endpoint must not be reported as + missing merely because Trail Mate has not bound the GPS or LoRa protocol + driver yet. +- Replacing the uConsole UI toolkit does not require rewriting app services or + presentation models. +- The uConsole Linux app can be installed through a standard `.deb` package, not + only by copying a build artifact by hand. -## 11. Drift Checks for Future Agents +## 12. Drift Checks for Future Agents Before implementing any uConsole/AIO2 slice, check these questions: @@ -452,18 +746,20 @@ Before implementing any uConsole/AIO2 slice, check these questions: - Is this change treating MCU feature parity as the ceiling for Linux? - Is this change pushing Linux-only scale or background-job assumptions into shared MCU-compatible modules? +- Is this change letting LVGL, Qt, GTK, Slint, or a web runtime leak into service + composition or presentation-model contracts? If the answer to any of these is yes, the implementation is drifting away from this specification. -## 12. Open Engineer Decisions +## 13. Open Engineer Decisions These points are intentionally not decided by this document: - Whether the target directory should be `apps/linux_uconsole` or whether `apps/linux_unoq` should be redefined to cover uConsole/AIO2. -- Whether the first desktop-class shell should use LVGL, a native Linux UI - toolkit, or another rendering approach. +- Which long-term UI stack should back the production uConsole desktop-class + shell after the LVGL bring-up shell exposes enough requirements. - Which AIO2 capabilities are mandatory for the first buildable slice. - Which feature provides the first presentation-model slice. - Which Linux-native feature module should be introduced first. @@ -473,8 +769,11 @@ The current recommendation is: - use `apps/linux_uconsole` unless UNO Q and uConsole/AIO2 are explicitly declared to be the same target; -- start with LVGL only if it accelerates the first shell without forcing compact - UI structure; +- keep LVGL as the buildable bring-up/fallback shell, without forcing compact UI + structure; +- evaluate GTK 4 first for the long-term uConsole product UI once rich + desktop workflows become concrete; +- evaluate Slint if GTK's deployment or binding cost is too high; - use Chat or Contacts as the first presentation-model slice because they expose service/UI coupling quickly without requiring real hardware; - introduce Linux-native extensions after the service/presentation boundary is diff --git a/modules/core_chat/include/chat/infra/meshtastic/mt_node_payload.h b/modules/core_chat/include/chat/infra/meshtastic/mt_node_payload.h new file mode 100644 index 00000000..e8c1df87 --- /dev/null +++ b/modules/core_chat/include/chat/infra/meshtastic/mt_node_payload.h @@ -0,0 +1,73 @@ +#pragma once + +#include "chat/domain/chat_types.h" +#include "chat/domain/contact_types.h" +#include "meshtastic/mesh.pb.h" + +#include +#include +#include + +namespace chat +{ +namespace meshtastic +{ + +struct NodePayloadDecodeContext +{ + NodeId fallback_node_id = 0; + float snr = 0.0f; + float rssi = 0.0f; + std::uint32_t timestamp = 0; + std::uint8_t hops_away = 0xFF; + std::uint8_t channel = 0xFF; + bool via_mqtt = false; +}; + +struct DecodedNodePayload +{ + NodeId node_id = 0; + std::string short_name{}; + std::string long_name{}; + float snr = 0.0f; + float rssi = 0.0f; + std::uint32_t timestamp = 0; + std::uint8_t protocol = static_cast( + contacts::NodeProtocolType::Meshtastic); + std::uint8_t role = 0xFF; + std::uint8_t hops_away = 0xFF; + std::uint8_t hw_model = 0; + std::uint8_t channel = 0xFF; + bool has_user = false; + bool has_macaddr = false; + std::array macaddr{}; + bool via_mqtt = false; + bool is_ignored = false; + bool has_public_key = false; + std::array public_key{}; + bool key_manually_verified = false; + bool has_device_metrics = false; + contacts::NodeDeviceMetrics device_metrics{}; + bool has_position = false; + contacts::NodePosition position{}; + + [[nodiscard]] contacts::NodeUpdate toNodeUpdate() const; +}; + +struct DecodedPositionPayload +{ + NodeId node_id = 0; + contacts::NodePosition position{}; +}; + +bool decodeNodeInfoPayload(const meshtastic_Data& data, + const NodePayloadDecodeContext& context, + DecodedNodePayload* out); + +bool decodePositionPayload(const meshtastic_Data& data, + NodeId node_id, + std::uint32_t fallback_timestamp, + DecodedPositionPayload* out); + +} // namespace meshtastic +} // namespace chat diff --git a/modules/core_chat/include/chat/infra/meshtastic/mt_protocol_helpers.h b/modules/core_chat/include/chat/infra/meshtastic/mt_protocol_helpers.h index 7d0c3c64..cedae00b 100644 --- a/modules/core_chat/include/chat/infra/meshtastic/mt_protocol_helpers.h +++ b/modules/core_chat/include/chat/infra/meshtastic/mt_protocol_helpers.h @@ -47,8 +47,6 @@ void fillDecodedPacketCommon(meshtastic_MeshPacket* packet, chat::ChannelId channel_index); bool allowPkiForPortnum(uint32_t portnum); uint32_t djb2HashText(const char* text); -void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset preset, bool wide_lora, - float& bw_khz, uint8_t& sf, uint8_t& cr_denom); } // namespace meshtastic -} // namespace chat \ No newline at end of file +} // namespace chat diff --git a/modules/core_chat/include/chat/infra/meshtastic/mt_radio_config.h b/modules/core_chat/include/chat/infra/meshtastic/mt_radio_config.h new file mode 100644 index 00000000..4f4d364d --- /dev/null +++ b/modules/core_chat/include/chat/infra/meshtastic/mt_radio_config.h @@ -0,0 +1,40 @@ +#pragma once + +#include "chat/domain/chat_types.h" +#include "chat/infra/meshtastic/mt_region.h" + +#include + +namespace chat +{ +namespace meshtastic +{ + +constexpr std::uint8_t kMeshtasticLoraSyncWord = 0x2B; +constexpr std::uint16_t kMeshtasticLoraPreambleLen = 16; +constexpr std::uint8_t kMeshtasticLoraCrcLen = 2; + +struct RadioConfig +{ + meshtastic_Config_LoRaConfig_RegionCode region_code = + meshtastic_Config_LoRaConfig_RegionCode_CN; + meshtastic_Config_LoRaConfig_ModemPreset modem_preset = + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + bool using_preset = true; + const char* channel_name = "LongFast"; + std::uint32_t channel_slot = 0; + float freq_mhz = 0.0f; + float bw_khz = 250.0f; + std::uint8_t sf = 11; + std::uint8_t cr_denom = 5; + std::int8_t tx_power_dbm = 17; + std::uint16_t preamble_len = kMeshtasticLoraPreambleLen; + std::uint8_t sync_word = kMeshtasticLoraSyncWord; + std::uint8_t crc_len = kMeshtasticLoraCrcLen; +}; + +const char* primaryChannelName(const MeshConfig& config); +RadioConfig deriveRadioConfig(const MeshConfig& config); + +} // namespace meshtastic +} // namespace chat diff --git a/modules/core_chat/include/chat/infra/meshtastic/mt_region.h b/modules/core_chat/include/chat/infra/meshtastic/mt_region.h index 924d0dda..d903b0ab 100644 --- a/modules/core_chat/include/chat/infra/meshtastic/mt_region.h +++ b/modules/core_chat/include/chat/infra/meshtastic/mt_region.h @@ -32,6 +32,11 @@ struct RegionInfo const RegionInfo* getRegionTable(size_t* out_count); const RegionInfo* findRegion(meshtastic_Config_LoRaConfig_RegionCode code); const char* presetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset); +void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset preset, + bool wide_lora, + float& bw_khz, + uint8_t& sf, + uint8_t& cr_denom); float computeFrequencyMhz(const RegionInfo* region, float bw_khz, const char* channel_name); float estimateFrequencyMhz(uint8_t region_code, uint8_t modem_preset); diff --git a/modules/core_chat/include/chat/usecase/chat_service.h b/modules/core_chat/include/chat/usecase/chat_service.h index e8b16e21..950b14a4 100644 --- a/modules/core_chat/include/chat/usecase/chat_service.h +++ b/modules/core_chat/include/chat/usecase/chat_service.h @@ -9,6 +9,8 @@ #include "../domain/chat_types.h" #include "../ports/i_chat_store.h" #include "../ports/i_mesh_adapter.h" +#include +#include #include namespace chat @@ -155,6 +157,26 @@ class ChatService } private: + struct IncomingIdentity + { + MeshProtocol protocol = MeshProtocol::Meshtastic; + ChannelId channel = ChannelId::PRIMARY; + NodeId from = 0; + NodeId peer = 0; + MessageId msg_id = 0; + + bool operator==(const IncomingIdentity& other) const + { + return protocol == other.protocol && + channel == other.channel && + from == other.from && + peer == other.peer && + msg_id == other.msg_id; + } + }; + + static constexpr std::size_t kRecentIncomingLimit = 256; + ChatModel& model_; IMeshAdapter& adapter_; IChatStore& store_; @@ -162,11 +184,15 @@ class ChatService bool model_enabled_ = true; MeshProtocol active_protocol_ = MeshProtocol::Meshtastic; mutable ChatMessage store_lookup_cache_{}; + std::deque recent_incoming_{}; std::vector incoming_text_observers_; std::vector incoming_message_observers_; std::vector outgoing_text_observers_; std::vector incoming_data_observers_; + + [[nodiscard]] bool isDuplicateIncoming(const ChatMessage& msg) const; + void rememberIncoming(const ChatMessage& msg); }; } // namespace chat diff --git a/modules/core_chat/src/infra/meshtastic/mt_node_payload.cpp b/modules/core_chat/src/infra/meshtastic/mt_node_payload.cpp new file mode 100644 index 00000000..147e4b76 --- /dev/null +++ b/modules/core_chat/src/infra/meshtastic/mt_node_payload.cpp @@ -0,0 +1,343 @@ +#include "chat/infra/meshtastic/mt_node_payload.h" + +#include "chat/infra/meshtastic/mt_protocol_helpers.h" +#include "meshtastic/config.pb.h" +#include "pb_decode.h" + +#include +#include +#include + +namespace chat +{ +namespace meshtastic +{ +namespace +{ + +constexpr std::uint8_t kUnknownRole = 0xFF; + +bool positionFromProto(const meshtastic_Position& pos, + std::uint32_t fallback_timestamp, + contacts::NodePosition* out); + +std::string boundedString(const char* text, std::size_t max_len) +{ + if (text == nullptr || max_len == 0) + { + return {}; + } + + std::size_t len = 0; + while (len < max_len && text[len] != '\0') + { + ++len; + } + return std::string(text, len); +} + +bool hasAnyByte(const std::uint8_t* data, std::size_t len) +{ + if (data == nullptr) + { + return false; + } + for (std::size_t index = 0; index < len; ++index) + { + if (data[index] != 0) + { + return true; + } + } + return false; +} + +bool hasBoundedText(const char* text, std::size_t max_len) +{ + if (text == nullptr) + { + return false; + } + for (std::size_t index = 0; index < max_len; ++index) + { + if (text[index] == '\0') + { + return false; + } + if (text[index] != '\0') + { + return true; + } + } + return false; +} + +bool hasMeaningfulUserFacts(const meshtastic_User& user) +{ + return hasBoundedText(user.id, sizeof(user.id)) || + hasBoundedText(user.short_name, sizeof(user.short_name)) || + hasBoundedText(user.long_name, sizeof(user.long_name)) || + hasAnyByte(user.macaddr, sizeof(user.macaddr)) || + user.public_key.size > 0 || + user.hw_model != meshtastic_HardwareModel_UNSET || + user.is_licensed || + user.has_is_unmessagable; +} + +bool hasMeaningfulNodeInfoFacts(const meshtastic_NodeInfo& node, + std::uint32_t fallback_timestamp) +{ + contacts::NodePosition ignored_position{}; + return node.num != 0 || (node.has_user && hasMeaningfulUserFacts(node.user)) || + (node.has_position && + positionFromProto(node.position, + fallback_timestamp, + &ignored_position)) || + node.snr != 0.0F || node.last_heard != 0 || + node.has_device_metrics || node.channel != 0 || node.via_mqtt || + node.has_hops_away || node.is_favorite || node.is_ignored || + node.is_key_manually_verified; +} + +std::uint8_t sanitizeRole(meshtastic_Config_DeviceConfig_Role role) +{ + if (role <= meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) + { + return static_cast(role); + } + return kUnknownRole; +} + +contacts::NodeDeviceMetrics deviceMetricsFromProto( + const meshtastic_DeviceMetrics& metrics) +{ + contacts::NodeDeviceMetrics out{}; + out.has_battery_level = metrics.has_battery_level; + out.battery_level = metrics.battery_level; + out.has_voltage = metrics.has_voltage; + out.voltage = metrics.voltage; + out.has_channel_utilization = metrics.has_channel_utilization; + out.channel_utilization = metrics.channel_utilization; + out.has_air_util_tx = metrics.has_air_util_tx; + out.air_util_tx = metrics.air_util_tx; + out.has_uptime_seconds = metrics.has_uptime_seconds; + out.uptime_seconds = metrics.uptime_seconds; + return out; +} + +bool positionFromProto(const meshtastic_Position& pos, + std::uint32_t fallback_timestamp, + contacts::NodePosition* out) +{ + if (out == nullptr || !hasValidPosition(pos)) + { + return false; + } + + contacts::NodePosition value{}; + value.valid = true; + value.latitude_i = pos.latitude_i; + value.longitude_i = pos.longitude_i; + value.has_altitude = pos.has_altitude || pos.has_altitude_hae; + value.altitude = pos.has_altitude ? pos.altitude : pos.altitude_hae; + value.timestamp = pos.timestamp != 0 + ? pos.timestamp + : (pos.time != 0 ? pos.time : fallback_timestamp); + value.precision_bits = pos.precision_bits; + value.pdop = pos.PDOP; + value.hdop = pos.HDOP; + value.vdop = pos.VDOP; + value.gps_accuracy_mm = pos.gps_accuracy; + *out = value; + return true; +} + +void applyUser(const meshtastic_User& user, DecodedNodePayload& out) +{ + out.has_user = true; + out.short_name = boundedString(user.short_name, sizeof(user.short_name)); + out.long_name = boundedString(user.long_name, sizeof(user.long_name)); + out.role = sanitizeRole(user.role); + out.hw_model = static_cast(user.hw_model); + + out.has_macaddr = hasAnyByte(user.macaddr, sizeof(user.macaddr)); + if (out.has_macaddr) + { + std::copy(user.macaddr, + user.macaddr + sizeof(user.macaddr), + out.macaddr.begin()); + } + + out.has_public_key = user.public_key.size == out.public_key.size(); + if (out.has_public_key) + { + std::copy(user.public_key.bytes, + user.public_key.bytes + out.public_key.size(), + out.public_key.begin()); + } +} + +DecodedNodePayload makeBasePayload(const NodePayloadDecodeContext& context) +{ + DecodedNodePayload out{}; + out.node_id = context.fallback_node_id; + out.snr = context.snr; + out.rssi = context.rssi; + out.timestamp = context.timestamp; + out.hops_away = context.hops_away; + out.channel = context.channel; + out.via_mqtt = context.via_mqtt; + return out; +} + +} // namespace + +contacts::NodeUpdate DecodedNodePayload::toNodeUpdate() const +{ + contacts::NodeUpdate update{}; + update.short_name = short_name.c_str(); + update.long_name = long_name.c_str(); + update.has_last_seen = timestamp != 0; + update.last_seen = timestamp; + update.has_snr = !std::isnan(snr); + update.snr = snr; + update.has_rssi = !std::isnan(rssi); + update.rssi = rssi; + update.has_hops_away = hops_away != 0xFF; + update.hops_away = hops_away; + update.has_channel = channel != 0xFF; + update.channel = channel; + update.has_protocol = protocol != 0; + update.protocol = protocol; + update.has_role = role != kUnknownRole; + update.role = role; + update.has_hw_model = hw_model != 0; + update.hw_model = hw_model; + update.has_macaddr = has_macaddr; + if (has_macaddr) + { + std::copy(macaddr.begin(), macaddr.end(), update.macaddr); + } + update.has_via_mqtt = true; + update.via_mqtt = via_mqtt; + update.has_is_ignored = true; + update.is_ignored = is_ignored; + update.has_public_key = has_user; + update.public_key_present = has_public_key; + update.has_key_manually_verified = true; + update.key_manually_verified = key_manually_verified; + update.has_device_metrics = has_device_metrics; + if (has_device_metrics) + { + update.device_metrics = device_metrics; + } + update.has_position = has_position; + if (has_position) + { + update.position = position; + } + return update; +} + +bool decodeNodeInfoPayload(const meshtastic_Data& data, + const NodePayloadDecodeContext& context, + DecodedNodePayload* out) +{ + if (out == nullptr || data.portnum != meshtastic_PortNum_NODEINFO_APP || + data.payload.size == 0 || + data.payload.size > sizeof(data.payload.bytes)) + { + return false; + } + + meshtastic_NodeInfo node = meshtastic_NodeInfo_init_default; + pb_istream_t node_stream = + pb_istream_from_buffer(data.payload.bytes, data.payload.size); + if (pb_decode(&node_stream, meshtastic_NodeInfo_fields, &node)) + { + if (!hasMeaningfulNodeInfoFacts(node, context.timestamp)) + { + return false; + } + DecodedNodePayload value = makeBasePayload(context); + value.node_id = node.num != 0 ? node.num : context.fallback_node_id; + if (std::isnan(value.snr) && node.snr != 0.0F) + { + value.snr = node.snr; + } + if (value.timestamp == 0 && node.last_heard != 0) + { + value.timestamp = node.last_heard; + } + value.via_mqtt = context.via_mqtt || node.via_mqtt; + value.is_ignored = node.is_ignored; + value.key_manually_verified = node.is_key_manually_verified; + if (node.has_hops_away) + { + value.hops_away = static_cast(node.hops_away); + } + if (node.has_user) + { + applyUser(node.user, value); + } + if (node.has_device_metrics) + { + value.has_device_metrics = true; + value.device_metrics = deviceMetricsFromProto(node.device_metrics); + } + if (node.has_position) + { + value.has_position = + positionFromProto(node.position, context.timestamp, &value.position); + } + *out = value; + return true; + } + + meshtastic_User user = meshtastic_User_init_default; + pb_istream_t user_stream = + pb_istream_from_buffer(data.payload.bytes, data.payload.size); + if (!pb_decode(&user_stream, meshtastic_User_fields, &user)) + { + return false; + } + if (!hasMeaningfulUserFacts(user)) + { + return false; + } + + DecodedNodePayload value = makeBasePayload(context); + applyUser(user, value); + *out = value; + return true; +} + +bool decodePositionPayload(const meshtastic_Data& data, + NodeId node_id, + std::uint32_t fallback_timestamp, + DecodedPositionPayload* out) +{ + if (out == nullptr || data.portnum != meshtastic_PortNum_POSITION_APP || + data.payload.size == 0 || + data.payload.size > sizeof(data.payload.bytes)) + { + return false; + } + + meshtastic_Position pos = meshtastic_Position_init_zero; + pb_istream_t stream = + pb_istream_from_buffer(data.payload.bytes, data.payload.size); + contacts::NodePosition position{}; + if (!pb_decode(&stream, meshtastic_Position_fields, &pos) || + !positionFromProto(pos, fallback_timestamp, &position)) + { + return false; + } + + out->node_id = node_id; + out->position = position; + return true; +} + +} // namespace meshtastic +} // namespace chat diff --git a/modules/core_chat/src/infra/meshtastic/mt_packet_wire.cpp b/modules/core_chat/src/infra/meshtastic/mt_packet_wire.cpp index fea8da0d..05872ccf 100644 --- a/modules/core_chat/src/infra/meshtastic/mt_packet_wire.cpp +++ b/modules/core_chat/src/infra/meshtastic/mt_packet_wire.cpp @@ -5,15 +5,24 @@ #include "chat/infra/meshtastic/mt_packet_wire.h" +#include + +#if __has_include() && __has_include() #include #include -#include +#define TRAILMATE_MESHTASTIC_WIRE_HAS_ARDUINO_CRYPTO 1 +#endif + +#ifndef TRAILMATE_MESHTASTIC_WIRE_HAS_ARDUINO_CRYPTO +#define TRAILMATE_MESHTASTIC_WIRE_HAS_ARDUINO_CRYPTO 0 +#endif namespace chat { namespace meshtastic { +#if TRAILMATE_MESHTASTIC_WIRE_HAS_ARDUINO_CRYPTO namespace { @@ -54,6 +63,7 @@ void aesCtrCrypt(const uint8_t* key, size_t key_len, uint8_t* nonce, } } // namespace +#endif bool buildWirePacket(const uint8_t* data_payload, size_t data_len, uint32_t from_node, uint32_t packet_id, @@ -73,12 +83,16 @@ bool buildWirePacket(const uint8_t* data_payload, size_t data_len, if (psk && psk_len > 0) { +#if TRAILMATE_MESHTASTIC_WIRE_HAS_ARDUINO_CRYPTO uint8_t nonce[16]; memset(nonce, 0, sizeof(nonce)); const uint64_t packet_id64 = static_cast(packet_id); memcpy(nonce, &packet_id64, sizeof(uint64_t)); memcpy(nonce + sizeof(uint64_t), &from_node, sizeof(uint32_t)); aesCtrCrypt(psk, psk_len, nonce, payload, payload_len); +#else + return false; +#endif } PacketHeaderWire hdr{}; @@ -151,14 +165,19 @@ bool decryptPayload(const PacketHeaderWire& header, return false; } + memcpy(out_plaintext, cipher, cipher_len); +#if TRAILMATE_MESHTASTIC_WIRE_HAS_ARDUINO_CRYPTO uint8_t nonce[16]; memset(nonce, 0, sizeof(nonce)); const uint64_t packet_id64 = static_cast(header.id); memcpy(nonce, &packet_id64, sizeof(uint64_t)); memcpy(nonce + sizeof(uint64_t), &header.from, sizeof(uint32_t)); - - memcpy(out_plaintext, cipher, cipher_len); aesCtrCrypt(psk, psk_len, nonce, out_plaintext, cipher_len); +#else + (void)header; + (void)psk_len; + return false; +#endif *out_plain_len = cipher_len; return true; diff --git a/modules/core_chat/src/infra/meshtastic/mt_protocol_helpers.cpp b/modules/core_chat/src/infra/meshtastic/mt_protocol_helpers.cpp index 42904249..d67747c6 100644 --- a/modules/core_chat/src/infra/meshtastic/mt_protocol_helpers.cpp +++ b/modules/core_chat/src/infra/meshtastic/mt_protocol_helpers.cpp @@ -404,59 +404,5 @@ uint32_t djb2HashText(const char* text) return hash; } -void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset preset, bool wide_lora, - float& bw_khz, uint8_t& sf, uint8_t& cr_denom) -{ - switch (preset) - { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: - bw_khz = wide_lora ? 1625.0f : 500.0f; - cr_denom = 5; - sf = 7; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 7; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 8; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 9; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 10; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: - bw_khz = wide_lora ? 1625.0f : 500.0f; - cr_denom = 8; - sf = 11; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - bw_khz = wide_lora ? 406.25f : 125.0f; - cr_denom = 8; - sf = 11; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - bw_khz = wide_lora ? 406.25f : 125.0f; - cr_denom = 8; - sf = 12; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - default: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 11; - break; - } -} - } // namespace meshtastic -} // namespace chat \ No newline at end of file +} // namespace chat diff --git a/modules/core_chat/src/infra/meshtastic/mt_radio_config.cpp b/modules/core_chat/src/infra/meshtastic/mt_radio_config.cpp new file mode 100644 index 00000000..b235534f --- /dev/null +++ b/modules/core_chat/src/infra/meshtastic/mt_radio_config.cpp @@ -0,0 +1,207 @@ +#include "chat/infra/meshtastic/mt_radio_config.h" + +#include + +namespace chat +{ +namespace meshtastic +{ +namespace +{ + +std::uint32_t djb2Hash(const char* text) +{ + std::uint32_t hash = 5381U; + if (text == nullptr) + { + return hash; + } + while (*text != '\0') + { + hash = ((hash << 5U) + hash) + static_cast(*text); + ++text; + } + return hash; +} + +template +T clampValue(T value, T min_value, T max_value) +{ + if (value < min_value) + { + return min_value; + } + if (value > max_value) + { + return max_value; + } + return value; +} + +float normalizeBandwidthKhz(float bw_khz) +{ + if (bw_khz == 31.0f) return 31.25f; + if (bw_khz == 62.0f) return 62.5f; + if (bw_khz == 200.0f) return 203.125f; + if (bw_khz == 400.0f) return 406.25f; + if (bw_khz == 800.0f) return 812.5f; + if (bw_khz == 1600.0f) return 1625.0f; + return bw_khz; +} + +std::uint32_t channelCount(const RegionInfo& region, float bw_khz) +{ + const float span_mhz = region.freq_end_mhz - region.freq_start_mhz; + const float spacing_mhz = region.spacing_khz / 1000.0f; + const float bw_mhz = bw_khz / 1000.0f; + std::uint32_t count = + static_cast(std::floor(span_mhz / (spacing_mhz + bw_mhz))); + return count < 1U ? 1U : count; +} + +} // namespace + +const char* primaryChannelName(const MeshConfig& config) +{ + if (!config.use_preset) + { + return "Custom"; + } + + const auto preset = + static_cast(config.modem_preset); + const char* name = presetDisplayName(preset); + return (name != nullptr && name[0] != '\0') ? name : "Custom"; +} + +RadioConfig deriveRadioConfig(const MeshConfig& config) +{ + RadioConfig out{}; + + out.region_code = + static_cast(config.region); + if (out.region_code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + { + out.region_code = meshtastic_Config_LoRaConfig_RegionCode_CN; + } + const RegionInfo* region = findRegion(out.region_code); + if (region == nullptr) + { + region = findRegion(meshtastic_Config_LoRaConfig_RegionCode_CN); + out.region_code = meshtastic_Config_LoRaConfig_RegionCode_CN; + } + + out.modem_preset = + static_cast(config.modem_preset); + out.using_preset = config.use_preset; + if (out.using_preset) + { + modemPresetToParams(out.modem_preset, + region != nullptr && region->wide_lora, + out.bw_khz, + out.sf, + out.cr_denom); + } + else + { + out.bw_khz = normalizeBandwidthKhz(config.bandwidth_khz); + out.sf = clampValue(config.spread_factor, 5, 12); + out.cr_denom = clampValue(config.coding_rate, 5, 8); + if (region != nullptr) + { + if (out.bw_khz < 7.0f) + { + out.bw_khz = 7.8f; + } + if (!region->wide_lora && out.bw_khz > 500.0f) + { + out.bw_khz = 500.0f; + } + if (region->wide_lora && out.bw_khz > 1625.0f) + { + out.bw_khz = 1625.0f; + } + } + } + + if (region != nullptr) + { + const float region_span_khz = + (region->freq_end_mhz - region->freq_start_mhz) * 1000.0f; + if (region_span_khz < out.bw_khz) + { + out.using_preset = true; + out.modem_preset = + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + modemPresetToParams(out.modem_preset, + region->wide_lora, + out.bw_khz, + out.sf, + out.cr_denom); + } + } + + out.channel_name = + out.using_preset ? presetDisplayName(out.modem_preset) : "Custom"; + if (out.channel_name == nullptr || out.channel_name[0] == '\0') + { + out.channel_name = "Custom"; + } + + if (region != nullptr) + { + const std::uint32_t count = channelCount(*region, out.bw_khz); + out.channel_slot = + config.channel_num > 0 + ? static_cast((config.channel_num - 1U) % count) + : djb2Hash(out.channel_name) % count; + + out.freq_mhz = region->freq_start_mhz + (out.bw_khz / 2000.0f) + + (out.channel_slot * (out.bw_khz / 1000.0f)); + } + + if (config.override_frequency_mhz > 0.0f) + { + out.freq_mhz = config.override_frequency_mhz; + } + out.freq_mhz += config.frequency_offset_mhz; + + if (region != nullptr && config.override_frequency_mhz <= 0.0f) + { + float min_center = region->freq_start_mhz + (out.bw_khz / 2000.0f); + float max_center = region->freq_end_mhz - (out.bw_khz / 2000.0f); + if (min_center > max_center) + { + min_center = region->freq_start_mhz; + max_center = region->freq_end_mhz; + } + out.freq_mhz = clampValue(out.freq_mhz, min_center, max_center); + } + + out.tx_power_dbm = config.tx_power; + if (region != nullptr && region->power_limit_dbm > 0U) + { + const auto limit = static_cast(region->power_limit_dbm); + if (out.tx_power_dbm == 0) + { + out.tx_power_dbm = limit; + } + if (out.tx_power_dbm > limit) + { + out.tx_power_dbm = limit; + } + } + if (out.tx_power_dbm == 0) + { + out.tx_power_dbm = 17; + } + if (out.tx_power_dbm < -9) + { + out.tx_power_dbm = -9; + } + + return out; +} + +} // namespace meshtastic +} // namespace chat diff --git a/modules/core_chat/src/infra/meshtastic/mt_region.cpp b/modules/core_chat/src/infra/meshtastic/mt_region.cpp index 4926a72f..4a37468f 100644 --- a/modules/core_chat/src/infra/meshtastic/mt_region.cpp +++ b/modules/core_chat/src/infra/meshtastic/mt_region.cpp @@ -85,10 +85,12 @@ const char* presetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset) { case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: return "LongFast"; + case meshtastic_Config_LoRaConfig_ModemPreset_VERY_LONG_SLOW: + return "VeryLongSlow"; case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: return "LongTurbo"; case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - return "LongMod"; + return "LongModerate"; case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: return "LongSlow"; case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: @@ -106,6 +108,63 @@ const char* presetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset) } } +void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset preset, + bool wide_lora, + float& bw_khz, + uint8_t& sf, + uint8_t& cr_denom) +{ + switch (preset) + { + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + bw_khz = wide_lora ? 1625.0f : 500.0f; + cr_denom = 5; + sf = 7; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 7; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 8; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 9; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 10; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: + bw_khz = wide_lora ? 1625.0f : 500.0f; + cr_denom = 8; + sf = 11; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + bw_khz = wide_lora ? 406.25f : 125.0f; + cr_denom = 8; + sf = 11; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + bw_khz = wide_lora ? 406.25f : 125.0f; + cr_denom = 8; + sf = 12; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + default: + bw_khz = wide_lora ? 812.5f : 250.0f; + cr_denom = 5; + sf = 11; + break; + } +} + float computeFrequencyMhz(const RegionInfo* region, float bw_khz, const char* channel_name) { if (!region || !channel_name) @@ -142,37 +201,9 @@ float estimateFrequencyMhz(uint8_t region_code, uint8_t modem_preset) static_cast(modem_preset); float bw_khz = 250.0f; - switch (preset) - { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: - bw_khz = info->wide_lora ? 1625.0f : 500.0f; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - bw_khz = info->wide_lora ? 812.5f : 250.0f; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - bw_khz = info->wide_lora ? 812.5f : 250.0f; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - bw_khz = info->wide_lora ? 812.5f : 250.0f; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - bw_khz = info->wide_lora ? 812.5f : 250.0f; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - bw_khz = info->wide_lora ? 406.25f : 125.0f; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - bw_khz = info->wide_lora ? 406.25f : 125.0f; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: - bw_khz = info->wide_lora ? 1625.0f : 500.0f; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - default: - bw_khz = info->wide_lora ? 812.5f : 250.0f; - break; - } + uint8_t sf = 11; + uint8_t cr_denom = 5; + modemPresetToParams(preset, info->wide_lora, bw_khz, sf, cr_denom); const char* channel_name = presetDisplayName(preset); return computeFrequencyMhz(info, bw_khz, channel_name); diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index 966e3de2..d1af3782 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -5,6 +5,7 @@ #include "chat/usecase/chat_service.h" #include "chat/time_utils.h" +#include #include namespace chat @@ -156,6 +157,7 @@ void ChatService::clearAllMessages() { model_.clearAll(); store_.clearAll(); + recent_incoming_.clear(); } void ChatService::markConversationRead(const ConversationId& conv) @@ -187,6 +189,17 @@ void ChatService::processIncoming() static_cast(msg.timestamp), static_cast(msg.text.size())); + if (isDuplicateIncoming(msg)) + { + CHAT_SERVICE_LOG("[ChatService] duplicate incoming text ignored ch=%u from=%08lX peer=%08lX id=%08lX\n", + static_cast(msg.channel), + static_cast(msg.from), + static_cast(msg.peer), + static_cast(msg.msg_id)); + continue; + } + rememberIncoming(msg); + if (model_enabled_) { model_.onIncoming(msg); @@ -232,6 +245,44 @@ void ChatService::processIncoming() } } +bool ChatService::isDuplicateIncoming(const ChatMessage& msg) const +{ + if (msg.msg_id == 0 || msg.status != MessageStatus::Incoming) + { + return false; + } + + IncomingIdentity identity{}; + identity.protocol = msg.protocol; + identity.channel = msg.channel; + identity.from = msg.from; + identity.peer = msg.peer; + identity.msg_id = msg.msg_id; + return std::find(recent_incoming_.begin(), + recent_incoming_.end(), + identity) != recent_incoming_.end(); +} + +void ChatService::rememberIncoming(const ChatMessage& msg) +{ + if (msg.msg_id == 0 || msg.status != MessageStatus::Incoming) + { + return; + } + + IncomingIdentity identity{}; + identity.protocol = msg.protocol; + identity.channel = msg.channel; + identity.from = msg.from; + identity.peer = msg.peer; + identity.msg_id = msg.msg_id; + recent_incoming_.push_back(identity); + while (recent_incoming_.size() > kRecentIncomingLimit) + { + recent_incoming_.pop_front(); + } +} + void ChatService::flushStore() { store_.flush(); diff --git a/modules/core_gps/include/gps/domain/gps_diagnostics.h b/modules/core_gps/include/gps/domain/gps_diagnostics.h new file mode 100644 index 00000000..7bac8675 --- /dev/null +++ b/modules/core_gps/include/gps/domain/gps_diagnostics.h @@ -0,0 +1,62 @@ +#pragma once + +#include + +namespace gps +{ + +enum class GpsDiagnosticCode : uint8_t +{ + OK = 0, + Disabled, + NotEnabled, + PowerOff, + TransportNotReady, + NoTraffic, + TrafficStalled, + NoFix, +}; + +struct GpsDiagnosticsSnapshot +{ + bool supported = true; + bool enabled = false; + bool powered = false; + bool ready = false; + bool has_fix = false; + uint8_t satellites = 0; + uint8_t sats_in_view = 0; + uint8_t sats_in_use = 0; + uint32_t chars_total = 0; + uint32_t chars_recent = 0; + uint32_t last_rx_age_ms = 0xFFFFFFFFUL; + uint32_t poll_interval_ms = 0; + uint32_t collection_interval_ms = 0; + GpsDiagnosticCode code = GpsDiagnosticCode::OK; +}; + +inline const char* gpsDiagnosticCodeName(GpsDiagnosticCode code) +{ + switch (code) + { + case GpsDiagnosticCode::OK: + return "GPSD_OK"; + case GpsDiagnosticCode::Disabled: + return "GPSD_DISABLED"; + case GpsDiagnosticCode::NotEnabled: + return "GPSD_NOT_ENABLED"; + case GpsDiagnosticCode::PowerOff: + return "GPSD_POWER_OFF"; + case GpsDiagnosticCode::TransportNotReady: + return "GPSD_TRANSPORT_NOT_READY"; + case GpsDiagnosticCode::NoTraffic: + return "GPSD_NO_UART_TRAFFIC"; + case GpsDiagnosticCode::TrafficStalled: + return "GPSD_UART_TRAFFIC_STALLED"; + case GpsDiagnosticCode::NoFix: + return "GPSD_NO_FIX"; + } + return "GPSD_UNKNOWN"; +} + +} // namespace gps diff --git a/modules/core_gps/include/gps/ports/i_gps_hw.h b/modules/core_gps/include/gps/ports/i_gps_hw.h index 7ed655a1..f5d195dc 100644 --- a/modules/core_gps/include/gps/ports/i_gps_hw.h +++ b/modules/core_gps/include/gps/ports/i_gps_hw.h @@ -16,6 +16,7 @@ class IGpsHardware virtual void powerOn() = 0; virtual void powerOff() = 0; virtual uint32_t loop() = 0; + virtual uint32_t lastLoopReadBytes() const = 0; virtual bool hasFix() const = 0; virtual double latitude() const = 0; virtual double longitude() const = 0; @@ -29,7 +30,7 @@ class IGpsHardware virtual size_t getSatellites(gps::GnssSatInfo* out, size_t max) const = 0; virtual gps::GnssStatus getGnssStatus() const = 0; virtual bool syncTime(uint32_t gps_task_interval_ms) = 0; - virtual bool applyGnssConfig(uint8_t mode, uint8_t sat_mask) = 0; + virtual bool applyGnssConfig(uint8_t mode, uint8_t sat_mask, bool send_rxm, bool send_gnss) = 0; virtual bool applyNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) = 0; }; diff --git a/modules/core_gps/include/gps/usecase/gnss_skyplot_presenter.h b/modules/core_gps/include/gps/usecase/gnss_skyplot_presenter.h new file mode 100644 index 00000000..bbf17aac --- /dev/null +++ b/modules/core_gps/include/gps/usecase/gnss_skyplot_presenter.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include + +#include "gps/domain/gnss_satellite.h" +#include "gps/domain/gps_state.h" + +namespace gps +{ + +enum class GnssSignalState : std::uint8_t +{ + Good, + Fair, + Weak, + NotUsed, + InView, +}; + +struct GnssSkyplotSatellite +{ + std::uint16_t id = 0; + GnssSystem system = GnssSystem::UNKNOWN; + std::uint16_t azimuth = 0; + std::uint8_t elevation = 0; + std::int8_t snr = -1; + bool used = false; + GnssSignalState signal = GnssSignalState::InView; + int rank = 0; +}; + +struct GnssSkyplotStatus +{ + bool has_snapshot = false; + bool has_fix = false; + GnssFix fix = GnssFix::NOFIX; + std::uint8_t sats_in_use = 0; + std::uint8_t sats_in_view = 0; + float hdop = 0.0F; +}; + +struct GnssSkyplotView +{ + GnssSkyplotStatus status{}; + std::vector satellites{}; +}; + +const char* gnss_system_label(GnssSystem system) noexcept; +const char* gnss_fix_label(GnssFix fix) noexcept; +const char* gnss_signal_label(GnssSignalState state) noexcept; +GnssSignalState gnss_signal_state(int snr, bool used) noexcept; +int gnss_satellite_rank(const GnssSatInfo& sat) noexcept; + +GnssSkyplotView build_gnss_skyplot_view(const GnssSatInfo* sats, + std::size_t count, + const GnssStatus& status, + const GpsState& gps_state, + bool has_snapshot, + std::size_t max_satellites); + +} // namespace gps diff --git a/modules/core_gps/include/gps/usecase/gps_runtime_config.h b/modules/core_gps/include/gps/usecase/gps_runtime_config.h index 930b14d1..51e6aa1e 100644 --- a/modules/core_gps/include/gps/usecase/gps_runtime_config.h +++ b/modules/core_gps/include/gps/usecase/gps_runtime_config.h @@ -7,6 +7,43 @@ namespace gps constexpr uint8_t kDefaultGnssSatelliteMask = 0x1 | 0x8 | 0x4; +enum class GpsReceiverProtocol : uint8_t +{ + Unknown = 0, + NoTraffic = 1, + Nmea = 2, + Ubx = 3, + OtherTraffic = 4, +}; + +inline const char* gpsReceiverProtocolName(GpsReceiverProtocol protocol) +{ + switch (protocol) + { + case GpsReceiverProtocol::Unknown: + return "unknown"; + case GpsReceiverProtocol::NoTraffic: + return "no_traffic"; + case GpsReceiverProtocol::Nmea: + return "nmea"; + case GpsReceiverProtocol::Ubx: + return "ubx"; + case GpsReceiverProtocol::OtherTraffic: + return "other_traffic"; + } + return "unknown"; +} + +struct GpsReceiverInitConfig +{ + uint32_t baud = 0; + uint32_t probe_ms = 900; + uint8_t profile = 0; + uint8_t rxm_policy = 0; + uint8_t gnss_policy = 0; + uint8_t nmea_policy = 0; +}; + struct GnssRuntimeConfig { uint8_t mode = 0; diff --git a/modules/core_gps/src/usecase/gnss_skyplot_presenter.cpp b/modules/core_gps/src/usecase/gnss_skyplot_presenter.cpp new file mode 100644 index 00000000..14d7d5a2 --- /dev/null +++ b/modules/core_gps/src/usecase/gnss_skyplot_presenter.cpp @@ -0,0 +1,157 @@ +#include "gps/usecase/gnss_skyplot_presenter.h" + +#include + +namespace gps +{ +namespace +{ + +std::uint8_t clamp_sat_count(std::size_t count) noexcept +{ + return static_cast( + std::min(count, static_cast(255U))); +} + +} // namespace + +const char* gnss_system_label(GnssSystem system) noexcept +{ + switch (system) + { + case GnssSystem::GPS: + return "GPS"; + case GnssSystem::GLN: + return "GLN"; + case GnssSystem::GAL: + return "GAL"; + case GnssSystem::BD: + return "BD"; + case GnssSystem::UNKNOWN: + default: + return "UNK"; + } +} + +const char* gnss_fix_label(GnssFix fix) noexcept +{ + switch (fix) + { + case GnssFix::FIX3D: + return "3D FIX"; + case GnssFix::FIX2D: + return "2D FIX"; + case GnssFix::NOFIX: + default: + return "NO FIX"; + } +} + +const char* gnss_signal_label(GnssSignalState state) noexcept +{ + switch (state) + { + case GnssSignalState::Good: + return "good"; + case GnssSignalState::Fair: + return "fair"; + case GnssSignalState::Weak: + return "weak"; + case GnssSignalState::NotUsed: + return "not-used"; + case GnssSignalState::InView: + default: + return "in-view"; + } +} + +GnssSignalState gnss_signal_state(int snr, bool used) noexcept +{ + if (snr < 0) + { + return used ? GnssSignalState::NotUsed : GnssSignalState::InView; + } + if (!used) + { + return GnssSignalState::NotUsed; + } + if (snr >= 35) + { + return GnssSignalState::Good; + } + if (snr >= 25) + { + return GnssSignalState::Fair; + } + return GnssSignalState::Weak; +} + +int gnss_satellite_rank(const GnssSatInfo& sat) noexcept +{ + const int used_bonus = sat.used ? 1000 : 0; + const int snr_score = std::max(0, static_cast(sat.snr)) * 10; + return used_bonus + snr_score + static_cast(sat.elevation); +} + +GnssSkyplotView build_gnss_skyplot_view(const GnssSatInfo* sats, + std::size_t count, + const GnssStatus& status, + const GpsState& gps_state, + bool has_snapshot, + std::size_t max_satellites) +{ + GnssSkyplotView out{}; + out.status.has_snapshot = has_snapshot; + out.status.fix = has_snapshot ? status.fix : GnssFix::NOFIX; + out.status.has_fix = + gps_state.valid || (has_snapshot && status.fix != GnssFix::NOFIX); + out.status.hdop = has_snapshot ? status.hdop : 0.0F; + out.status.sats_in_use = + has_snapshot ? status.sats_in_use : static_cast(0); + out.status.sats_in_view = + has_snapshot ? (status.sats_in_view > 0 ? status.sats_in_view + : clamp_sat_count(count)) + : static_cast(0); + + if (!has_snapshot || sats == nullptr || count == 0 || max_satellites == 0) + { + return out; + } + + const std::size_t safe_count = + std::min(count, static_cast(kMaxGnssSats)); + out.satellites.reserve(std::min(safe_count, max_satellites)); + for (std::size_t index = 0; index < safe_count; ++index) + { + const auto& sat = sats[index]; + GnssSkyplotSatellite item{}; + item.id = sat.id; + item.system = sat.sys; + item.azimuth = sat.azimuth; + item.elevation = sat.elevation; + item.snr = sat.snr; + item.used = sat.used; + item.signal = gnss_signal_state(sat.snr, sat.used); + item.rank = gnss_satellite_rank(sat); + out.satellites.push_back(item); + } + + std::sort(out.satellites.begin(), + out.satellites.end(), + [](const GnssSkyplotSatellite& lhs, + const GnssSkyplotSatellite& rhs) + { + if (lhs.rank != rhs.rank) + { + return lhs.rank > rhs.rank; + } + return lhs.id < rhs.id; + }); + if (out.satellites.size() > max_satellites) + { + out.satellites.resize(max_satellites); + } + return out; +} + +} // namespace gps diff --git a/modules/core_sys/include/app/app_config.h b/modules/core_sys/include/app/app_config.h index 69819db5..35373803 100644 --- a/modules/core_sys/include/app/app_config.h +++ b/modules/core_sys/include/app/app_config.h @@ -101,6 +101,12 @@ struct AppConfig // GPS settings bool gps_enabled; + uint32_t gps_init_baud; + uint32_t gps_init_probe_ms; + uint8_t gps_init_profile; + uint8_t gps_init_rxm_policy; + uint8_t gps_init_gnss_policy; + uint8_t gps_init_nmea_policy; uint32_t gps_interval_ms; uint8_t gps_mode; uint8_t gps_sat_mask; @@ -170,6 +176,12 @@ struct AppConfig secondary_downlink_enabled = false; memset(secondary_key, 0, 16); gps_enabled = true; + gps_init_baud = 0; + gps_init_probe_ms = 900; + gps_init_profile = 0; + gps_init_rxm_policy = 0; + gps_init_gnss_policy = 0; + gps_init_nmea_policy = 0; gps_interval_ms = 60000; gps_mode = 0; gps_sat_mask = 0x1 | 0x8 | 0x4; diff --git a/modules/core_sys/include/platform/ui/gps_runtime.h b/modules/core_sys/include/platform/ui/gps_runtime.h index 683c21c4..fd76fde0 100644 --- a/modules/core_sys/include/platform/ui/gps_runtime.h +++ b/modules/core_sys/include/platform/ui/gps_runtime.h @@ -4,7 +4,9 @@ #include #include "gps/domain/gnss_satellite.h" +#include "gps/domain/gps_diagnostics.h" #include "gps/domain/gps_state.h" +#include "gps/usecase/gps_runtime_config.h" namespace platform::ui::gps { @@ -14,9 +16,12 @@ using GnssSatInfo = ::gps::GnssSatInfo; using GnssStatus = ::gps::GnssStatus; using GnssFix = ::gps::GnssFix; using GnssSystem = ::gps::GnssSystem; +using GpsDiagnosticsSnapshot = ::gps::GpsDiagnosticsSnapshot; +using GpsReceiverInitConfig = ::gps::GpsReceiverInitConfig; GpsState get_data(); bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count, GnssStatus* status); +GpsDiagnosticsSnapshot diagnostics(); uint32_t last_motion_ms(); bool is_enabled(); bool is_powered(); @@ -25,6 +30,7 @@ void set_collection_interval(uint32_t interval_ms); void set_power_strategy(uint8_t strategy); void set_gnss_config(uint8_t mode, uint8_t sat_mask); void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask); +void set_receiver_init_config(const GpsReceiverInitConfig& config); void set_motion_idle_timeout(uint32_t timeout_ms); void set_motion_sensor_id(uint8_t sensor_id); void suspend_runtime(); diff --git a/modules/ui_shared/include/ui/screens/settings/settings_state.h b/modules/ui_shared/include/ui/screens/settings/settings_state.h index a5f9d5ce..754404d5 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_state.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_state.h @@ -45,6 +45,12 @@ struct SettingsData { // GPS bool gps_enabled = true; + int gps_init_baud = 0; + int gps_init_probe_ms = 900; + int gps_init_profile = 0; + int gps_init_rxm_policy = 0; + int gps_init_gnss_policy = 0; + int gps_init_nmea_policy = 0; int gps_mode = 0; int gps_sat_mask = 0x1 | 0x8 | 0x4; int gps_strategy = 0; diff --git a/modules/ui_shared/src/ui/components/air_status_footer.cpp b/modules/ui_shared/src/ui/components/air_status_footer.cpp index 70ec73c2..f99e5c02 100644 --- a/modules/ui_shared/src/ui/components/air_status_footer.cpp +++ b/modules/ui_shared/src/ui/components/air_status_footer.cpp @@ -4,8 +4,7 @@ #include "app/app_facade_access.h" #include "chat/infra/mesh_protocol_utils.h" #include "chat/infra/meshcore/mc_region_presets.h" -#include "chat/infra/meshtastic/mt_protocol_helpers.h" -#include "chat/infra/meshtastic/mt_region.h" +#include "chat/infra/meshtastic/mt_radio_config.h" #include "ui/assets/fonts/font_utils.h" #include "ui/components/two_pane_layout.h" #include "ui/components/two_pane_styles.h" @@ -72,31 +71,12 @@ void format_summary_and_detail(char* summary, if (protocol == chat::MeshProtocol::Meshtastic) { - const auto region_code = - static_cast(cfg.meshtastic_config.region); - const chat::meshtastic::RegionInfo* region = chat::meshtastic::findRegion(region_code); - if (cfg.meshtastic_config.use_preset && region) - { - const auto preset = static_cast( - cfg.meshtastic_config.modem_preset); - chat::meshtastic::modemPresetToParams( - preset, region->wide_lora, bw_khz, sf, cr); - const char* channel_name = chat::meshtastic::presetDisplayName(preset); - freq_mhz = chat::meshtastic::computeFrequencyMhz(region, bw_khz, channel_name); - } - else - { - bw_khz = cfg.meshtastic_config.bandwidth_khz; - sf = cfg.meshtastic_config.spread_factor; - cr = cfg.meshtastic_config.coding_rate; - freq_mhz = cfg.meshtastic_config.override_frequency_mhz; - } - freq_mhz += cfg.meshtastic_config.frequency_offset_mhz; - if (freq_mhz <= 0.0f) - { - freq_mhz = chat::meshtastic::estimateFrequencyMhz( - cfg.meshtastic_config.region, cfg.meshtastic_config.modem_preset); - } + const chat::meshtastic::RadioConfig radio = + chat::meshtastic::deriveRadioConfig(cfg.meshtastic_config); + freq_mhz = radio.freq_mhz; + bw_khz = radio.bw_khz; + sf = radio.sf; + cr = radio.cr_denom; } else if (protocol == chat::MeshProtocol::MeshCore) { diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index 4d6bc1bb..64d96070 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -70,6 +70,8 @@ constexpr size_t kMaxWifiNetworks = 24; constexpr const char* kPrefsNs = "settings"; constexpr int kNetTxPowerMin = app::AppConfig::kTxPowerMinDbm; constexpr int kNetTxPowerMax = app::AppConfig::kTxPowerMaxDbm; +constexpr int kGpsInitProbeMinMs = 250; +constexpr int kGpsInitProbeMaxMs = 1600; struct CategoryDef { @@ -92,6 +94,18 @@ struct ImeToggleClick lv_obj_t* state_label = nullptr; }; +gps_runtime::GpsReceiverInitConfig make_gps_receiver_init_config(const app::AppConfig& config) +{ + gps_runtime::GpsReceiverInitConfig init{}; + init.baud = config.gps_init_baud; + init.probe_ms = config.gps_init_probe_ms; + init.profile = config.gps_init_profile; + init.rxm_policy = config.gps_init_rxm_policy; + init.gnss_policy = config.gps_init_gnss_policy; + init.nmea_policy = config.gps_init_nmea_policy; + return init; +} + static OptionClick s_option_clicks[kMaxOptions]{}; static constexpr size_t kMaxImeOptions = 16; static ImeToggleClick s_ime_toggle_clicks[kMaxImeOptions]{}; @@ -119,9 +133,11 @@ static lv_timer_t* s_firmware_update_timer = nullptr; static bool s_firmware_overlay_owned = false; static firmware_update_runtime::Phase s_last_firmware_phase = firmware_update_runtime::Phase::Unsupported; static bool s_last_firmware_busy = false; +static lv_obj_t* s_gps_diagnostics_label = nullptr; static void update_item_value(settings::ui::ItemWidget& widget); static void open_factory_reset_modal(); +static void open_gps_diagnostics_modal(); static void open_enabled_imes_modal(settings::ui::ItemWidget& widget); static bool option_labels_are_translated(const settings::ui::SettingItem& item); static bool option_labels_use_content_font(const settings::ui::SettingItem& item); @@ -232,6 +248,29 @@ static void refresh_wifi_state_from_runtime() } } +static void firmware_status_summary(const firmware_update_runtime::Status& status, + char* out, + size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + + const char* message = status.message[0] != '\0' + ? status.message + : (status.supported ? "Ready to check" : "OTA unsupported"); + if (status.phase == firmware_update_runtime::Phase::Error && + status.detail[0] != '\0' && + std::strcmp(status.detail, message) != 0) + { + std::snprintf(out, out_len, "%s: %s", message, status.detail); + return; + } + + copy_bounded(out, out_len, message); +} + static void refresh_firmware_update_state_from_runtime() { const firmware_update_runtime::Status status = firmware_update_runtime::status(); @@ -248,10 +287,7 @@ static void refresh_firmware_update_state_from_runtime() sizeof(g_settings.fw_latest_version), status.checked ? g_settings.fw_current_version : "Not checked"); } - copy_bounded(g_settings.fw_update_status, - sizeof(g_settings.fw_update_status), - status.message[0] != '\0' ? status.message - : (status.supported ? "Ready to check" : "OTA unsupported")); + firmware_status_summary(status, g_settings.fw_update_status, sizeof(g_settings.fw_update_status)); } static void refresh_visible_item_values() @@ -317,8 +353,12 @@ static void sync_firmware_update_ui(bool notify_completion) ::ui::SystemNotification::show(status.message, 2600); break; case firmware_update_runtime::Phase::Error: - ::ui::SystemNotification::show(status.message, 3200); + { + char summary[160]; + firmware_status_summary(status, summary, sizeof(summary)); + ::ui::SystemNotification::show(summary, 3800); break; + } default: break; } @@ -896,6 +936,12 @@ static void settings_load() gps_interval_seconds = 1; } g_settings.gps_enabled = cfg.gps_enabled; + g_settings.gps_init_baud = static_cast(cfg.gps_init_baud); + g_settings.gps_init_probe_ms = static_cast(cfg.gps_init_probe_ms); + g_settings.gps_init_profile = cfg.gps_init_profile; + g_settings.gps_init_rxm_policy = cfg.gps_init_rxm_policy; + g_settings.gps_init_gnss_policy = cfg.gps_init_gnss_policy; + g_settings.gps_init_nmea_policy = cfg.gps_init_nmea_policy; g_settings.gps_mode = cfg.gps_mode; g_settings.gps_sat_mask = cfg.gps_sat_mask; g_settings.gps_strategy = cfg.gps_strategy; @@ -1190,6 +1236,7 @@ static void modal_close() g_state.modal_error = nullptr; g_state.editing_item = nullptr; g_state.editing_widget = nullptr; + s_gps_diagnostics_label = nullptr; s_option_click_count = 0; s_ime_toggle_count = 0; modal_restore_group(); @@ -1718,6 +1765,57 @@ static void on_option_clicked(lv_event_t* e) app_ctx.saveConfig(); gps_runtime::set_collection_interval(interval_ms); } + if (payload->item->pref_key && strcmp(payload->item->pref_key, "gps_init_baud") == 0) + { + app::IAppFacade& app_ctx = app::appFacade(); + app_ctx.getConfig().gps_init_baud = static_cast(payload->value); + app_ctx.saveConfig(); + gps_runtime::set_receiver_init_config(make_gps_receiver_init_config(app_ctx.getConfig())); + } + if (payload->item->pref_key && strcmp(payload->item->pref_key, "gps_init_probe_ms") == 0) + { + app::IAppFacade& app_ctx = app::appFacade(); + int probe_ms = payload->value; + if (probe_ms < kGpsInitProbeMinMs) + { + probe_ms = kGpsInitProbeMinMs; + } + if (probe_ms > kGpsInitProbeMaxMs) + { + probe_ms = kGpsInitProbeMaxMs; + } + app_ctx.getConfig().gps_init_probe_ms = static_cast(probe_ms); + app_ctx.saveConfig(); + gps_runtime::set_receiver_init_config(make_gps_receiver_init_config(app_ctx.getConfig())); + } + if (payload->item->pref_key && strcmp(payload->item->pref_key, "gps_init_profile") == 0) + { + app::IAppFacade& app_ctx = app::appFacade(); + app_ctx.getConfig().gps_init_profile = static_cast(payload->value); + app_ctx.saveConfig(); + gps_runtime::set_receiver_init_config(make_gps_receiver_init_config(app_ctx.getConfig())); + } + if (payload->item->pref_key && strcmp(payload->item->pref_key, "gps_init_rxm") == 0) + { + app::IAppFacade& app_ctx = app::appFacade(); + app_ctx.getConfig().gps_init_rxm_policy = static_cast(payload->value); + app_ctx.saveConfig(); + gps_runtime::set_receiver_init_config(make_gps_receiver_init_config(app_ctx.getConfig())); + } + if (payload->item->pref_key && strcmp(payload->item->pref_key, "gps_init_gnss") == 0) + { + app::IAppFacade& app_ctx = app::appFacade(); + app_ctx.getConfig().gps_init_gnss_policy = static_cast(payload->value); + app_ctx.saveConfig(); + gps_runtime::set_receiver_init_config(make_gps_receiver_init_config(app_ctx.getConfig())); + } + if (payload->item->pref_key && strcmp(payload->item->pref_key, "gps_init_nmea") == 0) + { + app::IAppFacade& app_ctx = app::appFacade(); + app_ctx.getConfig().gps_init_nmea_policy = static_cast(payload->value); + app_ctx.saveConfig(); + gps_runtime::set_receiver_init_config(make_gps_receiver_init_config(app_ctx.getConfig())); + } if (payload->item->pref_key && strcmp(payload->item->pref_key, "gps_mode") == 0) { app::IAppFacade& app_ctx = app::appFacade(); @@ -2009,6 +2107,146 @@ static void open_factory_reset_modal() lv_group_focus_obj(cancel_btn); } +static void format_gps_diagnostics_text(char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + + const gps_runtime::GpsDiagnosticsSnapshot diag = gps_runtime::diagnostics(); + char last_rx[24]; + if (diag.last_rx_age_ms == 0xFFFFFFFFUL) + { + std::snprintf(last_rx, sizeof(last_rx), "never"); + } + else + { + std::snprintf(last_rx, sizeof(last_rx), "%lu ms", + static_cast(diag.last_rx_age_ms)); + } + + std::snprintf(out, + out_len, + "Code: %s\n" + "Supported: %d Enabled: %d\n" + "Powered: %d Ready: %d\n" + "Fix: %d Sats: %u\n" + "View: %u Use: %u\n" + "Chars: %lu Recent: %lu\n" + "Last RX: %s\n" + "Poll: %lu ms Publish: %lu ms", + ::gps::gpsDiagnosticCodeName(diag.code), + diag.supported ? 1 : 0, + diag.enabled ? 1 : 0, + diag.powered ? 1 : 0, + diag.ready ? 1 : 0, + diag.has_fix ? 1 : 0, + static_cast(diag.satellites), + static_cast(diag.sats_in_view), + static_cast(diag.sats_in_use), + static_cast(diag.chars_total), + static_cast(diag.chars_recent), + last_rx, + static_cast(diag.poll_interval_ms), + static_cast(diag.collection_interval_ms)); + + std::printf("[GPS] diagnostics ui code=%s enabled=%d powered=%d ready=%d fix=%d sats=%u view=%u use=%u chars=%lu recent=%lu last_rx_age_ms=%lu\n", + ::gps::gpsDiagnosticCodeName(diag.code), + diag.enabled ? 1 : 0, + diag.powered ? 1 : 0, + diag.ready ? 1 : 0, + diag.has_fix ? 1 : 0, + static_cast(diag.satellites), + static_cast(diag.sats_in_view), + static_cast(diag.sats_in_use), + static_cast(diag.chars_total), + static_cast(diag.chars_recent), + static_cast(diag.last_rx_age_ms)); +} + +static void refresh_gps_diagnostics_label() +{ + if (!s_gps_diagnostics_label || !lv_obj_is_valid(s_gps_diagnostics_label)) + { + return; + } + char text[360]; + format_gps_diagnostics_text(text, sizeof(text)); + ::ui::i18n::set_label_text_raw(s_gps_diagnostics_label, text); +} + +static void on_gps_diagnostics_refresh_clicked(lv_event_t* e) +{ + (void)e; + refresh_gps_diagnostics_label(); +} + +static void on_gps_diagnostics_close_clicked(lv_event_t* e) +{ + (void)e; + modal_close(); +} + +static void open_gps_diagnostics_modal() +{ + if (g_state.modal_root) + { + return; + } + + modal_prepare_group(); + g_state.modal_root = create_modal_root(300, 220); + lv_obj_t* win = lv_obj_get_child(g_state.modal_root, 0); + + lv_obj_t* title = lv_label_create(win); + ::ui::i18n::set_label_text(title, "GPS Diagnostics"); + style::apply_label_primary(title); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + + s_gps_diagnostics_label = lv_label_create(win); + lv_obj_set_width(s_gps_diagnostics_label, LV_PCT(100)); + lv_label_set_long_mode(s_gps_diagnostics_label, LV_LABEL_LONG_WRAP); + style::apply_label_muted(s_gps_diagnostics_label); + lv_obj_align(s_gps_diagnostics_label, LV_ALIGN_TOP_LEFT, 0, 28); + refresh_gps_diagnostics_label(); + + lv_obj_t* btn_row = lv_obj_create(win); + lv_obj_set_size(btn_row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_align(btn_row, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_flex_flow(btn_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(btn_row, + LV_FLEX_ALIGN_SPACE_EVENLY, + LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(btn_row, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(btn_row, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(btn_row, 0, LV_PART_MAIN); + lv_obj_clear_flag(btn_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* refresh_btn = lv_btn_create(btn_row); + lv_obj_set_size(refresh_btn, + ::ui::page_profile::resolve_control_button_min_width(), + ::ui::page_profile::resolve_control_button_height()); + lv_obj_t* refresh_label = lv_label_create(refresh_btn); + ::ui::i18n::set_label_text(refresh_label, "Refresh"); + lv_obj_center(refresh_label); + lv_obj_add_event_cb(refresh_btn, on_gps_diagnostics_refresh_clicked, LV_EVENT_CLICKED, nullptr); + + lv_obj_t* close_btn = lv_btn_create(btn_row); + lv_obj_set_size(close_btn, + ::ui::page_profile::resolve_control_button_min_width(), + ::ui::page_profile::resolve_control_button_height()); + lv_obj_t* close_label = lv_label_create(close_btn); + ::ui::i18n::set_label_text(close_label, "Close"); + lv_obj_center(close_label); + lv_obj_add_event_cb(close_btn, on_gps_diagnostics_close_clicked, LV_EVENT_CLICKED, nullptr); + + lv_group_add_obj(g_state.modal_group, refresh_btn); + lv_group_add_obj(g_state.modal_group, close_btn); + lv_group_focus_obj(refresh_btn); +} + static void on_enabled_imes_back_clicked(lv_event_t* e) { (void)e; @@ -2284,6 +2522,32 @@ static const settings::ui::SettingOption kGpsModeOptions[] = { {"Power Save", 1}, {"Fix Only", 2}, }; +static const settings::ui::SettingOption kGpsInitBaudOptions[] = { + {"Auto", 0}, + {"9600", 9600}, + {"38400", 38400}, + {"115200", 115200}, + {"57600", 57600}, + {"19200", 19200}, + {"4800", 4800}, +}; +static const settings::ui::SettingOption kGpsInitProbeOptions[] = { + {"250 ms", 250}, + {"500 ms", 500}, + {"900 ms", 900}, + {"1600 ms", 1600}, +}; +static const settings::ui::SettingOption kGpsInitProfileOptions[] = { + {"Auto", 0}, + {"NMEA Passive", 1}, + {"u-blox Legacy", 2}, + {"u-blox Modern", 3}, +}; +static const settings::ui::SettingOption kGpsInitPolicyOptions[] = { + {"Auto", 0}, + {"Skip", 1}, + {"Send", 2}, +}; static const settings::ui::SettingOption kGpsSatOptions[] = { {"GPS+BDS+GAL", 0x1 | 0x8 | 0x4}, {"GPS", 0x1}, @@ -2457,8 +2721,8 @@ static const settings::ui::SettingOption kExternalNmeaOptions[] = { {"5Hz", 5}, }; static const settings::ui::SettingOption kExternalNmeaSentenceOptions[] = { - {"GGA+RMC+GSV", 0}, - {"RMC+GSV", 1}, + {"GGA+RMC+GSA+GSV", 0}, + {"RMC+GSA+GSV", 1}, {"GGA+RMC", 2}, }; @@ -2518,6 +2782,12 @@ static const settings::ui::SettingOption kTimeZoneOptions[] = { static settings::ui::SettingItem kGpsItems[] = { {"GPS Enabled", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.gps_enabled, nullptr, 0, false, "gps_enabled"}, + {"Receiver Baud", settings::ui::SettingType::Enum, kGpsInitBaudOptions, 7, &g_settings.gps_init_baud, nullptr, nullptr, 0, false, "gps_init_baud"}, + {"Probe Window", settings::ui::SettingType::Enum, kGpsInitProbeOptions, 4, &g_settings.gps_init_probe_ms, nullptr, nullptr, 0, false, "gps_init_probe_ms"}, + {"Receiver Profile", settings::ui::SettingType::Enum, kGpsInitProfileOptions, 4, &g_settings.gps_init_profile, nullptr, nullptr, 0, false, "gps_init_profile"}, + {"RXM Init", settings::ui::SettingType::Enum, kGpsInitPolicyOptions, 3, &g_settings.gps_init_rxm_policy, nullptr, nullptr, 0, false, "gps_init_rxm"}, + {"GNSS Init", settings::ui::SettingType::Enum, kGpsInitPolicyOptions, 3, &g_settings.gps_init_gnss_policy, nullptr, nullptr, 0, false, "gps_init_gnss"}, + {"NMEA Init", settings::ui::SettingType::Enum, kGpsInitPolicyOptions, 3, &g_settings.gps_init_nmea_policy, nullptr, nullptr, 0, false, "gps_init_nmea"}, {"Location Mode", settings::ui::SettingType::Enum, kGpsModeOptions, 3, &g_settings.gps_mode, nullptr, nullptr, 0, false, "gps_mode"}, {"Satellite Systems", settings::ui::SettingType::Enum, kGpsSatOptions, 5, &g_settings.gps_sat_mask, nullptr, nullptr, 0, false, "gps_sat_mask"}, {"Position Strategy", settings::ui::SettingType::Enum, kGpsStrategyOptions, 3, &g_settings.gps_strategy, nullptr, nullptr, 0, false, "gps_strategy"}, @@ -2526,6 +2796,7 @@ static settings::ui::SettingItem kGpsItems[] = { {"Coordinate Format", settings::ui::SettingType::Enum, kGpsCoordOptions, 3, &g_settings.gps_coord_format, nullptr, nullptr, 0, false, "gps_coord_fmt"}, {"NMEA Export", settings::ui::SettingType::Enum, kExternalNmeaOptions, 3, &g_settings.external_nmea_output_hz, nullptr, nullptr, 0, false, "external_nmea"}, {"NMEA Sentences", settings::ui::SettingType::Enum, kExternalNmeaSentenceOptions, 3, &g_settings.external_nmea_sentence_mask, nullptr, nullptr, 0, false, "external_nmea_sent"}, + {"Diagnostics", settings::ui::SettingType::Action, nullptr, 0, nullptr, nullptr, nullptr, 0, false, "gps_diagnostics"}, }; static settings::ui::SettingItem kMapItems[] = { @@ -3137,6 +3408,10 @@ static bool activate_item_widget(settings::ui::ItemWidget& widget) { open_enabled_imes_modal(widget); } + else if (item.pref_key && strcmp(item.pref_key, "gps_diagnostics") == 0) + { + open_gps_diagnostics_modal(); + } else if (item.pref_key && strcmp(item.pref_key, "chat_reset_mesh") == 0) { reset_mesh_settings(); @@ -3205,10 +3480,20 @@ static bool activate_item_widget(settings::ui::ItemWidget& widget) { sync_firmware_update_ui(false); const firmware_update_runtime::Status status = firmware_update_runtime::status(); - const char* message = status.busy ? "Update task already running" - : (status.message[0] != '\0' ? status.message - : "Unable to start update check"); - ::ui::SystemNotification::show(message, 2600); + char message[160]; + if (status.busy) + { + copy_bounded(message, sizeof(message), "Update task already running"); + } + else if (status.message[0] != '\0') + { + firmware_status_summary(status, message, sizeof(message)); + } + else + { + copy_bounded(message, sizeof(message), "Unable to start update check"); + } + ::ui::SystemNotification::show(message, 3000); } else { @@ -3221,10 +3506,20 @@ static bool activate_item_widget(settings::ui::ItemWidget& widget) { sync_firmware_update_ui(false); const firmware_update_runtime::Status status = firmware_update_runtime::status(); - const char* message = status.busy ? "Update task already running" - : (status.message[0] != '\0' ? status.message - : "Unable to start OTA install"); - ::ui::SystemNotification::show(message, 2600); + char message[160]; + if (status.busy) + { + copy_bounded(message, sizeof(message), "Update task already running"); + } + else if (status.message[0] != '\0') + { + firmware_status_summary(status, message, sizeof(message)); + } + else + { + copy_bounded(message, sizeof(message), "Unable to start OTA install"); + } + ::ui::SystemNotification::show(message, 3000); } else { diff --git a/platform/esp/arduino_common/include/hal/hal_gps.h b/platform/esp/arduino_common/include/hal/hal_gps.h index 359c677c..af6bd503 100644 --- a/platform/esp/arduino_common/include/hal/hal_gps.h +++ b/platform/esp/arduino_common/include/hal/hal_gps.h @@ -16,6 +16,7 @@ class HalGps void powerOn(); void powerOff(); uint32_t loop(); + uint32_t lastLoopReadBytes() const; bool hasFix() const; double latitude() const; double longitude() const; @@ -29,7 +30,7 @@ class HalGps size_t getSatellites(gps::GnssSatInfo* out, size_t max) const; gps::GnssStatus getGnssStatus() const; bool syncTime(uint32_t gps_task_interval_ms); - bool applyGnssConfig(uint8_t mode, uint8_t sat_mask); + bool applyGnssConfig(uint8_t mode, uint8_t sat_mask, bool send_rxm, bool send_gnss); bool applyNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); private: diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/GPS.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/GPS.h index d7aa1f88..9d9b2fa8 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/GPS.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/GPS.h @@ -42,6 +42,7 @@ class GPS : public TinyGPSPlus uint32_t loop(bool debug = false) { + last_loop_read_bytes_ = 0; if (_stream == nullptr) { return charsProcessed(); @@ -55,12 +56,19 @@ class GPS : public TinyGPSPlus while (_stream->available()) { int c = _stream->read(); + if (c < 0) + { + continue; + } chars_processed++; + last_loop_read_bytes_++; + observeDebugByte(static_cast(c)); + const bool nmea_candidate = (c == '$') || nmea_collecting_; if (debug) { Serial.write(c); } - else + else if (nmea_candidate) { encode(c); } @@ -73,6 +81,7 @@ class GPS : public TinyGPSPlus _stream->write(Serial.read()); } } + flushDebugRawSampleIfStale(millis()); // Log periodically (every 100 loops or every 5 seconds) loop_count++; @@ -93,6 +102,11 @@ class GPS : public TinyGPSPlus return charsProcessed(); } + uint32_t lastLoopReadBytes() const + { + return last_loop_read_bytes_; + } + String getModel() { return model; @@ -114,6 +128,12 @@ class GPS : public TinyGPSPlus bool sendUbx(uint8_t cls, uint8_t id, const uint8_t* payload, uint16_t len, bool wait_ack); void calcUbxChecksum(const uint8_t* data, uint16_t len, uint8_t& ck_a, uint8_t& ck_b); + void observeDebugByte(uint8_t b); + void flushDebugRawSampleIfStale(uint32_t now_ms); + void logDebugRawSample(const char* reason); + void observeDebugRawBurstByte(uint8_t b, uint32_t now_ms); + void logDebugRawBurst(const char* reason, uint32_t now_ms); + void logDebugNmeaSentence(const char* sentence); void handleNmeaChar(char c); void parseNmeaSentence(char* sentence); void parseGsv(const char* talker, char** fields, int field_count); @@ -137,4 +157,24 @@ class GPS : public TinyGPSPlus char nmea_buf_[128]{}; uint8_t nmea_len_ = 0; bool nmea_collecting_ = false; + uint8_t debug_nmea_sentence_count_ = 0; + uint8_t debug_raw_sample_[64]{}; + uint8_t debug_raw_sample_len_ = 0; + uint32_t debug_raw_first_ms_ = 0; + bool debug_raw_sample_logged_ = false; + bool debug_raw_saw_nmea_ = false; + uint8_t debug_raw_burst_sample_[64]{}; + uint8_t debug_raw_burst_sample_len_ = 0; + uint16_t debug_raw_burst_count_ = 0; + uint32_t debug_raw_burst_first_ms_ = 0; + uint32_t debug_raw_burst_last_ms_ = 0; + uint32_t debug_raw_burst_idle_gap_ms_ = 0; + uint32_t debug_raw_burst_bytes_ = 0; + uint16_t debug_raw_burst_printable_ = 0; + uint16_t debug_raw_burst_comma_ = 0; + uint16_t debug_raw_burst_high_ = 0; + uint16_t debug_raw_burst_zero_ = 0; + uint16_t debug_raw_burst_ubx_sync_ = 0; + bool debug_raw_burst_logged_ = false; + uint32_t last_loop_read_bytes_ = 0; }; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service.h index 56b75087..14ddd118 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service.h @@ -1,12 +1,14 @@ #pragma once #include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "freertos/task.h" #include #include "board/GpsBoard.h" #include "board/MotionBoard.h" #include "gps/domain/gnss_satellite.h" +#include "gps/domain/gps_diagnostics.h" #include "gps/domain/gps_state.h" #include "gps/domain/motion_config.h" #include "gps/motion_policy.h" @@ -25,9 +27,11 @@ class GpsService static GpsService& getInstance(); void begin(GpsBoard& gps_board, MotionBoard& motion_board, uint32_t disable_hw_init, - uint32_t gps_interval_ms, const MotionConfig& motion_config); + uint32_t gps_interval_ms, const MotionConfig& motion_config, + const GpsReceiverInitConfig& receiver_init_config = GpsReceiverInitConfig{}); GpsState getData(); bool getGnssSnapshot(GnssSatInfo* out, size_t max, size_t* out_count, GnssStatus* status); + GpsDiagnosticsSnapshot getDiagnostics(); uint32_t getCollectionInterval() const; void setEnabled(bool enabled); void setCollectionInterval(uint32_t interval_ms); @@ -35,6 +39,7 @@ class GpsService void setTeamModeActive(bool active); void setGnssConfig(uint8_t mode, uint8_t sat_mask); void setExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask); + void setReceiverInitConfig(const GpsReceiverInitConfig& config); MotionConfig getMotionConfig() const { return motion_config_; } void setMotionConfig(const MotionConfig& config); void setMotionIdleTimeout(uint32_t timeout_ms); @@ -57,6 +62,10 @@ class GpsService void updateMotionState(uint32_t now_ms); void applyGnssConfig(); void applyInternalNmeaConfig(); + bool takeGpsUartLock(TickType_t timeout = portMAX_DELAY); + void giveGpsUartLock(); + bool canSendReceiverUbxConfig(const char* source) const; + void startPostConfigStreamWatch(const char* label, uint32_t chars_total); GpsBoard* gps_board_ = nullptr; MotionBoard* motion_board_ = nullptr; @@ -65,17 +74,30 @@ class GpsService size_t gnss_sat_count_ = 0; GnssStatus gnss_status_{}; SemaphoreHandle_t gps_data_mutex_ = nullptr; + // Serializes physical UART open/read/write/close paths. This matters on T-Deck + // because init/config code and the collector task can otherwise touch Serial1 at + // the same time. + SemaphoreHandle_t gps_init_mutex_ = nullptr; TaskHandle_t gps_task_handle_ = nullptr; TaskHandle_t motion_task_handle_ = nullptr; uint32_t gps_last_update_time_ = 0; GpsRuntimeState runtime_state_{}; GpsRuntimeConfig runtime_config_{}; + GpsReceiverInitConfig receiver_init_config_{}; bool gps_time_synced_ = false; bool gps_powered_ = false; bool gps_disabled_ = false; bool user_enabled_ = true; bool gps_ready_ = false; + bool gps_initializing_ = false; + uint32_t gps_chars_total_ = 0; + uint32_t gps_chars_recent_ = 0; + uint32_t gps_last_rx_ms_ = 0; + bool gps_post_config_watch_active_ = false; + uint32_t gps_post_config_start_ms_ = 0; + uint32_t gps_post_config_chars_ = 0; + char gps_post_config_label_[24]{}; MotionConfig motion_config_{}; MotionPolicy motion_policy_{}; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service_api.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service_api.h index 0d9e77aa..100eae88 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service_api.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/gps_service_api.h @@ -3,14 +3,17 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "gps/domain/gnss_satellite.h" +#include "gps/domain/gps_diagnostics.h" #include "gps/domain/gps_state.h" #include "gps/domain/motion_config.h" +#include "gps/usecase/gps_runtime_config.h" namespace gps { GpsState gps_get_data(); bool gps_get_gnss_snapshot(gps::GnssSatInfo* out, size_t max, size_t* out_count, gps::GnssStatus* status); +GpsDiagnosticsSnapshot gps_get_diagnostics(); uint32_t gps_get_last_motion_ms(); bool gps_is_enabled(); bool gps_is_powered(); @@ -19,6 +22,7 @@ void gps_set_collection_interval(uint32_t interval_ms); void gps_set_power_strategy(uint8_t strategy); void gps_set_gnss_config(uint8_t mode, uint8_t sat_mask); void gps_set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask); +void gps_set_receiver_init_config(const GpsReceiverInitConfig& config); void gps_set_motion_idle_timeout(uint32_t timeout_ms); void gps_set_motion_sensor_id(uint8_t sensor_id); TaskHandle_t gps_get_task_handle(); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/infra/hal_gps_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/infra/hal_gps_adapter.h index dcd2fc30..addeb4df 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/infra/hal_gps_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/infra/hal_gps_adapter.h @@ -17,6 +17,7 @@ class HalGpsAdapter : public IGpsHardware void powerOn() override; void powerOff() override; uint32_t loop() override; + uint32_t lastLoopReadBytes() const override; bool hasFix() const override; double latitude() const override; double longitude() const override; @@ -30,7 +31,7 @@ class HalGpsAdapter : public IGpsHardware size_t getSatellites(gps::GnssSatInfo* out, size_t max) const override; gps::GnssStatus getGnssStatus() const override; bool syncTime(uint32_t gps_task_interval_ms) override; - bool applyGnssConfig(uint8_t mode, uint8_t sat_mask) override; + bool applyGnssConfig(uint8_t mode, uint8_t sat_mask, bool send_rxm, bool send_gnss) override; bool applyNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) override; private: diff --git a/platform/esp/arduino_common/src/app_config_store.cpp b/platform/esp/arduino_common/src/app_config_store.cpp index e5471ec6..08a8ec6a 100644 --- a/platform/esp/arduino_common/src/app_config_store.cpp +++ b/platform/esp/arduino_common/src/app_config_store.cpp @@ -26,6 +26,12 @@ constexpr const char* kChatKeySecondaryEnabled = "sec_enabled"; constexpr const char* kChatKeyPrimaryDownlink = "pri_downlink"; constexpr const char* kChatKeySecondaryUplink = "sec_uplink"; constexpr const char* kChatKeySecondaryDownlink = "sec_downlink"; +constexpr const char* kGpsKeyInitBaud = "init_baud"; +constexpr const char* kGpsKeyInitProbeMs = "init_probe_ms"; +constexpr const char* kGpsKeyInitProfile = "init_profile"; +constexpr const char* kGpsKeyInitRxmPolicy = "init_rxm_pol"; +constexpr const char* kGpsKeyInitGnssPolicy = "init_gnss_pol"; +constexpr const char* kGpsKeyInitNmeaPolicy = "init_nmea_pol"; constexpr const char* kGpsKeyMotionSensorId = "motion_sensor"; constexpr const char* kGpsKeyExternalNmeaSentence = "ext_nmea_sent"; constexpr const char* kSettingsKeyMapTrackInterval = "map_track_int"; @@ -651,6 +657,14 @@ void log_config_summary(const char* phase, const AppConfig& config) static_cast(config.meshtastic_config.tx_power), static_cast(config.meshcore_config.tx_power), static_cast(config.rnode_config.tx_power)); + Serial.printf("[AppCfg][%s][gps_init] baud=%lu probe_ms=%lu profile=%u rxm=%u gnss=%u nmea=%u\n", + safe_label(phase), + static_cast(config.gps_init_baud), + static_cast(config.gps_init_probe_ms), + static_cast(config.gps_init_profile), + static_cast(config.gps_init_rxm_policy), + static_cast(config.gps_init_gnss_policy), + static_cast(config.gps_init_nmea_policy)); Serial.printf("[AppCfg][%s][gps] enabled=%s interval_ms=%lu mode=%u sat_mask=%u strategy=%u alt_ref=%u coord_fmt=%u motion_idle_ms=%lu sensor_id=%u external_nmea=%u sentence=%u\n", safe_label(phase), bool_label(config.gps_enabled), @@ -713,6 +727,12 @@ bool loadAppConfigFromPreferences(AppConfig& config, auto& secondary_downlink_enabled = config.secondary_downlink_enabled; auto& secondary_key = config.secondary_key; auto& gps_enabled = config.gps_enabled; + auto& gps_init_baud = config.gps_init_baud; + auto& gps_init_probe_ms = config.gps_init_probe_ms; + auto& gps_init_profile = config.gps_init_profile; + auto& gps_init_rxm_policy = config.gps_init_rxm_policy; + auto& gps_init_gnss_policy = config.gps_init_gnss_policy; + auto& gps_init_nmea_policy = config.gps_init_nmea_policy; auto& gps_interval_ms = config.gps_interval_ms; auto& gps_mode = config.gps_mode; auto& gps_sat_mask = config.gps_sat_mask; @@ -899,6 +919,12 @@ bool loadAppConfigFromPreferences(AppConfig& config, }; gps_enabled = get_bool("gps_enabled", gps_enabled); + gps_init_baud = get_uint(kGpsKeyInitBaud, gps_init_baud); + gps_init_probe_ms = get_uint(kGpsKeyInitProbeMs, gps_init_probe_ms); + gps_init_profile = get_uchar(kGpsKeyInitProfile, gps_init_profile); + gps_init_rxm_policy = get_uchar(kGpsKeyInitRxmPolicy, gps_init_rxm_policy); + gps_init_gnss_policy = get_uchar(kGpsKeyInitGnssPolicy, gps_init_gnss_policy); + gps_init_nmea_policy = get_uchar(kGpsKeyInitNmeaPolicy, gps_init_nmea_policy); gps_interval_ms = get_uint("gps_interval", gps_interval_ms); gps_mode = get_uchar("gps_mode", gps_mode); gps_sat_mask = get_uchar("gps_sat_mask", gps_sat_mask); @@ -1032,6 +1058,12 @@ bool saveAppConfigToPreferences(AppConfig& config, auto& secondary_downlink_enabled = config.secondary_downlink_enabled; auto& secondary_key = config.secondary_key; auto& gps_enabled = config.gps_enabled; + auto& gps_init_baud = config.gps_init_baud; + auto& gps_init_probe_ms = config.gps_init_probe_ms; + auto& gps_init_profile = config.gps_init_profile; + auto& gps_init_rxm_policy = config.gps_init_rxm_policy; + auto& gps_init_gnss_policy = config.gps_init_gnss_policy; + auto& gps_init_nmea_policy = config.gps_init_nmea_policy; auto& gps_interval_ms = config.gps_interval_ms; auto& gps_mode = config.gps_mode; auto& gps_sat_mask = config.gps_sat_mask; @@ -1185,6 +1217,12 @@ bool saveAppConfigToPreferences(AppConfig& config, }; put_bool("gps_enabled", gps_enabled); + put_uint(kGpsKeyInitBaud, gps_init_baud); + put_uint(kGpsKeyInitProbeMs, gps_init_probe_ms); + put_uchar(kGpsKeyInitProfile, gps_init_profile); + put_uchar(kGpsKeyInitRxmPolicy, gps_init_rxm_policy); + put_uchar(kGpsKeyInitGnssPolicy, gps_init_gnss_policy); + put_uchar(kGpsKeyInitNmeaPolicy, gps_init_nmea_policy); put_uint("gps_interval", gps_interval_ms); put_uchar("gps_mode", gps_mode); put_uchar("gps_sat_mask", gps_sat_mask); diff --git a/platform/esp/arduino_common/src/app_context_platform_bindings.cpp b/platform/esp/arduino_common/src/app_context_platform_bindings.cpp index 17b3d326..74ab6a14 100644 --- a/platform/esp/arduino_common/src/app_context_platform_bindings.cpp +++ b/platform/esp/arduino_common/src/app_context_platform_bindings.cpp @@ -31,6 +31,18 @@ namespace { +gps::GpsReceiverInitConfig make_receiver_init_config(const app::AppConfig& config) +{ + gps::GpsReceiverInitConfig init{}; + init.baud = config.gps_init_baud; + init.probe_ms = config.gps_init_probe_ms; + init.profile = config.gps_init_profile; + init.rxm_policy = config.gps_init_rxm_policy; + init.gnss_policy = config.gps_init_gnss_policy; + init.nmea_policy = config.gps_init_nmea_policy; + return init; +} + std::unique_ptr create_mesh_runtime() { return std::unique_ptr(new chat::MeshAdapterRouter()); @@ -51,7 +63,8 @@ void init_gps_runtime(GpsBoard* gps_board, *motion_board, disable_hw_init, config.gps_interval_ms, - config.motion_config); + config.motion_config, + make_receiver_init_config(config)); gps_service.setEnabled(config.gps_enabled); gps_service.setCollectionInterval(config.gps_interval_ms); gps_service.setPowerStrategy(config.gps_strategy); @@ -61,6 +74,7 @@ void init_gps_runtime(GpsBoard* gps_board, void apply_position_config(const app::AppConfig& config) { + gps::GpsService::getInstance().setReceiverInitConfig(make_receiver_init_config(config)); gps::GpsService::getInstance().setEnabled(config.gps_enabled); gps::GpsService::getInstance().setCollectionInterval(config.gps_interval_ms); gps::GpsService::getInstance().setPowerStrategy(config.gps_strategy); diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp index fae478e4..17cf90fd 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp @@ -24,8 +24,10 @@ #define TEST_CURVE25519_FIELD_OPS #include "../../internal/blob_store_io.h" #include "board/TLoRaPagerTypes.h" +#include "chat/infra/meshtastic/mt_node_payload.h" #include "chat/infra/meshtastic/mt_pki_crypto.h" #include "chat/infra/meshtastic/mt_protocol_helpers.h" +#include "chat/infra/meshtastic/mt_radio_config.h" #include "chat/infra/meshtastic/mt_region.h" #include "meshtastic/config.pb.h" #include "meshtastic/mqtt.pb.h" @@ -51,8 +53,6 @@ namespace { constexpr uint8_t kDefaultPskIndex = 1; constexpr const char* kSecondaryChannelName = "Squad"; -constexpr uint8_t kLoraSyncWord = 0x2b; -constexpr uint16_t kLoraPreambleLen = 16; constexpr uint8_t kBitfieldWantResponseMask = 0x02; constexpr size_t kMaxMqttProxyQueue = 12; constexpr uint32_t kBroadcastNodeId = 0xFFFFFFFFu; @@ -62,14 +62,12 @@ using chat::meshtastic::appendTraceRouteNodeAndSnr; using chat::meshtastic::computeHopsAway; using chat::meshtastic::computeKeyVerificationHashes; using chat::meshtastic::decryptPkiAesCcm; -using chat::meshtastic::djb2HashText; using chat::meshtastic::encryptPkiAesCcm; using chat::meshtastic::fillDecodedPacketCommon; using chat::meshtastic::hashSharedKey; using chat::meshtastic::initPkiNonce; using chat::meshtastic::insertTraceRouteUnknownHops; using chat::meshtastic::makeEncryptedPacketFromWire; -using chat::meshtastic::modemPresetToParams; using chat::meshtastic::readPbString; using chat::meshtastic::shouldSetAirWantAck; @@ -234,30 +232,27 @@ static bool build_self_position_payload(uint8_t* out_buf, size_t* out_len) return true; } -static void publishPositionEvent(uint32_t node_id, const meshtastic_Position& pos) +static void publishPositionEvent(uint32_t node_id, + const chat::contacts::NodePosition& pos) { - if (node_id == 0 || !hasValidPosition(pos)) + if (node_id == 0 || !pos.valid) { return; } - bool has_altitude = pos.has_altitude || pos.has_altitude_hae; - int32_t altitude = pos.has_altitude ? pos.altitude : (pos.has_altitude_hae ? pos.altitude_hae : 0); - uint32_t ts = pos.timestamp ? pos.timestamp : pos.time; - - sys::NodePositionUpdateEvent* event = new sys::NodePositionUpdateEvent( - node_id, - pos.latitude_i, - pos.longitude_i, - has_altitude, - altitude, - ts, - pos.precision_bits, - pos.PDOP, - pos.HDOP, - pos.VDOP, - pos.gps_accuracy); - sys::EventBus::publish(event, 0); + sys::EventBus::publish( + new sys::NodePositionUpdateEvent(node_id, + pos.latitude_i, + pos.longitude_i, + pos.has_altitude, + pos.altitude, + pos.timestamp, + pos.precision_bits, + pos.pdop, + pos.hdop, + pos.vdop, + pos.gps_accuracy_mm), + 0); } } // namespace @@ -1795,6 +1790,7 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) { channel_index = 1; } + const uint32_t packet_timestamp = static_cast(time(nullptr)); auto publish_link_stats = [&](uint32_t node_id) { float snr = last_rx_snr_; @@ -1804,9 +1800,8 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) { return; } - uint32_t now_secs = time(nullptr); sys::NodeInfoUpdateEvent* event = new sys::NodeInfoUpdateEvent( - node_id, "", "", snr, rssi, now_secs, 0, + node_id, "", "", snr, rssi, packet_timestamp, 0, chat::contacts::kNodeRoleUnknown, hops_away, 0, channel_index); sys::EventBus::publish(event, 0); @@ -1814,178 +1809,94 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) if (decoded.portnum == meshtastic_PortNum_NODEINFO_APP && decoded.payload.size > 0) { - meshtastic_NodeInfo node = meshtastic_NodeInfo_init_default; - pb_istream_t nstream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); - if (pb_decode(&nstream, meshtastic_NodeInfo_fields, &node)) + chat::meshtastic::NodePayloadDecodeContext context{}; + context.fallback_node_id = header.from; + context.snr = last_rx_snr_; + context.rssi = last_rx_rssi_; + context.timestamp = packet_timestamp; + context.hops_away = computeHopsAway(header.flags); + context.channel = channel_index; + context.via_mqtt = + (header.flags & chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0; + + chat::meshtastic::DecodedNodePayload node{}; + if (chat::meshtastic::decodeNodeInfoPayload(decoded, context, &node)) { - uint32_t node_id = node.num ? node.num : header.from; - const char* short_name = node.has_user ? node.user.short_name : ""; - const char* long_name = node.has_user ? node.user.long_name : ""; - uint8_t role = chat::contacts::kNodeRoleUnknown; - if (node.has_user && node.user.role <= meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) - { - role = static_cast(node.user.role); - } - - float snr = last_rx_snr_; - if (std::isnan(snr)) - { - snr = node.snr; - } - float rssi = last_rx_rssi_; - uint8_t hops_away = node.has_hops_away ? node.hops_away : computeHopsAway(header.flags); - LORA_LOG("[LORA] RX NodeInfo from %08lX short='%s' long='%s' snr=%.1f\n", - (unsigned long)node_id, short_name, long_name, snr); + (unsigned long)node.node_id, + node.short_name.c_str(), + node.long_name.c_str(), + node.snr); - if (long_name[0]) + if (!node.long_name.empty()) { - node_long_names_[node_id] = long_name; + node_long_names_[node.node_id] = node.long_name; } - if (node.has_user && node.user.public_key.size == 32) + if (node.has_public_key) { - std::array key{}; - memcpy(key.data(), node.user.public_key.bytes, 32); - node_public_keys_[node_id] = key; - savePkiNodeKey(node_id, key.data(), key.size()); - std::string key_fp = toHex(key.data(), key.size(), 8); + node_public_keys_[node.node_id] = node.public_key; + savePkiNodeKey(node.node_id, + node.public_key.data(), + node.public_key.size()); + std::string key_fp = + toHex(node.public_key.data(), node.public_key.size(), 8); LORA_LOG("[LORA] PKI key stored for %08lX fp=%s\n", - (unsigned long)node_id, key_fp.c_str()); + (unsigned long)node.node_id, key_fp.c_str()); LORA_LOG("[LORA] PKI key updated for %08lX\n", - (unsigned long)node_id); + (unsigned long)node.node_id); } - uint32_t now_secs = time(nullptr); - chat::contacts::NodeDeviceMetrics metrics{}; - const bool has_metrics = node.has_device_metrics; - if (has_metrics) - { - metrics.has_battery_level = node.device_metrics.has_battery_level; - metrics.battery_level = node.device_metrics.battery_level; - metrics.has_voltage = node.device_metrics.has_voltage; - metrics.voltage = node.device_metrics.voltage; - metrics.has_channel_utilization = node.device_metrics.has_channel_utilization; - metrics.channel_utilization = node.device_metrics.channel_utilization; - metrics.has_air_util_tx = node.device_metrics.has_air_util_tx; - metrics.air_util_tx = node.device_metrics.air_util_tx; - metrics.has_uptime_seconds = node.device_metrics.has_uptime_seconds; - metrics.uptime_seconds = node.device_metrics.uptime_seconds; - } sys::NodeInfoUpdateEvent* event = new sys::NodeInfoUpdateEvent( - node_id, short_name, long_name, snr, rssi, now_secs, - static_cast(chat::contacts::NodeProtocolType::Meshtastic), role, - hops_away, static_cast(node.user.hw_model), channel_index, - node.has_user, node.has_user ? node.user.macaddr : nullptr, - node.via_mqtt || ((header.flags & chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0), + node.node_id, + node.short_name.c_str(), + node.long_name.c_str(), + node.snr, + node.rssi, + node.timestamp, + node.protocol, + node.role, + node.hops_away, + node.hw_model, + node.channel, + node.has_macaddr, + node.has_macaddr ? node.macaddr.data() : nullptr, + node.via_mqtt, node.is_ignored, - node.has_user && node.user.public_key.size == 32, - node.is_key_manually_verified, - has_metrics, has_metrics ? &metrics : nullptr); + node.has_public_key, + node.key_manually_verified, + node.has_device_metrics, + node.has_device_metrics ? &node.device_metrics : nullptr); bool published = sys::EventBus::publish(event, 0); if (published) { mt_diag_log("[MT][RX_NODEINFO] from=%08lX node=%08lX mode=nodeinfo published=1\n", static_cast(header.from), - static_cast(node_id)); + static_cast(node.node_id)); LORA_LOG("[LORA] NodeInfo event published node=%08lX\n", - (unsigned long)node_id); + (unsigned long)node.node_id); } else { mt_diag_dropf(&header, "nodeinfo_event_drop", "node=%08lX pending=%u", - static_cast(node_id), + static_cast(node.node_id), static_cast(sys::EventBus::pendingCount())); LORA_LOG("[LORA] NodeInfo event dropped node=%08lX pending=%u\n", - (unsigned long)node_id, + (unsigned long)node.node_id, static_cast(sys::EventBus::pendingCount())); } if (node.has_position) { - publishPositionEvent(node_id, node.position); + publishPositionEvent(node.node_id, node.position); } nodeinfo_decoded = true; } else { - meshtastic_User user = meshtastic_User_init_default; - pb_istream_t ustream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); - if (pb_decode(&ustream, meshtastic_User_fields, &user)) - { - const uint32_t node_id = header.from; - const char* short_name = user.short_name[0] ? user.short_name : ""; - const char* long_name = user.long_name[0] ? user.long_name : ""; - uint8_t role = chat::contacts::kNodeRoleUnknown; - if (user.role <= meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) - { - role = static_cast(user.role); - } - LORA_LOG("[LORA] RX User from %08lX id='%s' short='%s' long='%s'\n", - (unsigned long)node_id, user.id, short_name, long_name); - if (long_name[0]) - { - node_long_names_[node_id] = long_name; - } - if (user.public_key.size == 32) - { - std::array key{}; - memcpy(key.data(), user.public_key.bytes, 32); - node_public_keys_[node_id] = key; - savePkiNodeKey(node_id, key.data(), key.size()); - std::string key_fp = toHex(key.data(), key.size(), 8); - LORA_LOG("[LORA] PKI key stored for %08lX fp=%s\n", - (unsigned long)node_id, key_fp.c_str()); - LORA_LOG("[LORA] PKI key updated for %08lX\n", - (unsigned long)node_id); - } - - float snr = last_rx_snr_; - float rssi = last_rx_rssi_; - uint8_t hops_away = computeHopsAway(header.flags); - - uint32_t now_secs = time(nullptr); - sys::NodeInfoUpdateEvent* event = new sys::NodeInfoUpdateEvent( - node_id, short_name, long_name, snr, rssi, now_secs, - static_cast(chat::contacts::NodeProtocolType::Meshtastic), role, - hops_away, static_cast(user.hw_model), channel_index, - true, user.macaddr, - ((header.flags & chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0), - false, - user.public_key.size == 32, - false, - false, nullptr); - bool published = sys::EventBus::publish(event, 0); - if (published) - { - mt_diag_log("[MT][RX_NODEINFO] from=%08lX node=%08lX mode=user published=1\n", - static_cast(header.from), - static_cast(node_id)); - LORA_LOG("[LORA] NodeInfo event published node=%08lX\n", - (unsigned long)node_id); - } - else - { - mt_diag_dropf(&header, - "user_event_drop", - "node=%08lX pending=%u", - static_cast(node_id), - static_cast(sys::EventBus::pendingCount())); - LORA_LOG("[LORA] NodeInfo event dropped node=%08lX pending=%u\n", - (unsigned long)node_id, - static_cast(sys::EventBus::pendingCount())); - } - nodeinfo_decoded = true; - } - else - { - LORA_LOG("[LORA] RX NodeInfo decode fail from=%08lX err=%s\n", - (unsigned long)header.from, - PB_GET_ERROR(&nstream)); - LORA_LOG("[LORA] RX User decode fail from=%08lX err=%s\n", - (unsigned long)header.from, - PB_GET_ERROR(&ustream)); - } + mt_diag_dropf(&header, "nodeinfo_decode_fail"); + LORA_LOG("[LORA] RX NodeInfo decode fail from=%08lX\n", + (unsigned long)header.from); } } @@ -1996,23 +1907,24 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) if (decoded.portnum == meshtastic_PortNum_POSITION_APP && decoded.payload.size > 0) { - meshtastic_Position pos = meshtastic_Position_init_default; - pb_istream_t pstream = - pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); - if (pb_decode(&pstream, meshtastic_Position_fields, &pos)) + chat::meshtastic::DecodedPositionPayload position{}; + if (chat::meshtastic::decodePositionPayload( + decoded, + header.from, + packet_timestamp, + &position)) { mt_diag_log("[MT][RX_POSITION] from=%08lX id=%08lX payload=%u\n", static_cast(header.from), static_cast(header.id), static_cast(decoded.payload.size)); - publishPositionEvent(header.from, pos); + publishPositionEvent(position.node_id, position.position); } else { mt_diag_dropf(&header, "position_decode_fail"); - LORA_LOG("[LORA] RX Position decode fail from=%08lX err=%s\n", - (unsigned long)header.from, - PB_GET_ERROR(&pstream)); + LORA_LOG("[LORA] RX Position decode fail from=%08lX\n", + (unsigned long)header.from); } } @@ -2544,9 +2456,7 @@ bool MtAdapter::sendPacket(const PendingSend& pending) const char* channel_name = kSecondaryChannelName; if (channel != ChannelId::SECONDARY) { - auto preset = - static_cast(config_.modem_preset); - channel_name = config_.use_preset ? chat::meshtastic::presetDisplayName(preset) : "Custom"; + channel_name = chat::meshtastic::primaryChannelName(config_); } LORA_LOG("[LORA] TX channel name='%s' hash=0x%02X psk=%u pki=%u dest=%08lX\n", channel_name, @@ -2897,129 +2807,30 @@ void MtAdapter::configureRadio() return; } - meshtastic_Config_LoRaConfig_RegionCode region_code = - static_cast(config_.region); - if (region_code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + const chat::meshtastic::RadioConfig radio = + chat::meshtastic::deriveRadioConfig(config_); + if (radio.using_preset != config_.use_preset || + config_.modem_preset != static_cast(radio.modem_preset)) { - region_code = meshtastic_Config_LoRaConfig_RegionCode_CN; + config_.use_preset = radio.using_preset; + config_.modem_preset = static_cast(radio.modem_preset); } - const chat::meshtastic::RegionInfo* region = chat::meshtastic::findRegion(region_code); + config_.tx_power = radio.tx_power_dbm; - meshtastic_Config_LoRaConfig_ModemPreset preset = - static_cast(config_.modem_preset); - - float bw_khz = 250.0f; - uint8_t sf = 11; - uint8_t cr_denom = 5; - bool using_preset = config_.use_preset; - if (using_preset) - { - modemPresetToParams(preset, region->wide_lora, bw_khz, sf, cr_denom); - } - else - { - bw_khz = config_.bandwidth_khz; - sf = config_.spread_factor; - cr_denom = config_.coding_rate; - - if (bw_khz == 31.0f) bw_khz = 31.25f; - if (bw_khz == 62.0f) bw_khz = 62.5f; - if (bw_khz == 200.0f) bw_khz = 203.125f; - if (bw_khz == 400.0f) bw_khz = 406.25f; - if (bw_khz == 800.0f) bw_khz = 812.5f; - if (bw_khz == 1600.0f) bw_khz = 1625.0f; - - if (bw_khz < 7.0f) bw_khz = 7.8f; - if (!region->wide_lora && bw_khz > 500.0f) bw_khz = 500.0f; - if (region->wide_lora && bw_khz > 1625.0f) bw_khz = 1625.0f; - if (sf < 5) sf = 5; - if (sf > 12) sf = 12; - if (cr_denom < 5) cr_denom = 5; - if (cr_denom > 8) cr_denom = 8; - } - - const float region_span_khz = (region->freq_end_mhz - region->freq_start_mhz) * 1000.0f; - if (region_span_khz < bw_khz) - { - using_preset = true; - preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - modemPresetToParams(preset, region->wide_lora, bw_khz, sf, cr_denom); - config_.use_preset = true; - config_.modem_preset = static_cast(preset); - } - - const char* channel_name = - using_preset ? chat::meshtastic::presetDisplayName(preset) : "Custom"; - - float span_mhz = region->freq_end_mhz - region->freq_start_mhz; - float spacing_mhz = region->spacing_khz / 1000.0f; - float bw_mhz = bw_khz / 1000.0f; - uint32_t num_channels = static_cast(floor(span_mhz / (spacing_mhz + bw_mhz))); - if (num_channels < 1) - { - num_channels = 1; - } - - uint32_t channel_slot = 0; - if (config_.channel_num > 0) - { - channel_slot = static_cast((config_.channel_num - 1) % num_channels); - } - else - { - channel_slot = djb2HashText(channel_name) % num_channels; - } - - float freq_mhz = region->freq_start_mhz + (bw_khz / 2000.0f) + (channel_slot * bw_mhz); - if (config_.override_frequency_mhz > 0.0f) - { - freq_mhz = config_.override_frequency_mhz; - } - freq_mhz += config_.frequency_offset_mhz; - - if (config_.override_frequency_mhz <= 0.0f) - { - float min_center = region->freq_start_mhz + (bw_khz / 2000.0f); - float max_center = region->freq_end_mhz - (bw_khz / 2000.0f); - if (min_center > max_center) - { - min_center = region->freq_start_mhz; - max_center = region->freq_end_mhz; - } - if (freq_mhz < min_center) freq_mhz = min_center; - if (freq_mhz > max_center) freq_mhz = max_center; - } - - int8_t tx_power = config_.tx_power; - if (region->power_limit_dbm > 0) - { - if (tx_power == 0) - { - tx_power = static_cast(region->power_limit_dbm); - } - if (tx_power > static_cast(region->power_limit_dbm)) - { - tx_power = static_cast(region->power_limit_dbm); - } - } - if (tx_power == 0) - { - tx_power = 17; - } - if (tx_power < -9) - { - tx_power = -9; - } - config_.tx_power = tx_power; - - radio_freq_hz_ = static_cast(std::lround(freq_mhz * 1000000.0f)); - radio_bw_hz_ = static_cast(std::lround(bw_khz * 1000.0f)); - radio_sf_ = sf; - radio_cr_ = cr_denom; + radio_freq_hz_ = static_cast(std::lround(radio.freq_mhz * 1000000.0f)); + radio_bw_hz_ = static_cast(std::lround(radio.bw_khz * 1000.0f)); + radio_sf_ = radio.sf; + radio_cr_ = radio.cr_denom; #if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) - board_.configureLoraRadio(freq_mhz, bw_khz, sf, cr_denom, tx_power, - kLoraPreambleLen, kLoraSyncWord, 2); + board_.configureLoraRadio(radio.freq_mhz, + radio.bw_khz, + radio.sf, + radio.cr_denom, + radio.tx_power_dbm, + radio.preamble_len, + radio.sync_word, + radio.crc_len); #endif ready_ = true; @@ -3027,17 +2838,17 @@ void MtAdapter::configureRadio() last_nodeinfo_ms_ = millis(); LORA_LOG("[LORA] adapter ready, node_id=%08lX\n", (unsigned long)node_id_); LORA_LOG("[LORA] radio config region=%u preset=%u use_preset=%u freq=%.3fMHz sf=%u bw=%.1f cr=4/%u tx=%d ch=%lu sync=0x%02X preamble=%u tx_en=%u\n", - static_cast(region_code), - static_cast(preset), - using_preset ? 1U : 0U, - freq_mhz, - sf, - bw_khz, - cr_denom, - static_cast(tx_power), - static_cast(channel_slot), - kLoraSyncWord, - kLoraPreambleLen, + static_cast(radio.region_code), + static_cast(radio.modem_preset), + radio.using_preset ? 1U : 0U, + radio.freq_mhz, + radio.sf, + radio.bw_khz, + radio.cr_denom, + static_cast(radio.tx_power_dbm), + static_cast(radio.channel_slot), + radio.sync_word, + radio.preamble_len, config_.tx_enabled ? 1U : 0U); startRadioReceive(); } @@ -3100,13 +2911,7 @@ void MtAdapter::updateChannelKeys() secondary_psk_len_ = sizeof(secondary_psk_); } - auto preset = - static_cast(config_.modem_preset); - const char* primary_name = config_.use_preset ? chat::meshtastic::presetDisplayName(preset) : "Custom"; - if (!primary_name || primary_name[0] == '\0') - { - primary_name = "Custom"; - } + const char* primary_name = chat::meshtastic::primaryChannelName(config_); primary_channel_hash_ = computeChannelHash(primary_name, primary_psk_, primary_psk_len_); secondary_channel_hash_ = computeChannelHash(kSecondaryChannelName, diff --git a/platform/esp/arduino_common/src/gps/GPS.cpp b/platform/esp/arduino_common/src/gps/GPS.cpp index a3dac6c8..140f4c55 100644 --- a/platform/esp/arduino_common/src/gps/GPS.cpp +++ b/platform/esp/arduino_common/src/gps/GPS.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -34,6 +35,24 @@ void GPS::attach(Stream* stream) { _stream = stream; assert(_stream); + debug_nmea_sentence_count_ = 0; + debug_raw_sample_len_ = 0; + debug_raw_first_ms_ = 0; + debug_raw_sample_logged_ = false; + debug_raw_saw_nmea_ = false; + debug_raw_burst_sample_len_ = 0; + debug_raw_burst_count_ = 0; + debug_raw_burst_first_ms_ = 0; + debug_raw_burst_last_ms_ = 0; + debug_raw_burst_idle_gap_ms_ = 0; + debug_raw_burst_bytes_ = 0; + debug_raw_burst_printable_ = 0; + debug_raw_burst_comma_ = 0; + debug_raw_burst_high_ = 0; + debug_raw_burst_zero_ = 0; + debug_raw_burst_ubx_sync_ = 0; + debug_raw_burst_logged_ = false; + last_loop_read_bytes_ = 0; } bool GPS::init(Stream* stream) @@ -388,27 +407,32 @@ bool GPS::configureNmeaOutput(uint8_t output_hz, uint8_t sentence_mask) bool enable_gga = true; bool enable_rmc = true; + bool enable_gsa = true; bool enable_gsv = true; switch (sentence_mask) { - case 0: // GGA + RMC + GSV + case 0: // GGA + RMC + GSA + GSV enable_gga = true; enable_rmc = true; + enable_gsa = true; enable_gsv = true; break; - case 1: // RMC + GSV + case 1: // RMC + GSA + GSV enable_gga = false; enable_rmc = true; + enable_gsa = true; enable_gsv = true; break; case 2: // GGA + RMC enable_gga = true; enable_rmc = true; + enable_gsa = false; enable_gsv = false; break; default: enable_gga = true; enable_rmc = true; + enable_gsa = true; enable_gsv = true; break; } @@ -417,6 +441,7 @@ bool GPS::configureNmeaOutput(uint8_t output_hz, uint8_t sentence_mask) { enable_gga = false; enable_rmc = false; + enable_gsa = false; enable_gsv = false; } @@ -437,9 +462,17 @@ bool GPS::configureNmeaOutput(uint8_t output_hz, uint8_t sentence_mask) bool ok = true; ok = ok && set_msg_rate(0x00, enable_gga ? output_hz : 0); // GGA + ok = ok && set_msg_rate(0x02, enable_gsa ? output_hz : 0); // GSA ok = ok && set_msg_rate(0x04, enable_rmc ? output_hz : 0); // RMC ok = ok && set_msg_rate(0x03, enable_gsv ? output_hz : 0); // GSV - GPS_LOG("[GPS] CFG-MSG nmea_rate=%u mask=%u ok=%d\n", output_hz, sentence_mask, ok ? 1 : 0); + Serial.printf("[GPS] nmea config rate=%u mask=%u gga=%u gsa=%u rmc=%u gsv=%u ok=%d\n", + static_cast(output_hz), + static_cast(sentence_mask), + enable_gga ? 1U : 0U, + enable_gsa ? 1U : 0U, + enable_rmc ? 1U : 0U, + enable_gsv ? 1U : 0U, + ok ? 1 : 0); return ok; } @@ -479,6 +512,210 @@ gps::GnssStatus GPS::getGnssStatus() const return st; } +void GPS::observeDebugByte(uint8_t b) +{ + if (b == '$') + { + debug_raw_saw_nmea_ = true; + debug_raw_sample_len_ = 0; + if (debug_raw_burst_sample_len_ > 0 && !debug_raw_burst_logged_) + { + logDebugRawBurst("interrupted_by_nmea", millis()); + } + return; + } + if (debug_raw_sample_logged_) + { + observeDebugRawBurstByte(b, millis()); + } + if (debug_raw_saw_nmea_ || debug_raw_sample_logged_) + { + return; + } + if (debug_raw_sample_len_ < sizeof(debug_raw_sample_)) + { + if (debug_raw_sample_len_ == 0) + { + debug_raw_first_ms_ = millis(); + } + debug_raw_sample_[debug_raw_sample_len_++] = b; + } + if (debug_raw_sample_len_ == sizeof(debug_raw_sample_)) + { + logDebugRawSample("first64_no_nmea"); + } +} + +void GPS::flushDebugRawSampleIfStale(uint32_t now_ms) +{ + if (debug_raw_saw_nmea_ || debug_raw_sample_logged_ || debug_raw_sample_len_ == 0 || debug_raw_first_ms_ == 0) + { + if (debug_raw_burst_sample_len_ > 0 && !debug_raw_burst_logged_ && + debug_raw_burst_last_ms_ != 0 && (now_ms - debug_raw_burst_last_ms_) >= 1000UL) + { + logDebugRawBurst("partial_idle_1s", now_ms); + } + return; + } + if ((now_ms - debug_raw_first_ms_) >= 2000UL) + { + logDebugRawSample("partial_no_nmea_2s"); + } +} + +void GPS::logDebugRawSample(const char* reason) +{ + if (debug_raw_sample_logged_ || debug_raw_sample_len_ == 0) + { + return; + } + char hex[sizeof(debug_raw_sample_) * 3 + 1]{}; + size_t pos = 0; + for (size_t i = 0; i < debug_raw_sample_len_ && pos + 3 < sizeof(hex); ++i) + { + pos += snprintf(hex + pos, sizeof(hex) - pos, "%02X%s", + static_cast(debug_raw_sample_[i]), + (i + 1 == debug_raw_sample_len_) ? "" : " "); + } + Serial.printf("[GPS][RAW] reason=%s len=%u hex=%s\n", + reason ? reason : "no_nmea", + static_cast(debug_raw_sample_len_), + hex); + debug_raw_sample_logged_ = true; +} + +void GPS::observeDebugRawBurstByte(uint8_t b, uint32_t now_ms) +{ + constexpr uint32_t kBurstIdleGapMs = 2000UL; + constexpr uint16_t kMaxLoggedBursts = 8; + + if (debug_raw_saw_nmea_ || debug_raw_burst_count_ >= kMaxLoggedBursts) + { + return; + } + + uint32_t idle_gap_ms = 0; + if (debug_raw_burst_last_ms_ != 0 && now_ms >= debug_raw_burst_last_ms_) + { + idle_gap_ms = now_ms - debug_raw_burst_last_ms_; + } + + if (debug_raw_burst_last_ms_ != 0 && idle_gap_ms >= kBurstIdleGapMs) + { + if (debug_raw_burst_sample_len_ > 0 && !debug_raw_burst_logged_) + { + logDebugRawBurst("partial_before_gap", now_ms); + } + debug_raw_burst_sample_len_ = 0; + debug_raw_burst_first_ms_ = 0; + debug_raw_burst_idle_gap_ms_ = idle_gap_ms; + debug_raw_burst_bytes_ = 0; + debug_raw_burst_printable_ = 0; + debug_raw_burst_comma_ = 0; + debug_raw_burst_high_ = 0; + debug_raw_burst_zero_ = 0; + debug_raw_burst_ubx_sync_ = 0; + debug_raw_burst_logged_ = false; + } + + if (debug_raw_burst_sample_len_ == 0) + { + debug_raw_burst_first_ms_ = now_ms; + debug_raw_burst_idle_gap_ms_ = idle_gap_ms; + } + + if (debug_raw_burst_sample_len_ < sizeof(debug_raw_burst_sample_)) + { + debug_raw_burst_sample_[debug_raw_burst_sample_len_++] = b; + } + debug_raw_burst_bytes_++; + if (b >= 32 && b <= 126) + { + debug_raw_burst_printable_++; + } + if (b == ',') + { + debug_raw_burst_comma_++; + } + if (b >= 128) + { + debug_raw_burst_high_++; + } + if (b == 0) + { + debug_raw_burst_zero_++; + } + if (b == 0xB5 || b == 0x62) + { + debug_raw_burst_ubx_sync_++; + } + debug_raw_burst_last_ms_ = now_ms; + + if (!debug_raw_burst_logged_ && debug_raw_burst_sample_len_ == sizeof(debug_raw_burst_sample_)) + { + logDebugRawBurst("non_nmea_burst64", now_ms); + } +} + +void GPS::logDebugRawBurst(const char* reason, uint32_t now_ms) +{ + if (debug_raw_burst_logged_ || debug_raw_burst_sample_len_ == 0) + { + return; + } + char hex[sizeof(debug_raw_burst_sample_) * 3 + 1]{}; + size_t pos = 0; + for (size_t i = 0; i < debug_raw_burst_sample_len_ && pos + 3 < sizeof(hex); ++i) + { + pos += snprintf(hex + pos, sizeof(hex) - pos, "%02X%s", + static_cast(debug_raw_burst_sample_[i]), + (i + 1 == debug_raw_burst_sample_len_) ? "" : " "); + } + const uint32_t age_ms = (debug_raw_burst_first_ms_ > 0 && now_ms >= debug_raw_burst_first_ms_) + ? (now_ms - debug_raw_burst_first_ms_) + : 0; + Serial.printf("[GPS][RAW_BURST] n=%u reason=%s len=%u bytes=%lu gap_ms=%lu age_ms=%lu printable=%u comma=%u high=%u zero=%u ubx_sync=%u hex=%s\n", + static_cast(debug_raw_burst_count_ + 1), + reason ? reason : "non_nmea", + static_cast(debug_raw_burst_sample_len_), + static_cast(debug_raw_burst_bytes_), + static_cast(debug_raw_burst_idle_gap_ms_), + static_cast(age_ms), + static_cast(debug_raw_burst_printable_), + static_cast(debug_raw_burst_comma_), + static_cast(debug_raw_burst_high_), + static_cast(debug_raw_burst_zero_), + static_cast(debug_raw_burst_ubx_sync_), + hex); + debug_raw_burst_count_++; + debug_raw_burst_logged_ = true; +} + +void GPS::logDebugNmeaSentence(const char* sentence) +{ + if (!sentence || debug_nmea_sentence_count_ >= 8) + { + return; + } + + char preview[128]{}; + size_t out = 0; + for (size_t i = 0; sentence[i] != '\0' && out < sizeof(preview) - 1; ++i) + { + char c = sentence[i]; + if (c == '\r' || c == '\n') + { + break; + } + preview[out++] = (c >= 32 && c <= 126) ? c : '.'; + } + preview[out] = '\0'; + Serial.printf("[GPS][NMEA] sample%u=%s\n", + static_cast(debug_nmea_sentence_count_ + 1), + preview); + debug_nmea_sentence_count_++; +} + void GPS::handleNmeaChar(char c) { if (c == '$') @@ -513,6 +750,7 @@ void GPS::parseNmeaSentence(char* sentence) { return; } + logDebugNmeaSentence(sentence); char* start = sentence + 1; char* checksum = strchr(start, '*'); diff --git a/platform/esp/arduino_common/src/gps/gps_service.cpp b/platform/esp/arduino_common/src/gps/gps_service.cpp index 70c91b40..a8a24bbb 100644 --- a/platform/esp/arduino_common/src/gps/gps_service.cpp +++ b/platform/esp/arduino_common/src/gps/gps_service.cpp @@ -5,17 +5,26 @@ #include "gps/usecase/gps_runtime_policy.h" #include "platform/esp/arduino_common/gps/track_recorder.h" #include "platform/esp/arduino_common/power_tier.h" +#include #include -#ifndef GPS_TASK_LOG_ENABLE -#define GPS_TASK_LOG_ENABLE 0 -#endif - -#if GPS_TASK_LOG_ENABLE +// GPS task logs are release diagnostics while receiver bring-up is unstable. #define GPS_TASK_LOG(...) Serial.printf(__VA_ARGS__) -#else -#define GPS_TASK_LOG(...) -#endif + +namespace +{ + +constexpr uint32_t kGpsUartPollIntervalMs = 250; +constexpr uint32_t kGpsIdlePollIntervalMs = 1000; +constexpr uint32_t kGpsHealthLogIntervalMs = 10000; +constexpr uint32_t kGpsPostConfigWatchMs = 8000; + +const char* gps_valid_text(bool valid) +{ + return valid ? "fix" : "nofix"; +} + +} // namespace namespace gps { @@ -28,10 +37,13 @@ GpsService& GpsService::getInstance() void GpsService::begin(GpsBoard& gps_board, MotionBoard& motion_board, uint32_t disable_hw_init, uint32_t gps_interval_ms, - const MotionConfig& motion_config) + const MotionConfig& motion_config, + const GpsReceiverInitConfig& receiver_init_config) { gps_board_ = &gps_board; motion_board_ = &motion_board; + receiver_init_config_ = receiver_init_config; + gps_board_->setGPSReceiverInitConfig(receiver_init_config_); gps_adapter_.begin(gps_board); gps_disabled_ = (disable_hw_init & NO_HW_GPS) != 0; @@ -42,6 +54,7 @@ void GpsService::begin(GpsBoard& gps_board, MotionBoard& motion_board, return; } gps_ready_ = gps_adapter_.isReady(); + gps_powered_ = gps_ready_; if (!gps_ready_) { Serial.printf("[GPS] service starting reason=adapter_not_ready_waiting_retry\n"); @@ -54,6 +67,11 @@ void GpsService::begin(GpsBoard& gps_board, MotionBoard& motion_board, { log_e("Failed to create GPS data mutex"); } + gps_init_mutex_ = xSemaphoreCreateRecursiveMutex(); + if (gps_init_mutex_ == NULL) + { + log_e("Failed to create GPS init mutex"); + } runtime_state_.setCollectionInterval(gps_interval_ms); motion_config_ = normalizeMotionConfig(motion_config); @@ -162,6 +180,64 @@ bool GpsService::getGnssSnapshot(GnssSatInfo* out, size_t max, size_t* out_count return false; } +GpsDiagnosticsSnapshot GpsService::getDiagnostics() +{ + GpsDiagnosticsSnapshot snapshot{}; + snapshot.supported = !gps_disabled_; + snapshot.enabled = isEnabled(); + snapshot.powered = gps_powered_; + snapshot.ready = gps_ready_; + snapshot.chars_total = gps_chars_total_; + snapshot.chars_recent = gps_chars_recent_; + snapshot.poll_interval_ms = kGpsUartPollIntervalMs; + snapshot.collection_interval_ms = getCollectionInterval(); + snapshot.last_rx_age_ms = gps_last_rx_ms_ > 0 ? (millis() - gps_last_rx_ms_) : 0xFFFFFFFFUL; + + if (gps_data_mutex_ != NULL && xSemaphoreTake(gps_data_mutex_, pdMS_TO_TICKS(50)) == pdTRUE) + { + snapshot.has_fix = gps_state_.valid; + snapshot.satellites = gps_state_.satellites; + snapshot.sats_in_view = gnss_status_.sats_in_view; + snapshot.sats_in_use = gnss_status_.sats_in_use; + xSemaphoreGive(gps_data_mutex_); + } + + if (!snapshot.supported) + { + snapshot.code = GpsDiagnosticCode::Disabled; + } + else if (!snapshot.enabled) + { + snapshot.code = GpsDiagnosticCode::NotEnabled; + } + else if (!snapshot.powered) + { + snapshot.code = GpsDiagnosticCode::PowerOff; + } + else if (!snapshot.ready) + { + snapshot.code = GpsDiagnosticCode::TransportNotReady; + } + else if (snapshot.chars_total == 0) + { + snapshot.code = GpsDiagnosticCode::NoTraffic; + } + else if (snapshot.last_rx_age_ms != 0xFFFFFFFFUL && snapshot.last_rx_age_ms > 15000UL) + { + snapshot.code = GpsDiagnosticCode::TrafficStalled; + } + else if (!snapshot.has_fix) + { + snapshot.code = GpsDiagnosticCode::NoFix; + } + else + { + snapshot.code = GpsDiagnosticCode::OK; + } + + return snapshot; +} + uint32_t GpsService::getCollectionInterval() const { return runtime_state_.collectionIntervalMs(getPowerTier()); @@ -266,6 +342,20 @@ void GpsService::setExternalNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) runtime_config_.markExternalNmeaConfigApplied(); } +void GpsService::setReceiverInitConfig(const GpsReceiverInitConfig& config) +{ + if (!takeGpsUartLock()) + { + return; + } + receiver_init_config_ = config; + if (gps_board_ != nullptr) + { + gps_board_->setGPSReceiverInitConfig(receiver_init_config_); + } + giveGpsUartLock(); +} + void GpsService::setMotionConfig(const MotionConfig& config) { if (!isEnabled() || gps_board_ == nullptr) @@ -310,12 +400,68 @@ void GpsService::applyGnssConfig() { return; } - const GnssRuntimeConfig& gnss_config = runtime_config_.gnssConfig(); - if (!gps_adapter_.applyGnssConfig(gnss_config.mode, gnss_config.sat_mask)) + if (!takeGpsUartLock()) { return; } + const GnssRuntimeConfig& gnss_config = runtime_config_.gnssConfig(); + bool send_rxm = receiver_init_config_.rxm_policy != 1; + bool send_gnss = receiver_init_config_.gnss_policy != 1; +#if defined(ARDUINO_T_DECK_PRO) + const bool tdeck_legacy_or_unknown = receiver_init_config_.profile == 0 || + receiver_init_config_.profile == 1 || + receiver_init_config_.profile == 2; + if (tdeck_legacy_or_unknown && receiver_init_config_.rxm_policy == 0) + { + send_rxm = false; + } + if (tdeck_legacy_or_unknown && receiver_init_config_.gnss_policy == 0) + { + send_gnss = false; + } +#elif defined(ARDUINO_T_DECK) + if ((send_rxm || send_gnss) && !canSendReceiverUbxConfig("applyGnssConfig")) + { + send_rxm = false; + send_gnss = false; + } +#endif + if (!send_rxm && !send_gnss) + { + Serial.printf("[GPS] applyGnssConfig skipped rxm_policy=%u gnss_policy=%u profile=%u mode=%u sat_mask=0x%02X\n", + static_cast(receiver_init_config_.rxm_policy), + static_cast(receiver_init_config_.gnss_policy), + static_cast(receiver_init_config_.profile), + static_cast(gnss_config.mode), + static_cast(gnss_config.sat_mask)); + runtime_config_.markGnssConfigApplied(); + giveGpsUartLock(); + return; + } + const uint32_t chars_before = gps_chars_total_; + const uint32_t started_ms = millis(); + const bool ok = gps_adapter_.applyGnssConfig(gnss_config.mode, gnss_config.sat_mask, send_rxm, send_gnss); + const uint32_t elapsed_ms = millis() - started_ms; + Serial.printf("[GPS] applyGnssConfig mode=%u sat_mask=0x%02X ok=%d powered=%d ready=%d chars_before=%lu chars_after=%lu elapsed_ms=%lu rxm_send=%d gnss_send=%d profile=%u\n", + static_cast(gnss_config.mode), + static_cast(gnss_config.sat_mask), + ok ? 1 : 0, + gps_powered_ ? 1 : 0, + gps_ready_ ? 1 : 0, + static_cast(chars_before), + static_cast(gps_chars_total_), + static_cast(elapsed_ms), + send_rxm ? 1 : 0, + send_gnss ? 1 : 0, + static_cast(receiver_init_config_.profile)); + startPostConfigStreamWatch("applyGnssConfig", gps_chars_total_); + if (!ok) + { + giveGpsUartLock(); + return; + } runtime_config_.markGnssConfigApplied(); + giveGpsUartLock(); } void GpsService::applyInternalNmeaConfig() @@ -324,10 +470,114 @@ void GpsService::applyInternalNmeaConfig() { return; } - if (!gps_adapter_.applyNmeaConfig(1, 0)) + if (!takeGpsUartLock()) { return; } + if (receiver_init_config_.nmea_policy == 1) + { + Serial.printf("[GPS] applyInternalNmeaConfig skipped policy=skip\n"); + giveGpsUartLock(); + return; + } +#if defined(ARDUINO_T_DECK_PRO) + if (receiver_init_config_.nmea_policy == 0 && + (receiver_init_config_.profile == 0 || receiver_init_config_.profile == 1 || receiver_init_config_.profile == 2)) + { + Serial.printf("[GPS] applyInternalNmeaConfig skipped policy=auto_legacy profile=%u\n", + static_cast(receiver_init_config_.profile)); + giveGpsUartLock(); + return; + } +#elif defined(ARDUINO_T_DECK) + if (!canSendReceiverUbxConfig("applyInternalNmeaConfig")) + { + giveGpsUartLock(); + return; + } +#endif + const uint32_t chars_before = gps_chars_total_; + const uint32_t started_ms = millis(); + const bool ok = gps_adapter_.applyNmeaConfig(1, 0); + const uint32_t elapsed_ms = millis() - started_ms; + Serial.printf("[GPS] applyInternalNmeaConfig rate=1 sentence_mask=0 ok=%d powered=%d ready=%d chars_before=%lu chars_after=%lu elapsed_ms=%lu ubx=cfg_msg\n", + ok ? 1 : 0, + gps_powered_ ? 1 : 0, + gps_ready_ ? 1 : 0, + static_cast(chars_before), + static_cast(gps_chars_total_), + static_cast(elapsed_ms)); + startPostConfigStreamWatch("applyInternalNmeaConfig", gps_chars_total_); + if (!ok) + { + giveGpsUartLock(); + return; + } + giveGpsUartLock(); +} + +bool GpsService::takeGpsUartLock(TickType_t timeout) +{ + return gps_init_mutex_ == nullptr || xSemaphoreTakeRecursive(gps_init_mutex_, timeout) == pdTRUE; +} + +void GpsService::giveGpsUartLock() +{ + if (gps_init_mutex_ != nullptr) + { + xSemaphoreGiveRecursive(gps_init_mutex_); + } +} + +bool GpsService::canSendReceiverUbxConfig(const char* source) const +{ +#if defined(ARDUINO_T_DECK) + if (gps_board_ == nullptr) + { + return false; + } + + const GpsReceiverProtocol protocol = gps_board_->getGPSReceiverProtocol(); + const bool explicit_ubx_profile = receiver_init_config_.profile == 2 || + receiver_init_config_.profile == 3; + if (protocol == GpsReceiverProtocol::Ubx) + { + return true; + } + if (explicit_ubx_profile) + { + Serial.printf("[GPS] %s ubx_config allowed explicit_profile=%u detected_protocol=%s\n", + source ? source : "gps_config", + static_cast(receiver_init_config_.profile), + gpsReceiverProtocolName(protocol)); + return true; + } + + Serial.printf("[GPS] %s skipped ubx_config profile=%u detected_protocol=%s chars_total=%lu\n", + source ? source : "gps_config", + static_cast(receiver_init_config_.profile), + gpsReceiverProtocolName(protocol), + static_cast(gps_chars_total_)); + return false; +#else + (void)source; + return true; +#endif +} + +void GpsService::startPostConfigStreamWatch(const char* label, uint32_t chars_total) +{ + gps_post_config_watch_active_ = true; + gps_post_config_start_ms_ = millis(); + gps_post_config_chars_ = chars_total; + std::snprintf(gps_post_config_label_, + sizeof(gps_post_config_label_), + "%s", + label ? label : "gps_config"); + Serial.printf("[GPS] post_config watch source=%s chars=%lu window_ms=%lu\n", + gps_post_config_label_, + static_cast(gps_post_config_chars_), + static_cast(kGpsPostConfigWatchMs)); } void GpsService::setMotionIdleTimeout(uint32_t timeout_ms) @@ -357,10 +607,20 @@ void GpsService::gpsTask(void* pvParameters) uint32_t loop_count = 0; uint32_t task_start_ms = millis(); uint32_t last_log_ms = 0; + uint32_t last_health_log_ms = 0; + uint32_t last_health_total_chars = 0; + uint32_t last_health_total_uart_reads = 0; + uint32_t last_total_chars = 0; + uint32_t total_uart_reads = 0; GPS_TASK_LOG("[GPS Task] ===== TASK STARTED =====\n"); GPS_TASK_LOG("[GPS Task] Started at %lu ms, GPS ready: %d\n", task_start_ms, service->gps_adapter_.isReady()); GPS_TASK_LOG("[GPS Task] Collection interval: %lu ms\n", service->getCollectionInterval()); + Serial.printf("[GPS] task started ready=%d powered=%d poll_ms=%lu collection_ms=%lu\n", + service->gps_adapter_.isReady() ? 1 : 0, + service->gps_powered_ ? 1 : 0, + static_cast(kGpsUartPollIntervalMs), + static_cast(service->getCollectionInterval())); while (true) { @@ -368,6 +628,13 @@ void GpsService::gpsTask(void* pvParameters) uint32_t now_ms = millis(); bool gps_ready = service->gps_adapter_.isReady(); service->gps_ready_ = gps_ready; + uint32_t total_chars = last_total_chars; + uint32_t chars_this_loop = 0; + uint32_t uart_read_bytes = 0; + bool health_valid = false; + uint8_t health_sat_count = 0; + uint8_t health_sats_in_view = 0; + uint8_t health_sats_in_use = 0; if (service->motion_adapter_.isReady() && service->motion_policy_.isEnabled()) { @@ -384,8 +651,11 @@ void GpsService::gpsTask(void* pvParameters) if (should_log) { - GPS_TASK_LOG("[GPS Task] Loop %lu: GPS ready=%d, valid=%d, mutex=%p\n", - loop_count, gps_ready, service->gps_state_.valid, service->gps_data_mutex_); + GPS_TASK_LOG("[GPS Task] Loop %lu: GPS ready=%d, valid=%d, mutex_ok=%d\n", + loop_count, + gps_ready, + service->gps_state_.valid, + service->gps_data_mutex_ != NULL ? 1 : 0); last_log_ms = now_ms; } @@ -398,17 +668,48 @@ void GpsService::gpsTask(void* pvParameters) loop_count); } } + else if (service->gps_initializing_) + { + if (should_log) + { + GPS_TASK_LOG("[GPS Task] GPS initialization in progress, waiting (loop %lu)\n", loop_count); + } + } else if (gps_ready) { - static uint32_t last_total_chars = 0; - uint32_t total_chars = service->gps_adapter_.loop(); - uint32_t chars_this_loop = (total_chars > last_total_chars) ? (total_chars - last_total_chars) : 0; - last_total_chars = total_chars; - - if (should_log && chars_this_loop > 0) + if (service->takeGpsUartLock(pdMS_TO_TICKS(20))) { - GPS_TASK_LOG("[GPS Task] GPS loop processed %lu characters this cycle (total: %lu)\n", - chars_this_loop, total_chars); + total_chars = service->gps_adapter_.loop(); + uart_read_bytes = service->gps_adapter_.lastLoopReadBytes(); + total_uart_reads += uart_read_bytes; + chars_this_loop = (total_chars > last_total_chars) ? (total_chars - last_total_chars) : 0; + last_total_chars = total_chars; + service->gps_chars_total_ = total_chars; + if (uart_read_bytes > 0) + { + service->gps_last_rx_ms_ = now_ms; + } + service->giveGpsUartLock(); + } + else + { + total_chars = last_total_chars; + } + + if (chars_this_loop > 0 || uart_read_bytes > 0) + { + GPS_TASK_LOG("[GPS Task] GPS loop processed %lu characters this cycle (total: %lu, read_bytes=%lu)\n", + chars_this_loop, + total_chars, + uart_read_bytes); + } + if (uart_read_bytes != chars_this_loop && (uart_read_bytes > 0 || chars_this_loop > 0)) + { + Serial.printf("[GPS][COUNT_CHECK] loop=%lu read_bytes=%lu tinygps_delta=%lu total=%lu\n", + static_cast(loop_count), + static_cast(uart_read_bytes), + static_cast(chars_this_loop), + static_cast(total_chars)); } if (service->gps_data_mutex_ != NULL && xSemaphoreTake(service->gps_data_mutex_, portMAX_DELAY) == pdTRUE) @@ -428,8 +729,7 @@ void GpsService::gpsTask(void* pvParameters) if (!service->gps_time_synced_) { - uint32_t gps_interval = service->getCollectionInterval(); - if (service->gps_adapter_.syncTime(gps_interval)) + if (service->gps_adapter_.syncTime(kGpsUartPollIntervalMs)) { service->gps_time_synced_ = true; GPS_TASK_LOG("[GPS Task] *** TIME SYNCED TO RTC (automatic) *** (loop %lu, sat=%d)\n", @@ -501,6 +801,7 @@ void GpsService::gpsTask(void* pvParameters) service->gps_state_.alt_m = 0.0; service->gps_state_.speed_mps = 0.0; service->gps_state_.course_deg = 0.0; + service->gps_state_.satellites = sat_count; if (was_valid) { GPS_TASK_LOG("[GPS Task] *** FIX LOST *** (loop %lu)\n", loop_count); @@ -511,6 +812,10 @@ void GpsService::gpsTask(void* pvParameters) loop_count, sat_count, chars_this_loop); } } + health_valid = service->gps_state_.valid; + health_sat_count = sat_count; + health_sats_in_view = service->gnss_status_.sats_in_view; + health_sats_in_use = service->gnss_status_.sats_in_use; xSemaphoreGive(service->gps_data_mutex_); // Append GPX track points outside the GPS mutex to keep the task responsive. @@ -524,32 +829,61 @@ void GpsService::gpsTask(void* pvParameters) GPS_TASK_LOG("[GPS Task] ERROR: Failed to take mutex (loop %lu)\n", loop_count); } } - else + else if (service->gps_powered_) { static uint32_t last_retry_ms = 0; const uint32_t RETRY_INTERVAL_MS = 300000; - if (should_log) - { - GPS_TASK_LOG("[GPS Task] GPS not ready (loop %lu)\n", loop_count); - } - if (last_retry_ms == 0 || (now_ms - last_retry_ms) >= RETRY_INTERVAL_MS) { + if (should_log) + { + GPS_TASK_LOG("[GPS Task] GPS not ready (loop %lu)\n", loop_count); + } + GPS_TASK_LOG("[GPS Task] Attempting to reinitialize GPS (last retry: %lu ms ago, loop %lu)\n", last_retry_ms > 0 ? (now_ms - last_retry_ms) : 0, loop_count); - bool retry_result = service->gps_adapter_.init(); + bool retry_result = false; + bool retry_attempted = false; + + service->takeGpsUartLock(); + + const bool ready_after_lock = service->gps_adapter_.isReady(); + service->gps_ready_ = ready_after_lock; + + if (service->gps_powered_ && !service->gps_initializing_ && !ready_after_lock) + { + retry_attempted = true; + service->gps_initializing_ = true; + service->gps_ready_ = false; + retry_result = service->gps_adapter_.init(); + service->gps_ready_ = service->gps_adapter_.isReady() || retry_result; + if (retry_result) + { + const GnssRuntimeConfig gnss_config = service->runtime_config_.gnssConfig(); + service->runtime_config_.setGnssConfig(gnss_config.mode, gnss_config.sat_mask); + service->applyGnssConfig(); + service->applyInternalNmeaConfig(); + } + service->gps_initializing_ = false; + } + + service->giveGpsUartLock(); last_retry_ms = now_ms; - if (retry_result) + if (!retry_attempted) + { + if (should_log) + { + GPS_TASK_LOG("[GPS Task] GPS reinitialization skipped; init state changed (loop %lu)\n", + loop_count); + } + } + else if (retry_result) { GPS_TASK_LOG("[GPS Task] *** GPS REINITIALIZATION SUCCESSFUL *** (loop %lu)\n", loop_count); service->gps_ready_ = true; - const GnssRuntimeConfig gnss_config = service->runtime_config_.gnssConfig(); - service->runtime_config_.setGnssConfig(gnss_config.mode, gnss_config.sat_mask); - service->applyGnssConfig(); - service->applyInternalNmeaConfig(); } else { @@ -560,7 +894,67 @@ void GpsService::gpsTask(void* pvParameters) } } - uint32_t interval_ms = service->getCollectionInterval(); + if (service->gps_post_config_watch_active_) + { + const uint32_t watch_elapsed_ms = millis() - service->gps_post_config_start_ms_; + if (total_chars > service->gps_post_config_chars_) + { + Serial.printf("[GPS] post_config stream_resumed source=%s chars_before=%lu chars_now=%lu delta=%lu elapsed_ms=%lu\n", + service->gps_post_config_label_, + static_cast(service->gps_post_config_chars_), + static_cast(total_chars), + static_cast(total_chars - service->gps_post_config_chars_), + static_cast(watch_elapsed_ms)); + service->gps_post_config_watch_active_ = false; + } + else if (watch_elapsed_ms >= kGpsPostConfigWatchMs) + { + const GpsDiagnosticCode code = + (service->gps_post_config_chars_ == 0 && total_chars == 0) + ? GpsDiagnosticCode::NoTraffic + : GpsDiagnosticCode::TrafficStalled; + Serial.printf("[GPS] post_config no_stream source=%s code=%s chars_before=%lu chars_now=%lu elapsed_ms=%lu powered=%d ready=%d\n", + service->gps_post_config_label_, + gpsDiagnosticCodeName(code), + static_cast(service->gps_post_config_chars_), + static_cast(total_chars), + static_cast(watch_elapsed_ms), + service->gps_powered_ ? 1 : 0, + gps_ready ? 1 : 0); + service->gps_post_config_watch_active_ = false; + } + } + + now_ms = millis(); + if (last_health_log_ms == 0 || (now_ms - last_health_log_ms) >= kGpsHealthLogIntervalMs) + { + const uint32_t chars_since_log = + (total_chars >= last_health_total_chars) ? (total_chars - last_health_total_chars) : 0; + const uint32_t reads_since_log = + (total_uart_reads >= last_health_total_uart_reads) ? (total_uart_reads - last_health_total_uart_reads) : 0; + Serial.printf("[GPS] health ready=%d powered=%d state=%s sats=%u view=%u use=%u chars_total=%lu chars_%lus=%lu read_%lus=%lu poll_ms=%lu collection_ms=%lu loops=%lu\n", + gps_ready ? 1 : 0, + service->gps_powered_ ? 1 : 0, + gps_valid_text(health_valid), + static_cast(health_sat_count), + static_cast(health_sats_in_view), + static_cast(health_sats_in_use), + static_cast(total_chars), + static_cast(kGpsHealthLogIntervalMs / 1000), + static_cast(chars_since_log), + static_cast(kGpsHealthLogIntervalMs / 1000), + static_cast(reads_since_log), + static_cast(kGpsUartPollIntervalMs), + static_cast(service->getCollectionInterval()), + static_cast(loop_count)); + last_health_total_chars = total_chars; + last_health_total_uart_reads = total_uart_reads; + service->gps_chars_recent_ = chars_since_log; + last_health_log_ms = now_ms; + } + + const uint32_t interval_ms = + (service->gps_powered_ && gps_ready) ? kGpsUartPollIntervalMs : kGpsIdlePollIntervalMs; TickType_t frequency = pdMS_TO_TICKS(interval_ms); if (should_log) @@ -605,10 +999,14 @@ void GpsService::setGPSPowerState(bool enable) { if (enable) { + takeGpsUartLock(); if (gps_powered_) { + giveGpsUartLock(); return; } + gps_initializing_ = true; + gps_ready_ = false; gps_adapter_.powerOn(); gps_powered_ = true; bool init_ok = gps_adapter_.init(); @@ -622,6 +1020,8 @@ void GpsService::setGPSPowerState(bool enable) applyGnssConfig(); applyInternalNmeaConfig(); } + gps_initializing_ = false; + giveGpsUartLock(); if (gps_task_handle_ != nullptr) { vTaskResume(gps_task_handle_); @@ -629,10 +1029,14 @@ void GpsService::setGPSPowerState(bool enable) } else { + takeGpsUartLock(); if (!gps_powered_) { + gps_initializing_ = false; + giveGpsUartLock(); return; } + gps_initializing_ = true; if (gps_task_handle_ != nullptr) { vTaskSuspend(gps_task_handle_); @@ -640,6 +1044,9 @@ void GpsService::setGPSPowerState(bool enable) gps_adapter_.powerOff(); gps_powered_ = false; gps_ready_ = false; + gps_post_config_watch_active_ = false; + gps_initializing_ = false; + giveGpsUartLock(); } } diff --git a/platform/esp/arduino_common/src/gps/gps_service_api.cpp b/platform/esp/arduino_common/src/gps/gps_service_api.cpp index 67936af1..b4373a06 100644 --- a/platform/esp/arduino_common/src/gps/gps_service_api.cpp +++ b/platform/esp/arduino_common/src/gps/gps_service_api.cpp @@ -15,6 +15,11 @@ bool gps_get_gnss_snapshot(gps::GnssSatInfo* out, size_t max, size_t* out_count, return GpsService::getInstance().getGnssSnapshot(out, max, out_count, status); } +GpsDiagnosticsSnapshot gps_get_diagnostics() +{ + return GpsService::getInstance().getDiagnostics(); +} + uint32_t gps_get_last_motion_ms() { return GpsService::getInstance().getLastMotionMs(); @@ -55,6 +60,11 @@ void gps_set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) GpsService::getInstance().setExternalNmeaConfig(output_hz, sentence_mask); } +void gps_set_receiver_init_config(const GpsReceiverInitConfig& config) +{ + GpsService::getInstance().setReceiverInitConfig(config); +} + void gps_set_motion_idle_timeout(uint32_t timeout_ms) { GpsService::getInstance().setMotionIdleTimeout(timeout_ms); diff --git a/platform/esp/arduino_common/src/gps/infra/hal_gps_adapter.cpp b/platform/esp/arduino_common/src/gps/infra/hal_gps_adapter.cpp index 21310f8f..9b22e8a7 100644 --- a/platform/esp/arduino_common/src/gps/infra/hal_gps_adapter.cpp +++ b/platform/esp/arduino_common/src/gps/infra/hal_gps_adapter.cpp @@ -34,6 +34,11 @@ uint32_t HalGpsAdapter::loop() return hal_gps_.loop(); } +uint32_t HalGpsAdapter::lastLoopReadBytes() const +{ + return hal_gps_.lastLoopReadBytes(); +} + bool HalGpsAdapter::hasFix() const { return hal_gps_.hasFix(); @@ -99,9 +104,9 @@ bool HalGpsAdapter::syncTime(uint32_t gps_task_interval_ms) return hal_gps_.syncTime(gps_task_interval_ms); } -bool HalGpsAdapter::applyGnssConfig(uint8_t mode, uint8_t sat_mask) +bool HalGpsAdapter::applyGnssConfig(uint8_t mode, uint8_t sat_mask, bool send_rxm, bool send_gnss) { - return hal_gps_.applyGnssConfig(mode, sat_mask); + return hal_gps_.applyGnssConfig(mode, sat_mask, send_rxm, send_gnss); } bool HalGpsAdapter::applyNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) diff --git a/platform/esp/arduino_common/src/hal/hal_gps.cpp b/platform/esp/arduino_common/src/hal/hal_gps.cpp index 5c7cf0ed..7a91f190 100644 --- a/platform/esp/arduino_common/src/hal/hal_gps.cpp +++ b/platform/esp/arduino_common/src/hal/hal_gps.cpp @@ -55,6 +55,15 @@ uint32_t HalGps::loop() return board_->getGPS().loop(); } +uint32_t HalGps::lastLoopReadBytes() const +{ + if (board_ == nullptr) + { + return 0; + } + return board_->getGPS().lastLoopReadBytes(); +} + bool HalGps::hasFix() const { return board_ != nullptr && board_->getGPS().location.isValid(); @@ -124,7 +133,7 @@ bool HalGps::syncTime(uint32_t gps_task_interval_ms) return board_->syncTimeFromGPS(gps_task_interval_ms); } -bool HalGps::applyGnssConfig(uint8_t mode, uint8_t sat_mask) +bool HalGps::applyGnssConfig(uint8_t mode, uint8_t sat_mask, bool send_rxm, bool send_gnss) { if (board_ == nullptr) { @@ -132,9 +141,22 @@ bool HalGps::applyGnssConfig(uint8_t mode, uint8_t sat_mask) } GPS& gps = board_->getGPS(); - bool ok_mode = gps.setReceiverMode(mode, sat_mask); - bool ok_gnss = gps.configureGnss(sat_mask); + bool ok_mode = !send_rxm || gps.setReceiverMode(mode, sat_mask); + +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) + bool ok_gnss = !send_gnss || gps.configureGnss(sat_mask); + Serial.printf("[GPS] gnss config tdeck mode=%u sat_mask=0x%02X rxm_send=%d rxm=%d cfg_gnss_send=%d cfg_gnss=%d\n", + static_cast(mode), + static_cast(sat_mask), + send_rxm ? 1 : 0, + ok_mode ? 1 : 0, + send_gnss ? 1 : 0, + ok_gnss ? 1 : 0); return ok_mode && ok_gnss; +#else + bool ok_gnss = !send_gnss || gps.configureGnss(sat_mask); + return ok_mode && ok_gnss; +#endif } bool HalGps::applyNmeaConfig(uint8_t output_hz, uint8_t sentence_mask) diff --git a/platform/esp/arduino_common/src/platform_ui_firmware_update_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_firmware_update_runtime.cpp index bd774c1f..541b6568 100644 --- a/platform/esp/arduino_common/src/platform_ui_firmware_update_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_firmware_update_runtime.cpp @@ -1,6 +1,7 @@ #include "platform/ui/firmware_update_runtime.h" #include +#include #include #include #include @@ -36,6 +37,8 @@ constexpr const char* kReleaseMetadataUrl = "https://vicliu624.github.io/trail-m constexpr const char* kReleaseBaseUrl = "https://vicliu624.github.io/trail-mate"; constexpr int kHttpBufferSize = 2048; constexpr int kHttpTxBufferSize = 512; +constexpr std::size_t kMetadataLogSnippetBytes = 160; +constexpr std::size_t kOtaProgressLogStepBytes = 128 * 1024; constexpr std::size_t kTlsLargeAllocThresholdBytes = 4096; constexpr uint32_t kWorkerStackBytes = 12 * 1024; constexpr UBaseType_t kWorkerPriority = 4; @@ -83,6 +86,94 @@ struct RuntimeState RuntimeState s_runtime{}; portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +void ota_log(const char* format, ...) +{ + std::printf("[OTA] "); + va_list args; + va_start(args, format); + std::vprintf(format ? format : "", args); + va_end(args); + std::printf("\n"); + std::fflush(stdout); +} + +const char* bool_text(bool value) +{ + return value ? "true" : "false"; +} + +const char* safe_text(const char* value) +{ + return value && value[0] != '\0' ? value : "(empty)"; +} + +const char* esp_err_name_safe(esp_err_t err) +{ + const char* name = esp_err_to_name(err); + return name ? name : "ESP_ERR_UNKNOWN"; +} + +void set_esp_error(std::string& out_error, const char* message, esp_err_t err) +{ + char buffer[128]; + std::snprintf(buffer, + sizeof(buffer), + "%s: %s (0x%x)", + message ? message : "ESP error", + esp_err_name_safe(err), + static_cast(err)); + out_error = buffer; +} + +const char* action_name(RequestedAction action) +{ + switch (action) + { + case RequestedAction::Check: + return "check"; + case RequestedAction::Install: + return "install"; + } + return "unknown"; +} + +const char* wifi_state_name(::platform::ui::wifi::ConnectionState state) +{ + switch (state) + { + case ::platform::ui::wifi::ConnectionState::Unsupported: + return "unsupported"; + case ::platform::ui::wifi::ConnectionState::Disabled: + return "disabled"; + case ::platform::ui::wifi::ConnectionState::Idle: + return "idle"; + case ::platform::ui::wifi::ConnectionState::Scanning: + return "scanning"; + case ::platform::ui::wifi::ConnectionState::Connecting: + return "connecting"; + case ::platform::ui::wifi::ConnectionState::Connected: + return "connected"; + case ::platform::ui::wifi::ConnectionState::Error: + return "error"; + } + return "unknown"; +} + +std::string compact_log_snippet(const std::string& text) +{ + const std::size_t length = text.size() < kMetadataLogSnippetBytes ? text.size() + : kMetadataLogSnippetBytes; + std::string snippet = text.substr(0, length); + for (char& ch : snippet) + { + if (ch == '\r' || ch == '\n' || ch == '\t') + { + ch = ' '; + } + } + return snippet; +} + void copy_bounded(char* out, std::size_t out_len, const char* text) { if (!out || out_len == 0) @@ -400,12 +491,12 @@ void worker_finished() void log_memory_snapshot(const char* stage) { - std::printf("[OTA][MEM] %s ram_free=%u ram_largest=%u psram_free=%u psram_largest=%u\n", - stage ? stage : "state", - static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)), - static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)), - static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)), - static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM))); + ota_log("[MEM] %s ram_free=%u ram_largest=%u psram_free=%u psram_largest=%u", + stage ? stage : "state", + static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)), + static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)), + static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM))); } void* mbedtls_calloc_prefer_psram(std::size_t count, std::size_t size) @@ -448,9 +539,9 @@ bool ensure_tls_allocator_configured() log_memory_snapshot("before tls alloc config"); configured = mbedtls_platform_set_calloc_free(&mbedtls_calloc_prefer_psram, &mbedtls_free_prefer_psram) == 0; - std::printf("[OTA][TLS] allocator configured=%d threshold=%lu\n", - configured ? 1 : 0, - static_cast(kTlsLargeAllocThresholdBytes)); + ota_log("[TLS] allocator configured=%s threshold=%lu", + bool_text(configured), + static_cast(kTlsLargeAllocThresholdBytes)); log_memory_snapshot("after tls alloc config"); return configured; } @@ -473,6 +564,8 @@ bool http_get_text(const std::string& url, std::string& out, std::string& out_er out.clear(); out_error.clear(); + ota_log("metadata http start url=%s", url.c_str()); + esp_http_client_config_t config{}; configure_http_client(config, url); @@ -480,20 +573,30 @@ bool http_get_text(const std::string& url, std::string& out, std::string& out_er if (client == nullptr) { out_error = "Create HTTP client failed"; + ota_log("metadata http init failed"); return false; } bool ok = false; - if (esp_http_client_open(client, 0) == ESP_OK) + const esp_err_t open_err = esp_http_client_open(client, 0); + if (open_err == ESP_OK) { - if (esp_http_client_fetch_headers(client) >= 0) + ota_log("metadata http open ok"); + const int64_t fetch_result = esp_http_client_fetch_headers(client); + if (fetch_result >= 0) { const int status_code = esp_http_client_get_status_code(client); + const long long content_length = esp_http_client_get_content_length(client); + ota_log("metadata http headers status=%d fetch_result=%lld content_length=%lld", + status_code, + static_cast(fetch_result), + content_length); if (status_code < 200 || status_code >= 300) { char buffer[64]; std::snprintf(buffer, sizeof(buffer), "Metadata HTTP %d", status_code); out_error = buffer; + ota_log("metadata http rejected status=%d", status_code); } else { @@ -504,6 +607,9 @@ bool http_get_text(const std::string& url, std::string& out, std::string& out_er if (read < 0) { out_error = "Read metadata failed"; + ota_log("metadata http read failed read=%d bytes_so_far=%u", + read, + static_cast(out.size())); break; } if (read == 0) @@ -518,15 +624,38 @@ bool http_get_text(const std::string& url, std::string& out, std::string& out_er else { out_error = "Fetch metadata headers failed"; + ota_log("metadata http fetch headers failed result=%lld status=%d", + static_cast(fetch_result), + esp_http_client_get_status_code(client)); } } else { - out_error = "Open metadata request failed"; + set_esp_error(out_error, "Open metadata request failed", open_err); + ota_log("metadata http open failed err=%s (0x%x)", + esp_err_name_safe(open_err), + static_cast(open_err)); } - esp_http_client_close(client); - esp_http_client_cleanup(client); + const esp_err_t close_err = esp_http_client_close(client); + if (close_err != ESP_OK) + { + ota_log("metadata http close err=%s (0x%x)", + esp_err_name_safe(close_err), + static_cast(close_err)); + } + const esp_err_t cleanup_err = esp_http_client_cleanup(client); + if (cleanup_err != ESP_OK) + { + ota_log("metadata http cleanup err=%s (0x%x)", + esp_err_name_safe(cleanup_err), + static_cast(cleanup_err)); + } + ota_log("metadata http finish ok=%s bytes=%u error=%s snippet=\"%s\"", + bool_text(ok), + static_cast(out.size()), + out_error.empty() ? "(none)" : out_error.c_str(), + ok ? compact_log_snippet(out).c_str() : ""); return ok; } @@ -578,14 +707,32 @@ bool fetch_release_metadata(ReleaseMetadata& out_metadata, std::string& out_erro out_error.clear(); const auto wifi_status = ::platform::ui::wifi::status(); + ota_log("metadata wifi supported=%s enabled=%s connected=%s state=%s ssid=\"%s\" ip=%s rssi=%d message=\"%s\"", + bool_text(wifi_status.supported), + bool_text(wifi_status.enabled), + bool_text(wifi_status.connected), + wifi_state_name(wifi_status.state), + safe_text(wifi_status.ssid), + safe_text(wifi_status.ip), + wifi_status.rssi, + safe_text(wifi_status.message)); if (!wifi_status.supported) { out_error = "Wi-Fi unsupported"; + ota_log("metadata rejected: %s", out_error.c_str()); return false; } if (!wifi_status.connected) { - out_error = "Connect Wi-Fi in Settings first"; + char buffer[128]; + std::snprintf(buffer, + sizeof(buffer), + "Wi-Fi not connected: %s", + wifi_state_name(wifi_status.state)); + out_error = buffer; + ota_log("metadata rejected: %s message=\"%s\"", + out_error.c_str(), + safe_text(wifi_status.message)); return false; } @@ -599,14 +746,23 @@ bool fetch_release_metadata(ReleaseMetadata& out_metadata, std::string& out_erro if (!root) { out_error = "Parse release metadata failed"; + ota_log("metadata json parse failed error_at=\"%s\" body_snippet=\"%s\"", + safe_text(cJSON_GetErrorPtr()), + compact_log_snippet(text).c_str()); return false; } out_metadata.release_available = json_bool(root, "available"); + ota_log("metadata release available=%s tag=%s version=%s target=%s", + bool_text(out_metadata.release_available), + safe_text(json_string(root, "tag_name").c_str()), + safe_text(json_string(root, "version").c_str()), + safe_text(firmware_target_id())); if (!out_metadata.release_available) { cJSON_Delete(root); out_error = "No published release available"; + ota_log("metadata rejected: %s", out_error.c_str()); return false; } @@ -620,6 +776,10 @@ bool fetch_release_metadata(ReleaseMetadata& out_metadata, std::string& out_erro { cJSON_Delete(root); out_error = "No release published for this device"; + ota_log("metadata rejected: %s target=%s targets_object=%s", + out_error.c_str(), + safe_text(firmware_target_id()), + bool_text(cJSON_IsObject(targets))); return false; } @@ -634,23 +794,43 @@ bool fetch_release_metadata(ReleaseMetadata& out_metadata, std::string& out_erro out_metadata.ota_sha256 = json_string(target, "ota_sha256"); out_metadata.ota_size_bytes = json_size_t(target, "ota_size_bytes"); + ota_log("metadata target available=%s ota_available=%s version=%s path=%s sha_len=%u size=%u", + bool_text(out_metadata.target_available), + bool_text(out_metadata.ota_available), + safe_text(out_metadata.latest_version.c_str()), + safe_text(out_metadata.ota_path.c_str()), + static_cast(out_metadata.ota_sha256.size()), + static_cast(out_metadata.ota_size_bytes)); + cJSON_Delete(root); if (out_metadata.latest_version.empty()) { out_error = "Release metadata missing version"; + ota_log("metadata rejected: %s", out_error.c_str()); return false; } if (!out_metadata.ota_available || out_metadata.ota_path.empty()) { out_error = "No OTA package for this device"; + ota_log("metadata rejected: %s ota_available=%s path=%s", + out_error.c_str(), + bool_text(out_metadata.ota_available), + safe_text(out_metadata.ota_path.c_str())); return false; } - if (out_metadata.ota_sha256.empty()) + if (out_metadata.ota_sha256.size() != 64) { - out_error = "OTA metadata missing SHA256"; + out_error = out_metadata.ota_sha256.empty() ? "OTA metadata missing SHA256" + : "OTA metadata SHA256 invalid"; + ota_log("metadata rejected: %s sha_len=%u", + out_error.c_str(), + static_cast(out_metadata.ota_sha256.size())); return false; } + ota_log("metadata accepted latest=%s ota_size=%u", + safe_text(out_metadata.latest_version.c_str()), + static_cast(out_metadata.ota_size_bytes)); return true; } @@ -658,6 +838,11 @@ bool battery_allows_install(std::string& out_error) { out_error.clear(); const auto battery = ::platform::ui::device::battery_info(); + ota_log("install battery available=%s charging=%s level=%d min=%d", + bool_text(battery.available), + bool_text(battery.charging), + battery.level, + kMinBatteryPercentForInstall); if (!battery.available || battery.charging || battery.level < 0) { return true; @@ -665,6 +850,7 @@ bool battery_allows_install(std::string& out_error) if (battery.level < kMinBatteryPercentForInstall) { out_error = "Charge battery before updating"; + ota_log("install rejected: %s", out_error.c_str()); return false; } return true; @@ -687,6 +873,10 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) out_error.clear(); const std::string url = join_url(kReleaseBaseUrl, metadata.ota_path); + ota_log("ota download start url=%s expected_size=%u expected_sha=%s", + url.c_str(), + static_cast(metadata.ota_size_bytes), + safe_text(metadata.ota_sha256.c_str())); esp_http_client_config_t config{}; configure_http_client(config, url); @@ -694,6 +884,7 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) if (client == nullptr) { out_error = "Create OTA client failed"; + ota_log("ota http init failed"); return false; } @@ -702,13 +893,23 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) { esp_http_client_cleanup(client); out_error = "No OTA partition available"; + ota_log("ota rejected: %s", out_error.c_str()); return false; } + ota_log("ota partition label=%s address=0x%x size=%u subtype=%u", + update_partition->label, + static_cast(update_partition->address), + static_cast(update_partition->size), + static_cast(update_partition->subtype)); if (metadata.ota_size_bytes > 0 && metadata.ota_size_bytes > update_partition->size) { esp_http_client_cleanup(client); out_error = "Firmware image is too large"; + ota_log("ota rejected: %s image_size=%u partition_size=%u", + out_error.c_str(), + static_cast(metadata.ota_size_bytes), + static_cast(update_partition->size)); return false; } @@ -717,6 +918,7 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) bool ok = false; bool client_opened = false; std::size_t bytes_written = 0; + std::size_t next_progress_log = kOtaProgressLogStepBytes; int last_progress = -1; std::uint8_t buffer[kHttpBufferSize]; @@ -725,17 +927,32 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) mbedtls_sha256_init(&sha_ctx); mbedtls_sha256_starts(&sha_ctx, 0); - if (esp_http_client_open(client, 0) != ESP_OK) + const esp_err_t open_err = esp_http_client_open(client, 0); + if (open_err != ESP_OK) { - out_error = "Open OTA request failed"; + set_esp_error(out_error, "Open OTA request failed", open_err); + ota_log("ota http open failed err=%s (0x%x)", + esp_err_name_safe(open_err), + static_cast(open_err)); goto cleanup; } client_opened = true; + ota_log("ota http open ok"); - if (esp_http_client_fetch_headers(client) < 0) { - out_error = "Fetch OTA headers failed"; - goto cleanup; + const int64_t fetch_result = esp_http_client_fetch_headers(client); + if (fetch_result < 0) + { + out_error = "Fetch OTA headers failed"; + ota_log("ota http fetch headers failed result=%lld status=%d", + static_cast(fetch_result), + esp_http_client_get_status_code(client)); + goto cleanup; + } + ota_log("ota http headers fetch_result=%lld status=%d content_length=%lld", + static_cast(fetch_result), + esp_http_client_get_status_code(client), + esp_http_client_get_content_length(client)); } { @@ -745,6 +962,7 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) char buffer[64]; std::snprintf(buffer, sizeof(buffer), "OTA HTTP %d", http_status_code); out_error = buffer; + ota_log("ota http rejected status=%d", http_status_code); goto cleanup; } } @@ -754,15 +972,26 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) if (content_length > 0 && static_cast(content_length) > update_partition->size) { out_error = "OTA image exceeds partition size"; + ota_log("ota rejected: %s content_length=%lld partition_size=%u", + out_error.c_str(), + content_length, + static_cast(update_partition->size)); goto cleanup; } } - if (esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle) != ESP_OK) { - out_error = "Begin OTA write failed"; - goto cleanup; + const esp_err_t begin_err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle); + if (begin_err != ESP_OK) + { + set_esp_error(out_error, "Begin OTA write failed", begin_err); + ota_log("ota begin failed err=%s (0x%x)", + esp_err_name_safe(begin_err), + static_cast(begin_err)); + goto cleanup; + } } + ota_log("ota begin ok"); ota_started = true; set_progress_status(Phase::Downloading, "Downloading update...", "0%", 0); @@ -775,15 +1004,22 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) if (read < 0) { out_error = "Read OTA image failed"; + ota_log("ota read failed read=%d bytes_written=%u", read, static_cast(bytes_written)); goto cleanup; } if (read == 0) { break; } - if (esp_ota_write(ota_handle, buffer, static_cast(read)) != ESP_OK) + const esp_err_t write_err = esp_ota_write(ota_handle, buffer, static_cast(read)); + if (write_err != ESP_OK) { - out_error = "Write OTA image failed"; + set_esp_error(out_error, "Write OTA image failed", write_err); + ota_log("ota write failed err=%s (0x%x) offset=%u read=%d", + esp_err_name_safe(write_err), + static_cast(write_err), + static_cast(bytes_written), + read); goto cleanup; } @@ -818,16 +1054,30 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) set_progress_status(Phase::Downloading, "Downloading update...", detail, progress); } } + if (bytes_written >= next_progress_log) + { + ota_log("ota progress bytes=%u total=%u", + static_cast(bytes_written), + static_cast(progress_total)); + next_progress_log += kOtaProgressLogStepBytes; + } } + ota_log("ota download complete bytes=%u", static_cast(bytes_written)); + if (bytes_written == 0) { out_error = "OTA image download was empty"; + ota_log("ota rejected: %s", out_error.c_str()); goto cleanup; } if (metadata.ota_size_bytes > 0 && bytes_written != metadata.ota_size_bytes) { out_error = "OTA image size mismatch"; + ota_log("ota rejected: %s expected=%u actual=%u", + out_error.c_str(), + static_cast(metadata.ota_size_bytes), + static_cast(bytes_written)); goto cleanup; } @@ -842,77 +1092,139 @@ bool begin_ota_download(const ReleaseMetadata& metadata, std::string& out_error) if (lowercase_ascii(actual_sha256) != lowercase_ascii(metadata.ota_sha256)) { out_error = "OTA SHA256 mismatch"; + ota_log("ota sha mismatch expected=%s actual=%s", + safe_text(metadata.ota_sha256.c_str()), + actual_sha256); goto cleanup; } + ota_log("ota sha ok actual=%s", actual_sha256); } set_progress_status(Phase::Installing, "Verifying update...", "Finalizing image", 100); - if (esp_ota_end(ota_handle) != ESP_OK) { - out_error = "Finalize OTA image failed"; - goto cleanup; + const esp_err_t end_err = esp_ota_end(ota_handle); + if (end_err != ESP_OK) + { + set_esp_error(out_error, "Finalize OTA image failed", end_err); + ota_log("ota end failed err=%s (0x%x)", + esp_err_name_safe(end_err), + static_cast(end_err)); + goto cleanup; + } } + ota_log("ota end ok"); ota_started = false; - if (esp_ota_set_boot_partition(update_partition) != ESP_OK) { - out_error = "Activate OTA partition failed"; - goto cleanup; + const esp_err_t boot_err = esp_ota_set_boot_partition(update_partition); + if (boot_err != ESP_OK) + { + set_esp_error(out_error, "Activate OTA partition failed", boot_err); + ota_log("ota set boot partition failed err=%s (0x%x)", + esp_err_name_safe(boot_err), + static_cast(boot_err)); + goto cleanup; + } } + ota_log("ota set boot partition ok"); ok = true; cleanup: if (ota_started) { - esp_ota_abort(ota_handle); + const esp_err_t abort_err = esp_ota_abort(ota_handle); + ota_log("ota abort err=%s (0x%x)", + esp_err_name_safe(abort_err), + static_cast(abort_err)); } if (client_opened) { - esp_http_client_close(client); + const esp_err_t close_err = esp_http_client_close(client); + if (close_err != ESP_OK) + { + ota_log("ota http close err=%s (0x%x)", + esp_err_name_safe(close_err), + static_cast(close_err)); + } + } + { + const esp_err_t cleanup_err = esp_http_client_cleanup(client); + if (cleanup_err != ESP_OK) + { + ota_log("ota http cleanup err=%s (0x%x)", + esp_err_name_safe(cleanup_err), + static_cast(cleanup_err)); + } } - esp_http_client_cleanup(client); mbedtls_sha256_free(&sha_ctx); + ota_log("ota download finish ok=%s bytes=%u error=%s", + bool_text(ok), + static_cast(bytes_written), + out_error.empty() ? "(none)" : out_error.c_str()); return ok; } bool perform_check(std::string& out_error) { + ota_log("check start current=%s target=%s metadata_url=%s", + safe_text(::platform::ui::device::firmware_version()), + safe_text(firmware_target_id()), + kReleaseMetadataUrl); ReleaseMetadata metadata{}; if (!fetch_release_metadata(metadata, out_error)) { + ota_log("check failed stage=metadata error=%s", safe_text(out_error.c_str())); return false; } const char* current_version = ::platform::ui::device::firmware_version(); - if (compare_versions(current_version ? current_version : "", metadata.latest_version) < 0) + const int compare_result = compare_versions(current_version ? current_version : "", metadata.latest_version); + ota_log("check compare current=%s latest=%s result=%d", + safe_text(current_version), + safe_text(metadata.latest_version.c_str()), + compare_result); + if (compare_result < 0) { set_update_available_status(metadata.latest_version.c_str()); + ota_log("check result update_available latest=%s", safe_text(metadata.latest_version.c_str())); } else { set_up_to_date_status(metadata.latest_version.c_str()); + ota_log("check result up_to_date latest=%s", safe_text(metadata.latest_version.c_str())); } return true; } bool perform_install(std::string& out_error) { + ota_log("install start current=%s target=%s", + safe_text(::platform::ui::device::firmware_version()), + safe_text(firmware_target_id())); ReleaseMetadata metadata{}; if (!fetch_release_metadata(metadata, out_error)) { + ota_log("install failed stage=metadata error=%s", safe_text(out_error.c_str())); return false; } const char* current_version = ::platform::ui::device::firmware_version(); - if (compare_versions(current_version ? current_version : "", metadata.latest_version) >= 0) + const int compare_result = compare_versions(current_version ? current_version : "", metadata.latest_version); + ota_log("install compare current=%s latest=%s result=%d", + safe_text(current_version), + safe_text(metadata.latest_version.c_str()), + compare_result); + if (compare_result >= 0) { set_up_to_date_status(metadata.latest_version.c_str()); + ota_log("install skipped already_up_to_date latest=%s", safe_text(metadata.latest_version.c_str())); return true; } if (!battery_allows_install(out_error)) { + ota_log("install failed stage=battery error=%s", safe_text(out_error.c_str())); return false; } @@ -925,6 +1237,7 @@ bool perform_install(std::string& out_error) if (restore_ble) { set_progress_status(Phase::Installing, "Preparing update...", "Stopping BLE service", -1); + ota_log("install stopping BLE before OTA"); ble_manager->setEnabled(false); } } @@ -934,10 +1247,14 @@ bool perform_install(std::string& out_error) if (!ok) { restore_ble_after_failure(restore_ble); + ota_log("install failed stage=download error=%s ble_restored=%s", + safe_text(out_error.c_str()), + bool_text(restore_ble)); return false; } set_rebooting_status(metadata.latest_version.c_str()); + ota_log("install success latest=%s rebooting", safe_text(metadata.latest_version.c_str())); vTaskDelay(pdMS_TO_TICKS(600)); ::platform::ui::device::restart(); return true; @@ -953,6 +1270,7 @@ void worker_task_entry(void* param) delete ctx; } + ota_log("worker start action=%s", action_name(action)); std::string error; bool ok = false; switch (action) @@ -968,29 +1286,56 @@ void worker_task_entry(void* param) if (!ok) { - set_error_status(error.c_str()); + set_error_status(action == RequestedAction::Check ? "Update check failed" : "Update install failed", + error.c_str()); } + ota_log("worker finish action=%s ok=%s error=%s", + action_name(action), + bool_text(ok), + error.empty() ? "(none)" : error.c_str()); worker_finished(); vTaskDelete(nullptr); } bool queue_worker(RequestedAction action, const char* initial_detail) { + ota_log("queue request action=%s target=%s current=%s wifi_supported=%s stack=%lu priority=%u", + action_name(action), + safe_text(firmware_target_id()), + safe_text(::platform::ui::device::firmware_version()), + bool_text(::platform::ui::wifi::is_supported()), + static_cast(kWorkerStackBytes), + static_cast(kWorkerPriority)); WorkerContext* ctx = new (std::nothrow) WorkerContext{}; if (!ctx) { set_error_status("Allocate update worker failed"); + ota_log("queue rejected action=%s reason=alloc_failed", action_name(action)); return false; } ctx->action = action; portENTER_CRITICAL(&s_lock); ensure_initialized_locked(); - if (!s_runtime.status.supported || s_runtime.worker_task != nullptr || s_runtime.launch_pending) + const bool supported = s_runtime.status.supported; + const bool has_worker = s_runtime.worker_task != nullptr; + const bool launch_pending = s_runtime.launch_pending; + char status_message[sizeof(s_runtime.status.message)] = {}; + char status_detail[sizeof(s_runtime.status.detail)] = {}; + copy_bounded(status_message, sizeof(status_message), s_runtime.status.message); + copy_bounded(status_detail, sizeof(status_detail), s_runtime.status.detail); + if (!supported || has_worker || launch_pending) { portEXIT_CRITICAL(&s_lock); delete ctx; + ota_log("queue rejected action=%s supported=%s has_worker=%s launch_pending=%s message=%s detail=%s", + action_name(action), + bool_text(supported), + bool_text(has_worker), + bool_text(launch_pending), + safe_text(status_message), + safe_text(status_detail)); return false; } s_runtime.launch_pending = true; @@ -1016,11 +1361,23 @@ bool queue_worker(RequestedAction action, const char* initial_detail) set_status_locked(s_runtime.status, Phase::Error, false, "Create update task failed", nullptr, -1); portEXIT_CRITICAL(&s_lock); delete ctx; + ota_log("queue rejected action=%s reason=create_task_failed result=%d handle=%p", + action_name(action), + static_cast(task_ok), + static_cast(task_handle)); return false; } - s_runtime.worker_task = task_handle; - s_runtime.launch_pending = false; + const bool worker_finished_before_handle_store = !s_runtime.launch_pending; + if (!worker_finished_before_handle_store) + { + s_runtime.worker_task = task_handle; + s_runtime.launch_pending = false; + } portEXIT_CRITICAL(&s_lock); + ota_log("queue accepted action=%s task=%p already_finished=%s", + action_name(action), + static_cast(task_handle), + bool_text(worker_finished_before_handle_store)); return true; } diff --git a/platform/esp/arduino_common/src/platform_ui_gps_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_gps_runtime.cpp index fa083b36..ea23475f 100644 --- a/platform/esp/arduino_common/src/platform_ui_gps_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_gps_runtime.cpp @@ -15,6 +15,11 @@ bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count return ::gps::gps_get_gnss_snapshot(out, max, out_count, status); } +GpsDiagnosticsSnapshot diagnostics() +{ + return ::gps::gps_get_diagnostics(); +} + uint32_t last_motion_ms() { return ::gps::gps_get_last_motion_ms(); @@ -55,6 +60,11 @@ void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) ::gps::gps_set_external_nmea_config(output_hz, sentence_mask); } +void set_receiver_init_config(const GpsReceiverInitConfig& config) +{ + ::gps::gps_set_receiver_init_config(config); +} + void set_motion_idle_timeout(uint32_t timeout_ms) { ::gps::gps_set_motion_idle_timeout(timeout_ms); diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/gps_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/gps_runtime.h index 2bf4e236..547e2348 100644 --- a/platform/esp/idf_common/include/platform/esp/idf_common/gps_runtime.h +++ b/platform/esp/idf_common/include/platform/esp/idf_common/gps_runtime.h @@ -6,13 +6,16 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "gps/domain/gnss_satellite.h" +#include "gps/domain/gps_diagnostics.h" #include "gps/domain/gps_state.h" +#include "gps/usecase/gps_runtime_config.h" namespace platform::esp::idf_common::gps_runtime { gps::GpsState get_data(); bool get_gnss_snapshot(gps::GnssSatInfo* out, std::size_t max, std::size_t* out_count, gps::GnssStatus* status); +gps::GpsDiagnosticsSnapshot diagnostics(); uint32_t last_motion_ms(); bool is_enabled(); bool is_powered(); @@ -21,6 +24,7 @@ void set_collection_interval(uint32_t interval_ms); void set_power_strategy(uint8_t strategy); void set_gnss_config(uint8_t mode, uint8_t sat_mask); void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask); +void set_receiver_init_config(const gps::GpsReceiverInitConfig& config); void set_motion_idle_timeout(uint32_t timeout_ms); void set_motion_sensor_id(uint8_t sensor_id); TaskHandle_t get_task_handle(); diff --git a/platform/esp/idf_common/src/gps_runtime.cpp b/platform/esp/idf_common/src/gps_runtime.cpp index dd89cf3f..1b99db54 100644 --- a/platform/esp/idf_common/src/gps_runtime.cpp +++ b/platform/esp/idf_common/src/gps_runtime.cpp @@ -738,6 +738,57 @@ bool get_gnss_snapshot(gps::GnssSatInfo* out, std::size_t max, std::size_t* out_ return true; } +gps::GpsDiagnosticsSnapshot diagnostics() +{ + std::lock_guard lock(s_mutex); + gps::GpsDiagnosticsSnapshot snapshot{}; + snapshot.supported = platform::ui::device::gps_supported(); + snapshot.enabled = snapshot.supported && s_runtime.enabled; + snapshot.powered = snapshot.supported && s_runtime.powered; + snapshot.ready = s_runtime.worker_handle != nullptr; + snapshot.has_fix = s_runtime.data.valid; + snapshot.satellites = s_runtime.data.satellites; + snapshot.sats_in_view = s_runtime.status.sats_in_view; + snapshot.sats_in_use = s_runtime.status.sats_in_use; + snapshot.last_rx_age_ms = s_runtime.last_rx_ms ? (now_ms() - s_runtime.last_rx_ms) : 0xFFFFFFFFUL; + snapshot.poll_interval_ms = 200; + snapshot.collection_interval_ms = s_runtime.collection_interval_ms; + + if (!snapshot.supported) + { + snapshot.code = gps::GpsDiagnosticCode::Disabled; + } + else if (!snapshot.enabled) + { + snapshot.code = gps::GpsDiagnosticCode::NotEnabled; + } + else if (!snapshot.powered) + { + snapshot.code = gps::GpsDiagnosticCode::PowerOff; + } + else if (!snapshot.ready) + { + snapshot.code = gps::GpsDiagnosticCode::TransportNotReady; + } + else if (snapshot.last_rx_age_ms == 0xFFFFFFFFUL) + { + snapshot.code = gps::GpsDiagnosticCode::NoTraffic; + } + else if (snapshot.last_rx_age_ms > kNoDataWarnMs) + { + snapshot.code = gps::GpsDiagnosticCode::TrafficStalled; + } + else if (!snapshot.has_fix) + { + snapshot.code = gps::GpsDiagnosticCode::NoFix; + } + else + { + snapshot.code = gps::GpsDiagnosticCode::OK; + } + return snapshot; +} + uint32_t last_motion_ms() { std::lock_guard lock(s_mutex); @@ -813,6 +864,11 @@ void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) s_runtime.external_nmea_sentence_mask = sentence_mask; } +void set_receiver_init_config(const gps::GpsReceiverInitConfig& config) +{ + (void)config; +} + void set_motion_idle_timeout(uint32_t timeout_ms) { std::lock_guard lock(s_mutex); diff --git a/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp b/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp index fa083b36..ea23475f 100644 --- a/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp @@ -15,6 +15,11 @@ bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count return ::gps::gps_get_gnss_snapshot(out, max, out_count, status); } +GpsDiagnosticsSnapshot diagnostics() +{ + return ::gps::gps_get_diagnostics(); +} + uint32_t last_motion_ms() { return ::gps::gps_get_last_motion_ms(); @@ -55,6 +60,11 @@ void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) ::gps::gps_set_external_nmea_config(output_hz, sentence_mask); } +void set_receiver_init_config(const GpsReceiverInitConfig& config) +{ + ::gps::gps_set_receiver_init_config(config); +} + void set_motion_idle_timeout(uint32_t timeout_ms) { ::gps::gps_set_motion_idle_timeout(timeout_ms); diff --git a/platform/linux/common/README.md b/platform/linux/common/README.md index 2748df1f..85f8bfc3 100644 --- a/platform/linux/common/README.md +++ b/platform/linux/common/README.md @@ -10,6 +10,8 @@ This layer is the neutral meeting point for: - Linux-safe surface presentation contracts - Linux implementations of shared `platform::ui::*` runtime contracts such as settings, time, screen timeout, and generic device state +- SQLite-backed Linux runtime state shared by simulator/device/uConsole shells +- Linux map tile cache plumbing for online fetch plus local XYZ file cache - the shared Linux boot/menu shell session used by both the simulator and the thin framebuffer device shell diff --git a/platform/linux/common/include/app/linux_app_facade.h b/platform/linux/common/include/app/linux_app_facade.h index 149f7685..4cde5204 100644 --- a/platform/linux/common/include/app/linux_app_facade.h +++ b/platform/linux/common/include/app/linux_app_facade.h @@ -2,6 +2,7 @@ #include "app/app_config.h" #include "app/app_facades.h" +#include "app/linux_app_services.h" #include @@ -78,19 +79,8 @@ class MinimalLinuxAppFacade final : public ::app::IAppFacade void dispatchPendingEvents(std::size_t max_events = 32) override; private: - struct Implementation; - - Implementation& impl(); - const Implementation& impl() const; - void loadPersistedConfig(); - void seedDefaultIdentity(); - void syncLocalIdentity(); - void ensureServicesReady(); - - ::app::AppConfig config_{}; - std::unique_ptr impl_; + ::trailmate::linux_app::LinuxAppServices services_; ::chat::ui::IChatUiRuntime* chat_ui_runtime_ = nullptr; - bool initialized_ = false; }; } // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/include/app/linux_app_services.h b/platform/linux/common/include/app/linux_app_services.h new file mode 100644 index 00000000..b92d5e2d --- /dev/null +++ b/platform/linux/common/include/app/linux_app_services.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include + +#include "app/app_config.h" +#include "chat/domain/chat_types.h" +#include "platform/linux/runtime_mode.h" + +namespace chat +{ +class ChatService; +class IMeshAdapter; +namespace contacts +{ +class ContactService; +} +} // namespace chat + +namespace team +{ +class TeamController; +class TeamPairingService; +class TeamService; +class TeamTrackSampler; +} // namespace team + +namespace sys +{ +struct Event; +} + +namespace trailmate::linux_app +{ + +using UiEventDispatcher = bool (*)(void* context, ::sys::Event* event); + +struct LinuxAppServicesOptions +{ + const char* default_node_name = "Trail Mate Linux"; + const char* default_short_name = "TL"; + const char* demo_broadcast_text = "Broadcast: Linux local mesh online."; + platform::linux_runtime::LinuxRuntimeMode runtime_mode = + platform::linux_runtime::resolve_runtime_mode(); + bool ble_supported = false; +}; + +// UI-independent Linux application service entrypoint. +// +// This is intentionally narrower than the legacy IAppFacade adapter: desktop +// shells should depend on app services and presentation models, not on compact +// LVGL page contracts. +class LinuxAppServices final +{ + public: + explicit LinuxAppServices(LinuxAppServicesOptions options = {}); + ~LinuxAppServices(); + + LinuxAppServices(const LinuxAppServices&) = delete; + LinuxAppServices& operator=(const LinuxAppServices&) = delete; + + bool initialize(); + void shutdown(); + [[nodiscard]] bool isInitialized() const noexcept; + + void setUiEventDispatcher(UiEventDispatcher dispatcher, + void* context = nullptr) noexcept; + bool dispatchUiEvent(::sys::Event* event); + + void tick(std::size_t max_events = 32); + void updateCoreServices(); + void tickEventRuntime(); + void dispatchPendingEvents(std::size_t max_events = 32); + + [[nodiscard]] ::app::AppConfig& config(); + [[nodiscard]] const ::app::AppConfig& config() const; + [[nodiscard]] ::app::AppConfig& getConfig(); + [[nodiscard]] const ::app::AppConfig& getConfig() const; + void saveConfig(); + void applyMeshConfig(); + void applyUserInfo(); + void applyPositionConfig(); + void applyNetworkLimits(); + void applyPrivacyConfig(); + void applyChatDefaults(); + + [[nodiscard]] ::chat::MeshProtocol meshProtocol() const; + [[nodiscard]] ::chat::MeshProtocol getMeshProtocol() const; + bool switchMeshProtocol(::chat::MeshProtocol protocol, bool persist = true); + + [[nodiscard]] ::chat::ChatService& chat(); + [[nodiscard]] ::chat::ChatService& getChatService(); + [[nodiscard]] ::chat::contacts::ContactService& contacts(); + [[nodiscard]] ::chat::contacts::ContactService& getContactService(); + [[nodiscard]] ::chat::IMeshAdapter* meshAdapter(); + [[nodiscard]] ::chat::IMeshAdapter* getMeshAdapter(); + [[nodiscard]] const ::chat::IMeshAdapter* meshAdapter() const; + [[nodiscard]] const ::chat::IMeshAdapter* getMeshAdapter() const; + [[nodiscard]] ::chat::NodeId selfNodeId() const; + [[nodiscard]] ::chat::NodeId getSelfNodeId() const; + + [[nodiscard]] ::team::TeamController* teamController(); + [[nodiscard]] ::team::TeamController* getTeamController(); + [[nodiscard]] ::team::TeamPairingService* teamPairing(); + [[nodiscard]] ::team::TeamPairingService* getTeamPairing(); + [[nodiscard]] ::team::TeamService* teamService(); + [[nodiscard]] ::team::TeamService* getTeamService(); + [[nodiscard]] const ::team::TeamService* teamService() const; + [[nodiscard]] const ::team::TeamService* getTeamService() const; + [[nodiscard]] ::team::TeamTrackSampler* teamTrackSampler(); + [[nodiscard]] ::team::TeamTrackSampler* getTeamTrackSampler(); + void setTeamModeActive(bool active); + + void broadcastNodeInfo(); + void clearNodeDb(); + void clearMessageDb(); + + bool isBleEnabled() const; + void setBleEnabled(bool enabled); + void restartDevice(); + + private: + struct Implementation; + + Implementation& impl(); + const Implementation& impl() const; + void loadPersistedConfig(); + void seedDefaultIdentity(); + void syncLocalIdentity(); + void ensureServicesReady(); + + LinuxAppServicesOptions options_{}; + ::app::AppConfig config_{}; + std::unique_ptr impl_; + UiEventDispatcher ui_event_dispatcher_ = nullptr; + void* ui_event_context_ = nullptr; + bool initialized_ = false; +}; + +} // namespace trailmate::linux_app diff --git a/platform/linux/common/include/chat/linux_raw_lora_mesh_adapter.h b/platform/linux/common/include/chat/linux_raw_lora_mesh_adapter.h new file mode 100644 index 00000000..c46f3fb6 --- /dev/null +++ b/platform/linux/common/include/chat/linux_raw_lora_mesh_adapter.h @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "chat/domain/chat_model.h" +#include "chat/ports/i_mesh_adapter.h" +#include "platform/linux/sx126x_radio.h" + +namespace trailmate::linux_app +{ + +class LinuxRawLoraMeshAdapter final : public ::chat::IMeshAdapter +{ + public: + explicit LinuxRawLoraMeshAdapter(::chat::NodeId self_node_id = 0); + + [[nodiscard]] static bool hardwareCandidatePresent(); + + bool begin(); + void tick(); + bool takePendingSendResult(::chat::MessageId& out_msg_id, bool& out_ok); + + ::chat::MeshCapabilities getCapabilities() const override; + bool sendText(::chat::ChannelId channel, + const std::string& text, + ::chat::MessageId* out_msg_id, + ::chat::NodeId peer = 0) override; + bool sendTextWithId(::chat::ChannelId channel, + const std::string& text, + ::chat::MessageId forced_msg_id, + ::chat::MessageId* out_msg_id, + ::chat::NodeId peer = 0) override; + bool pollIncomingText(::chat::MeshIncomingText* out) override; + bool sendAppData(::chat::ChannelId channel, + std::uint32_t portnum, + const std::uint8_t* payload, + std::size_t len, + ::chat::NodeId dest = 0, + bool want_ack = false, + ::chat::MessageId packet_id = 0, + bool want_response = false) override; + bool pollIncomingData(::chat::MeshIncomingData* out) override; + bool requestNodeInfo(::chat::NodeId dest, bool want_response) override; + void applyConfig(const ::chat::MeshConfig& config) override; + void applyProtocolConfig(::chat::MeshProtocol protocol, + const ::chat::MeshConfig& config); + void setUserInfo(const char* long_name, const char* short_name) override; + ::chat::NodeId getNodeId() const override; + bool isReady() const override; + bool pollIncomingRawPacket(std::uint8_t* out_data, + std::size_t& out_len, + std::size_t max_len) override; + void processSendQueue() override; + + void setSelfNodeId(::chat::NodeId id); + [[nodiscard]] std::string statusText() const; + [[nodiscard]] std::string radioConfigText() const; + [[nodiscard]] std::string radioStatsText() const; + [[nodiscard]] std::vector diagnosticLines() const; + + private: + enum class PacketKind : std::uint8_t + { + Text = 1, + AppData = 2, + }; + + struct PendingResult + { + ::chat::MessageId msg_id = 0; + bool ok = false; + }; + + bool ensureRadioReady(); + ::chat::MessageId nextMessageId(); + void logStatusIfChanged(const char* title, const std::string& status); + void logRadioStatsChanges(); + void logRxMonitorHeartbeat(); + bool sendMeshtasticPayload(::chat::ChannelId channel, + ::chat::NodeId dest, + ::chat::MessageId msg_id, + std::uint32_t portnum, + const std::uint8_t* payload, + std::size_t len, + bool want_ack, + bool want_response); + bool sendMeshtasticNodeInfoTo(::chat::NodeId dest, + bool want_response, + ::chat::ChannelId channel); + bool sendFrame(PacketKind kind, + ::chat::ChannelId channel, + ::chat::NodeId dest, + ::chat::MessageId msg_id, + std::uint32_t portnum, + const std::uint8_t* payload, + std::size_t len); + bool parseFrame(const ::platform::linux_runtime::Sx126xPacket& packet); + bool parseMeshtasticPacket( + const ::platform::linux_runtime::Sx126xPacket& packet); + + ::platform::linux_runtime::Sx126xRadio& radio_; + ::chat::NodeId self_node_id_ = 0; + ::chat::MeshConfig config_{}; + ::chat::MeshProtocol active_protocol_ = ::chat::MeshProtocol::Meshtastic; + std::uint32_t next_msg_id_ = 1; + bool started_ = false; + bool tx_enabled_ = true; + std::string long_name_{}; + std::string short_name_{}; + std::string last_status_ = "LoRa driver not started."; + std::deque<::chat::MeshIncomingText> incoming_text_{}; + std::deque<::chat::MeshIncomingData> incoming_data_{}; + std::deque pending_results_{}; + std::deque<::platform::linux_runtime::Sx126xPacket> raw_packets_{}; + ::platform::linux_runtime::Sx126xRadioStats last_logged_stats_{}; + bool stats_logged_ = false; + std::string last_logged_status_{}; + std::uint32_t last_rx_monitor_log_s_ = 0; +}; + +} // namespace trailmate::linux_app diff --git a/platform/linux/common/include/chat/linux_sqlite_chat_store.h b/platform/linux/common/include/chat/linux_sqlite_chat_store.h new file mode 100644 index 00000000..7d8da6b3 --- /dev/null +++ b/platform/linux/common/include/chat/linux_sqlite_chat_store.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include "chat/ports/i_chat_store.h" + +namespace trailmate::linux_app +{ + +class LinuxSqliteChatStore final : public ::chat::IChatStore +{ + public: + LinuxSqliteChatStore(); + ~LinuxSqliteChatStore() override; + + void append(const ::chat::ChatMessage& msg) override; + std::vector<::chat::ChatMessage> loadRecent( + const ::chat::ConversationId& conv, + std::size_t n) override; + std::vector<::chat::ConversationMeta> loadConversationPage( + std::size_t offset, + std::size_t limit, + std::size_t* total) override; + void setUnread(const ::chat::ConversationId& conv, int unread) override; + int getUnread(const ::chat::ConversationId& conv) const override; + void clearConversation(const ::chat::ConversationId& conv) override; + void clearAll() override; + bool updateMessageStatus(::chat::MessageId msg_id, + ::chat::MessageStatus status) override; + bool getMessage(::chat::MessageId msg_id, + ::chat::ChatMessage* out) const override; + void flush() override; + + private: + mutable std::mutex mutex_; +}; + +} // namespace trailmate::linux_app diff --git a/platform/linux/common/include/platform/linux/map_contour_tile_generator.h b/platform/linux/common/include/platform/linux/map_contour_tile_generator.h new file mode 100644 index 00000000..e42d27d3 --- /dev/null +++ b/platform/linux/common/include/platform/linux/map_contour_tile_generator.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +#include "platform/linux/map_tile_cache.h" + +namespace platform::linux_runtime +{ + +struct MapContourGenerationResult +{ + std::size_t requested_tiles = 0; + std::size_t cached_tiles = 0; + std::size_t generated_tiles = 0; + std::size_t failed_tiles = 0; + std::string message{}; +}; + +class MapContourTileGenerator final +{ + public: + MapContourTileGenerator(); + + [[nodiscard]] MapContourGenerationResult ensure_tiles( + const std::vector& tiles, + const std::string& earthdata_token) const; + + private: + MapContourTileStore store_{}; +}; + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/map_diagnostics.h b/platform/linux/common/include/platform/linux/map_diagnostics.h new file mode 100644 index 00000000..370a3907 --- /dev/null +++ b/platform/linux/common/include/platform/linux/map_diagnostics.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include + +namespace platform::linux_runtime +{ + +std::filesystem::path map_diagnostic_log_path(); +void append_map_diagnostic(std::string_view category, + std::string_view message); + +std::string map_curl_doh_url(); +void apply_map_curl_resolver(CURL* curl); +std::string curl_error_message(CURLcode code, const char* error_buffer); + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/map_tile_cache.h b/platform/linux/common/include/platform/linux/map_tile_cache.h new file mode 100644 index 00000000..4e8cee54 --- /dev/null +++ b/platform/linux/common/include/platform/linux/map_tile_cache.h @@ -0,0 +1,131 @@ +#pragma once + +#include +#include +#include +#include + +namespace platform::linux_runtime +{ + +enum class MapBaseSource : std::uint8_t +{ + Osm = 0, + Terrain = 1, + Satellite = 2, +}; + +struct MapTileId +{ + MapBaseSource source = MapBaseSource::Osm; + int z = 0; + int x = 0; + int y = 0; +}; + +enum class MapContourKind : std::uint8_t +{ + Major = 0, + Minor = 1, +}; + +struct MapContourProfile +{ + MapContourKind kind = MapContourKind::Major; + int interval_m = 0; +}; + +struct MapContourTileId +{ + MapContourProfile profile{}; + int z = 0; + int x = 0; + int y = 0; +}; + +enum class MapTileStatus +{ + Invalid, + Missing, + Cached, + Downloaded, + Failed, +}; + +struct MapTileResult +{ + MapTileStatus status = MapTileStatus::Invalid; + MapTileId tile{}; + std::filesystem::path path{}; + std::string message{}; + long http_status = 0; + std::uintmax_t bytes = 0; +}; + +struct MapTileCacheStats +{ + std::filesystem::path root{}; + std::filesystem::path database{}; + std::uint64_t cached_tiles = 0; + std::uint64_t failed_tiles = 0; + std::uint64_t total_bytes = 0; +}; + +MapBaseSource sanitize_map_base_source(std::uint8_t source) noexcept; +const char* map_base_source_key(MapBaseSource source) noexcept; +const char* map_base_source_label(MapBaseSource source) noexcept; +const char* map_base_source_extension(MapBaseSource source) noexcept; +const char* map_contour_kind_key(MapContourKind kind) noexcept; +std::string map_contour_profile_key(const MapContourProfile& profile); + +class MapTileCache final +{ + public: + MapTileCache(); + explicit MapTileCache(std::filesystem::path root); + + [[nodiscard]] const std::filesystem::path& root() const noexcept; + [[nodiscard]] std::filesystem::path tile_path(const MapTileId& tile) const; + [[nodiscard]] std::filesystem::path relative_tile_path( + const MapTileId& tile) const; + [[nodiscard]] bool tile_available(const MapTileId& tile) const; + [[nodiscard]] MapTileCacheStats stats() const; + + MapTileResult ensure_tile(const MapTileId& tile) const; + + private: + std::filesystem::path root_{}; +}; + +class MapContourTileStore final +{ + public: + MapContourTileStore(); + explicit MapContourTileStore(std::filesystem::path root); + + [[nodiscard]] const std::filesystem::path& root() const noexcept; + [[nodiscard]] std::filesystem::path tile_path( + const MapContourTileId& tile) const; + [[nodiscard]] std::filesystem::path existing_tile_path( + const MapContourTileId& tile) const; + [[nodiscard]] std::filesystem::path relative_tile_path( + const MapContourTileId& tile) const; + [[nodiscard]] bool tile_available(const MapContourTileId& tile) const; + + private: + std::filesystem::path root_{}; +}; + +void normalize_map_tile(MapTileId& tile) noexcept; +void normalize_map_contour_tile(MapContourTileId& tile) noexcept; +std::vector map_tiles_around(double lat, + double lon, + int zoom, + MapBaseSource source, + int radius_x, + int radius_y); +std::vector contour_profiles_for_zoom( + int zoom, + bool allow_ultra_fine); + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/runtime_packet_log.h b/platform/linux/common/include/platform/linux/runtime_packet_log.h new file mode 100644 index 00000000..0a85315c --- /dev/null +++ b/platform/linux/common/include/platform/linux/runtime_packet_log.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include + +namespace platform::linux_runtime +{ + +enum class PacketLogSource +{ + Gps, + Lora, + Mqtt, +}; + +enum class PacketLogDirection +{ + Rx, + Tx, + System, +}; + +enum class PacketLogSegmentKind +{ + Header, + Body, + Checksum, + Meta, + Error, +}; + +struct PacketLogSegment +{ + PacketLogSegmentKind kind = PacketLogSegmentKind::Meta; + std::string label{}; + std::string text{}; +}; + +struct PacketLogEntry +{ + PacketLogSource source = PacketLogSource::Gps; + PacketLogDirection direction = PacketLogDirection::Rx; + std::uint64_t timestamp_ms = 0; + std::string title{}; + std::string summary{}; + std::string raw_hex{}; + std::vector segments{}; +}; + +void append_packet_log(PacketLogEntry entry); +std::vector recent_packet_logs(PacketLogSource source, + std::size_t max_entries); +void clear_packet_logs(PacketLogSource source); + +const char* packet_log_source_label(PacketLogSource source) noexcept; +const char* packet_log_direction_label(PacketLogDirection direction) noexcept; +const char* packet_log_segment_class(PacketLogSegmentKind kind) noexcept; +std::string hex_bytes(const std::uint8_t* data, std::size_t size); +std::string hex_bytes(const char* data, std::size_t size); + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/include/platform/linux/runtime_paths.h b/platform/linux/common/include/platform/linux/runtime_paths.h index d1c78960..6cd6b699 100644 --- a/platform/linux/common/include/platform/linux/runtime_paths.h +++ b/platform/linux/common/include/platform/linux/runtime_paths.h @@ -43,6 +43,10 @@ RuntimePaths resolve_paths(); /// Returns /settings/.kv std::filesystem::path settings_file(const char* ns); +/// Convenience: SQLite database used by Linux runtime state. +/// Returns /trailmate.sqlite3 +std::filesystem::path sqlite_database_path(); + /// Convenience: child path under sd_root. std::filesystem::path sd_child(std::string_view relative); diff --git a/platform/linux/common/include/platform/linux/sx126x_radio.h b/platform/linux/common/include/platform/linux/sx126x_radio.h new file mode 100644 index 00000000..2670aba7 --- /dev/null +++ b/platform/linux/common/include/platform/linux/sx126x_radio.h @@ -0,0 +1,168 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace platform::linux_runtime +{ + +struct Sx126xRadioConfig +{ + std::string spi_device = "/dev/spidev1.0"; + std::string gpiochip = "/dev/gpiochip0"; + int power_gpio = 16; + int reset_gpio = 25; + int busy_gpio = 24; + int irq_gpio = 26; + std::uint32_t spi_speed_hz = 2000000; + bool dio2_as_rf_switch = true; + bool dio3_tcxo_1v8 = true; +}; + +struct Sx126xLoRaConfig +{ + float freq_mhz = 433.175f; + float bw_khz = 250.0f; + std::uint8_t sf = 8; + std::uint8_t cr = 5; + std::int8_t tx_power_dbm = 17; + std::uint16_t preamble_len = 16; + std::uint8_t sync_word = 0x12; + std::uint8_t crc_len = 2; +}; + +struct Sx126xPacket +{ + std::array data{}; + std::size_t size = 0; + float rssi_dbm = 0.0f; + float snr_db = 0.0f; + std::uint32_t freq_hz = 0; + std::uint32_t bw_hz = 0; + std::uint8_t sf = 0; + std::uint8_t cr = 0; +}; + +struct Sx126xRadioStats +{ + bool online = false; + std::uint32_t rx_packets = 0; + std::uint32_t tx_packets = 0; + std::uint32_t rx_crc_errors = 0; + std::uint32_t rx_header_errors = 0; + std::uint32_t rx_timeouts = 0; + std::uint32_t rx_invalid_lengths = 0; + std::uint32_t rx_read_errors = 0; + std::uint32_t last_irq_flags = 0; + Sx126xLoRaConfig lora_config{}; +}; + +class Sx126xRadio final +{ + public: + static Sx126xRadio& instance(); + + [[nodiscard]] static bool hardwareCandidatePresent(); + [[nodiscard]] static Sx126xRadioConfig defaultConfigFromEnvironment(); + [[nodiscard]] static Sx126xLoRaConfig defaultLoRaConfigFromEnvironment(); + + bool acquire(const Sx126xRadioConfig& config = defaultConfigFromEnvironment()); + void release(); + + bool configureLoRa(const Sx126xLoRaConfig& config); + bool startReceive(); + bool transmit(const std::uint8_t* data, std::size_t size); + bool pollReceive(Sx126xPacket* out); + float readRssi(); + + [[nodiscard]] bool isOnline() const; + [[nodiscard]] const char* lastError() const; + [[nodiscard]] Sx126xLoRaConfig appliedLoRaConfig() const; + [[nodiscard]] Sx126xRadioStats stats() const; + + private: + Sx126xRadio() = default; + ~Sx126xRadio(); + + Sx126xRadio(const Sx126xRadio&) = delete; + Sx126xRadio& operator=(const Sx126xRadio&) = delete; + + bool initLocked(const Sx126xRadioConfig& config); + bool openSpiLocked(); + bool openGpioLocked(); + void closeLocked(); + + void waitReadyLocked() const; + bool transferLocked(const std::uint8_t* tx, + std::uint8_t* rx, + std::size_t size); + bool writeCommandLocked(std::uint8_t cmd, + const std::uint8_t* data, + std::size_t size, + bool wait); + bool readCommandLocked(std::uint8_t cmd, + const std::uint8_t* prefix, + std::size_t prefix_size, + std::uint8_t* data, + std::size_t size, + bool wait); + bool writeRegisterLocked(std::uint16_t addr, + const std::uint8_t* data, + std::size_t size); + bool readRegisterLocked(std::uint16_t addr, + std::uint8_t* data, + std::size_t size); + + bool prepareAio2Locked(); + bool probeLocked(); + bool readStatusLocked(std::uint8_t* out_status); + bool setPacketTypeLocked(std::uint8_t packet_type); + bool setRfFrequencyLocked(float freq_mhz); + bool setTxPowerLocked(std::int8_t tx_power); + bool setDioIrqParamsLocked(std::uint16_t irq_mask, + std::uint16_t dio1_mask); + bool clearIrqLocked(std::uint16_t flags); + bool setBufferBaseLocked(std::uint8_t tx_base, std::uint8_t rx_base); + bool setRxLocked(std::uint32_t timeout_raw); + bool setTxLocked(std::uint32_t timeout_raw); + bool configureLoRaLocked(const Sx126xLoRaConfig& config); + std::uint32_t getIrqFlagsLocked(); + int getPacketLengthLocked(std::uint8_t* out_offset); + int readPacketLocked(std::uint8_t offset, + std::uint8_t* buffer, + std::size_t size); + bool readPacketStatusLocked(float* out_rssi_dbm, float* out_snr_db); + + void setErrorLocked(const char* error); + void setErrorStringLocked(const std::string& error); + void updateLastIrqLocked(std::uint32_t irq); + + mutable std::mutex mutex_{}; + Sx126xRadioConfig config_{}; + Sx126xLoRaConfig lora_config_{}; + int spi_fd_ = -1; + int chip_fd_ = -1; + int power_fd_ = -1; + int reset_fd_ = -1; + int busy_fd_ = -1; + int irq_fd_ = -1; + int users_ = 0; + std::uint8_t packet_type_ = 0xFF; + float freq_mhz_ = 0.0f; + bool initialized_ = false; + bool online_ = false; + std::uint32_t rx_packets_ = 0; + std::uint32_t tx_packets_ = 0; + std::uint32_t rx_crc_errors_ = 0; + std::uint32_t rx_header_errors_ = 0; + std::uint32_t rx_timeouts_ = 0; + std::uint32_t rx_invalid_lengths_ = 0; + std::uint32_t rx_read_errors_ = 0; + std::uint32_t last_irq_flags_ = 0; + char last_error_[512] = {}; +}; + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/app/linux_app_facade.cpp b/platform/linux/common/src/app/linux_app_facade.cpp index aa8f8363..f151e59b 100644 --- a/platform/linux/common/src/app/linux_app_facade.cpp +++ b/platform/linux/common/src/app/linux_app_facade.cpp @@ -1,47 +1,9 @@ #include "app/linux_app_facade.h" -#include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include #include "app/app_facade_access.h" -#include "chat/domain/chat_model.h" -#include "chat/infra/contact_store_core.h" -#include "chat/infra/node_store_core.h" -#include "chat/infra/store/ram_store.h" -#include "chat/ports/i_contact_blob_store.h" -#include "chat/ports/i_node_blob_store.h" -#include "chat/usecase/chat_service.h" -#include "chat/usecase/contact_service.h" -#include "platform/ui/device_runtime.h" -#include "platform/ui/gps_runtime.h" -#include "platform/ui/settings_store.h" -#include "platform/ui/team_ui_store_runtime.h" -#include "sys/clock.h" #include "sys/event_bus.h" -#include "team/ports/i_team_crypto.h" -#include "team/ports/i_team_event_sink.h" -#include "team/ports/i_team_pairing_event_sink.h" -#include "team/ports/i_team_pairing_transport.h" -#include "team/ports/i_team_runtime.h" -#include "team/ports/i_team_track_source.h" -#include "team/protocol/team_pairing_wire.h" -#include "team/protocol/team_position.h" -#include "team/usecase/team_controller.h" -#include "team/usecase/team_pairing_coordinator.h" -#include "team/usecase/team_pairing_service.h" -#include "team/usecase/team_service.h" -#include "team/usecase/team_track_sampler.h" #include "ui/chat_ui_runtime.h" namespace trailmate::cardputer_zero::linux_ui @@ -51,107 +13,6 @@ namespace TeamUiEventDispatcher s_team_ui_event_dispatcher = nullptr; -constexpr const char* kConfigNamespace = "linux_app_facade"; -constexpr const char* kConfigBlobKey = "app_config_v1"; -constexpr uint32_t kConfigBlobMagic = 0x544D4346U; // TMCF -constexpr uint32_t kConfigBlobVersion = 1U; - -constexpr const char* kNodeStoreNamespace = "linux_contact_nodes"; -constexpr const char* kNodeStoreKey = "nodes_v1"; -constexpr const char* kContactStoreNamespace = "linux_contact_names"; -constexpr const char* kContactStoreKey = "contacts_v1"; - -constexpr ::chat::NodeId kDemoAlphaNodeId = 0x435A1001U; -constexpr ::chat::NodeId kDemoBravoNodeId = 0x435A1002U; -constexpr ::chat::NodeId kDemoScoutNodeId = 0x435A1003U; -constexpr ::chat::NodeId kDemoNearbyNodeId = 0x435A1004U; -constexpr ::chat::NodeId kDemoBroadcastNodeId = kDemoAlphaNodeId; -constexpr ::chat::NodeId kSyntheticPairLeaderNodeId = kDemoAlphaNodeId; -constexpr ::chat::NodeId kSyntheticPairMemberNodeId = kDemoBravoNodeId; - -constexpr std::array kSyntheticLeaderMac{{0x43, 0x5A, 0x20, 0x01, 0x00, 0x01}}; -constexpr std::array kSyntheticMemberMac{{0x43, 0x5A, 0x20, 0x01, 0x00, 0x02}}; - -constexpr uint32_t kSyntheticPairKeyId = 1U; -constexpr uint32_t kSyntheticPairNonce = 0x5A431122U; -constexpr uint32_t kSyntheticPairDelayMs = 220U; -constexpr uint32_t kAutoReplyDelayMs = 700U; - -team::TeamId makeSyntheticPairTeamId() -{ - team::TeamId team_id{}; - const std::array bytes{{'C', 'Z', 'T', 'E', 'A', 'M', '0', '1'}}; - for (size_t i = 0; i < team_id.size() && i < bytes.size(); ++i) - { - team_id[i] = bytes[i]; - } - return team_id; -} - -std::array makeSyntheticPairPsk() -{ - std::array psk{}; - for (size_t i = 0; i < psk.size(); ++i) - { - psk[i] = static_cast(0x30U + (i * 7U + 3U) % 0x4FU); - } - return psk; -} - -const char* syntheticPairTeamName() -{ - return "Field Team"; -} - -std::string makeAutoReplyText(::chat::NodeId peer, const std::string& text) -{ - const std::string trimmed = text.substr(0, std::min(text.size(), 28U)); - switch (peer) - { - case kDemoAlphaNodeId: - return "Alice copied: " + trimmed; - case kDemoBravoNodeId: - return "Bravo link OK: " + trimmed; - case kDemoScoutNodeId: - return "Scout received your ping."; - default: - return "Peer ack: " + trimmed; - } -} - -struct DemoPeerSeed -{ - ::chat::NodeId node_id = 0; - const char* short_name = nullptr; - const char* long_name = nullptr; - const char* nickname = nullptr; - bool ignored = false; - float snr = 0.0f; - float rssi = 0.0f; - int32_t lat_e7 = 0; - int32_t lon_e7 = 0; -}; - -std::array demoPeerSeeds() -{ - return {{ - {kDemoAlphaNodeId, "ALFA", "Alice Local", "Alice", false, 11.2f, -72.0f, 311214000, 1214737000}, - {kDemoBravoNodeId, "BRAV", "Bravo Pager", "Bravo", false, 8.6f, -79.0f, 311218500, 1214749000}, - {kDemoScoutNodeId, "SCOT", "Scout Relay", nullptr, true, 4.1f, -94.0f, 311227000, 1214762000}, - {kDemoNearbyNodeId, "NBY1", "Nearby Relay", nullptr, false, 6.8f, -88.0f, 311231200, 1214756000}, - }}; -} - -struct PersistedConfigBlob -{ - uint32_t magic = kConfigBlobMagic; - uint32_t version = kConfigBlobVersion; - ::app::AppConfig config{}; -}; - -static_assert(std::is_trivially_copyable_v<::app::AppConfig>, - "MinimalLinuxAppFacade persists AppConfig as an opaque blob."); - void copy_bounded(char* out, std::size_t out_len, const char* text) { if (out == nullptr || out_len == 0) @@ -169,1186 +30,44 @@ void copy_bounded(char* out, std::size_t out_len, const char* text) out[out_len - 1U] = '\0'; } -bool is_supported_protocol(::chat::MeshProtocol protocol) +bool isTeamUiEvent(const ::sys::Event& event) { - switch (protocol) - { - case ::chat::MeshProtocol::Meshtastic: - case ::chat::MeshProtocol::MeshCore: - case ::chat::MeshProtocol::RNode: - case ::chat::MeshProtocol::LXMF: - return true; - default: - return false; - } + return event.type == ::sys::EventType::TeamKick || + event.type == ::sys::EventType::TeamTransferLeader || + event.type == ::sys::EventType::TeamKeyDist || + event.type == ::sys::EventType::TeamStatus || + event.type == ::sys::EventType::TeamPosition || + event.type == ::sys::EventType::TeamWaypoint || + event.type == ::sys::EventType::TeamTrack || + event.type == ::sys::EventType::TeamChat || + event.type == ::sys::EventType::TeamPairing || + event.type == ::sys::EventType::TeamError || + event.type == ::sys::EventType::SystemTick; } -class LinuxNodeBlobStore final : public ::chat::contacts::INodeBlobStore +bool handleFacadeUiEvent(void* context, ::sys::Event* event) { - public: - bool loadBlob(std::vector& out) override + if (context == nullptr || event == nullptr) { - return ::platform::ui::settings_store::get_blob(kNodeStoreNamespace, kNodeStoreKey, out); - } - - bool saveBlob(const uint8_t* data, size_t len) override - { - return ::platform::ui::settings_store::put_blob(kNodeStoreNamespace, kNodeStoreKey, data, len); - } - - void clearBlob() override - { - ::platform::ui::settings_store::clear_namespace(kNodeStoreNamespace); - } -}; - -class LinuxContactBlobStore final : public ::chat::IContactBlobStore -{ - public: - bool loadBlob(std::vector& out) override - { - return ::platform::ui::settings_store::get_blob(kContactStoreNamespace, kContactStoreKey, out); - } - - bool saveBlob(const uint8_t* data, size_t len) override - { - return ::platform::ui::settings_store::put_blob(kContactStoreNamespace, kContactStoreKey, data, len); - } - - void clear() - { - ::platform::ui::settings_store::clear_namespace(kContactStoreNamespace); - } -}; - -class LinuxLoopbackMeshAdapter final : public ::chat::IMeshAdapter -{ - public: - explicit LinuxLoopbackMeshAdapter(::chat::NodeId self_node_id) : self_node_id_(self_node_id) {} - - ::chat::MeshCapabilities getCapabilities() const override - { - return { - .supports_unicast_text = true, - .supports_unicast_appdata = true, - .supports_broadcast_appdata = true, - .supports_appdata_ack = true, - .provides_appdata_sender = true, - .supports_node_info = true, - .supports_pki = true, - .supports_discovery_actions = true, - }; - } - - bool sendText(::chat::ChannelId channel, const std::string& text, - ::chat::MessageId* out_msg_id, ::chat::NodeId peer = 0) override - { - return sendTextWithId(channel, text, 0, out_msg_id, peer); - } - - bool sendTextWithId(::chat::ChannelId channel, const std::string& text, - ::chat::MessageId forced_msg_id, - ::chat::MessageId* out_msg_id, ::chat::NodeId peer = 0) override - { - if (text.empty()) - { - return false; - } - - const ::chat::MessageId msg_id = (forced_msg_id != 0) ? forced_msg_id : nextMessageId(); - if (out_msg_id) - { - *out_msg_id = msg_id; - } - - pending_send_results_.push_back({msg_id, true}); - - if (peer != 0 && peer == self_node_id_) - { - ::chat::MeshIncomingText loopback{}; - loopback.channel = channel; - loopback.from = self_node_id_; - loopback.to = self_node_id_; - loopback.msg_id = msg_id; - loopback.timestamp = sys::epoch_seconds_now(); - loopback.text = text; - incoming_texts_.push_back(loopback); - } - else if (peer != 0) - { - scheduleIncomingText({ - .channel = channel, - .from = peer, - .to = self_node_id_, - .msg_id = nextMessageId(), - .timestamp = sys::epoch_seconds_now(), - .text = makeAutoReplyText(peer, text), - }, - kAutoReplyDelayMs); - } - else - { - scheduleIncomingText({ - .channel = channel, - .from = kDemoBroadcastNodeId, - .to = 0, - .msg_id = nextMessageId(), - .timestamp = sys::epoch_seconds_now(), - .text = "Broadcast heard: " + text.substr(0, std::min(text.size(), 32U)), - }, - kAutoReplyDelayMs + 120U); - } - - return true; - } - - bool pollIncomingText(::chat::MeshIncomingText* out) override - { - if (out == nullptr || incoming_texts_.empty()) - { - return false; - } - - *out = incoming_texts_.front(); - incoming_texts_.erase(incoming_texts_.begin()); - return true; - } - - bool sendAppData(::chat::ChannelId channel, uint32_t portnum, - const uint8_t* payload, size_t len, - ::chat::NodeId dest = 0, bool want_ack = false, - ::chat::MessageId packet_id = 0, - bool want_response = false) override - { - (void)channel; - (void)portnum; - (void)payload; - (void)len; - (void)dest; - (void)want_ack; - (void)packet_id; - (void)want_response; - return true; - } - - bool pollIncomingData(::chat::MeshIncomingData* out) override - { - if (out == nullptr || incoming_data_.empty()) - { - return false; - } - - *out = incoming_data_.front(); - incoming_data_.erase(incoming_data_.begin()); - return true; - } - - bool requestNodeInfo(::chat::NodeId dest, bool want_response) override - { - (void)dest; - (void)want_response; - return true; - } - - bool startKeyVerification(::chat::NodeId dest) override - { - (void)dest; - return true; - } - - bool submitKeyVerificationNumber(::chat::NodeId dest, uint64_t nonce, uint32_t number) override - { - (void)dest; - (void)nonce; - (void)number; - return true; - } - - ::chat::NodeId getNodeId() const override - { - return self_node_id_; - } - - bool isPkiReady() const override - { - return true; - } - - bool hasPkiKey(::chat::NodeId dest) const override - { - return dest != 0; - } - - bool triggerDiscoveryAction(::chat::MeshDiscoveryAction action) override - { - (void)action; - return true; - } - - void applyConfig(const ::chat::MeshConfig& config) override - { - config_ = config; - } - - void setUserInfo(const char* long_name, const char* short_name) override - { - long_name_ = long_name ? long_name : ""; - short_name_ = short_name ? short_name : ""; - } - - bool isReady() const override - { - return true; - } - - bool pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size_t max_len) override - { - (void)out_data; - (void)out_len; - (void)max_len; return false; } - void setSelfNodeId(::chat::NodeId node_id) - { - self_node_id_ = node_id; - } - - void queueIncomingText(const ::chat::MeshIncomingText& msg) - { - incoming_texts_.push_back(msg); - } - - void queueIncomingData(const ::chat::MeshIncomingData& msg) - { - incoming_data_.push_back(msg); - } - - void tick() - { - const uint32_t now_ms = sys::millis_now(); - while (!pending_incoming_texts_.empty()) - { - const auto& pending = pending_incoming_texts_.front(); - if (pending.due_ms > now_ms) - { - break; - } - incoming_texts_.push_back(pending.message); - pending_incoming_texts_.pop_front(); - } - } - - bool takePendingSendResult(::chat::MessageId& out_msg_id, bool& out_ok) - { - if (pending_send_results_.empty()) - { - return false; - } - - const auto result = pending_send_results_.front(); - pending_send_results_.erase(pending_send_results_.begin()); - out_msg_id = result.first; - out_ok = result.second; - return true; - } - - private: - struct DelayedIncomingText - { - uint32_t due_ms = 0; - ::chat::MeshIncomingText message{}; - }; - - ::chat::MessageId nextMessageId() - { - if (next_message_id_ == 0) - { - next_message_id_ = 1; - } - return next_message_id_++; - } - - void scheduleIncomingText(::chat::MeshIncomingText msg, uint32_t delay_ms) - { - msg.timestamp = sys::epoch_seconds_now(); - pending_incoming_texts_.push_back({ - .due_ms = sys::millis_now() + delay_ms, - .message = std::move(msg), - }); - } - - ::chat::NodeId self_node_id_ = 0; - ::chat::MessageId next_message_id_ = 1; - ::chat::MeshConfig config_{}; - std::string long_name_{}; - std::string short_name_{}; - std::vector<::chat::MeshIncomingText> incoming_texts_{}; - std::vector<::chat::MeshIncomingData> incoming_data_{}; - std::vector> pending_send_results_{}; - std::deque pending_incoming_texts_{}; -}; - -class LinuxTeamRuntime final : public ::team::ITeamRuntime -{ - public: - uint32_t nowMillis() override - { - return sys::millis_now(); - } - - uint32_t nowUnixSeconds() override - { - return sys::epoch_seconds_now(); - } - - void fillRandomBytes(uint8_t* out, size_t len) override - { - if (out == nullptr || len == 0) - { - return; - } - - static std::random_device rd; - static std::mt19937 gen(rd()); - static std::uniform_int_distribution dist(0, 255); - for (size_t i = 0; i < len; ++i) - { - out[i] = static_cast(dist(gen)); - } - } -}; - -class LinuxTeamCrypto final : public ::team::ITeamCrypto -{ - public: - bool deriveKey(const uint8_t* key, size_t key_len, - const char* info, - uint8_t* out, size_t out_len) override - { - if (key == nullptr || key_len == 0 || out == nullptr || out_len == 0) - { - return false; - } - - uint32_t state = 2166136261u; - for (size_t i = 0; i < key_len; ++i) - { - state ^= key[i]; - state *= 16777619u; - } - if (info) - { - for (const char* p = info; *p != '\0'; ++p) - { - state ^= static_cast(*p); - state *= 16777619u; - } - } - - for (size_t i = 0; i < out_len; ++i) - { - state ^= static_cast(i + 1U); - state *= 16777619u; - out[i] = static_cast((state >> ((i % 4U) * 8U)) & 0xFFU); - } - return true; - } - - bool aeadEncrypt(const uint8_t* key, size_t key_len, - const uint8_t* nonce, size_t nonce_len, - const uint8_t* aad, size_t aad_len, - const uint8_t* plain, size_t plain_len, - std::vector& out_cipher) override - { - return xorCipher(key, key_len, nonce, nonce_len, aad, aad_len, plain, plain_len, out_cipher); - } - - bool aeadDecrypt(const uint8_t* key, size_t key_len, - const uint8_t* nonce, size_t nonce_len, - const uint8_t* aad, size_t aad_len, - const uint8_t* cipher, size_t cipher_len, - std::vector& out_plain) override - { - return xorCipher(key, key_len, nonce, nonce_len, aad, aad_len, cipher, cipher_len, out_plain); - } - - private: - static bool xorCipher(const uint8_t* key, size_t key_len, - const uint8_t* nonce, size_t nonce_len, - const uint8_t* aad, size_t aad_len, - const uint8_t* input, size_t input_len, - std::vector& output) - { - if (key == nullptr || key_len == 0 || input == nullptr) - { - return false; - } - - uint32_t state = 0x9E3779B9u; - for (size_t i = 0; i < key_len; ++i) - { - state = (state * 33u) ^ key[i]; - } - for (size_t i = 0; i < nonce_len; ++i) - { - state = (state * 33u) ^ nonce[i]; - } - for (size_t i = 0; i < aad_len; ++i) - { - state = (state * 33u) ^ aad[i]; - } - - output.resize(input_len); - for (size_t i = 0; i < input_len; ++i) - { - state = state * 1664525u + 1013904223u; - output[i] = static_cast(input[i] ^ ((state >> 24U) & 0xFFU)); - } - return true; - } -}; - -class LinuxChatEventBusBridge final : public ::chat::ChatService::IncomingMessageObserver -{ - public: - explicit LinuxChatEventBusBridge(::chat::ChatService& service) : service_(service) - { - service_.addIncomingMessageObserver(this); - } - - ~LinuxChatEventBusBridge() override - { - service_.removeIncomingMessageObserver(this); - } - - void onIncomingMessage(const ::chat::ChatMessage& msg, const ::chat::RxMeta* rx_meta) override - { - ::sys::EventBus::publish( - new ::sys::ChatNewMessageEvent(static_cast(msg.channel), - msg.msg_id, - msg.text.c_str(), - rx_meta), - 0); - } - - private: - ::chat::ChatService& service_; -}; - -class LinuxTeamEventBusSink final : public ::team::ITeamEventSink -{ - public: - void onTeamKick(const ::team::TeamKickEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamKickEvent(event), 0); - } - - void onTeamTransferLeader(const ::team::TeamTransferLeaderEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamTransferLeaderEvent(event), 0); - } - - void onTeamKeyDist(const ::team::TeamKeyDistEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamKeyDistEvent(event), 0); - } - - void onTeamStatus(const ::team::TeamStatusEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamStatusEvent(event), 0); - } - - void onTeamPosition(const ::team::TeamPositionEvent& event) override - { - ::team::proto::TeamPositionMessage pos{}; - if (event.ctx.from != 0 && - ::team::proto::decodeTeamPositionMessage(event.payload.data(), - event.payload.size(), - &pos)) - { - const uint32_t timestamp = (pos.ts != 0) ? pos.ts : event.ctx.timestamp; - ::sys::EventBus::publish( - new ::sys::NodePositionUpdateEvent( - event.ctx.from, - pos.lat_e7, - pos.lon_e7, - ::team::proto::teamPositionHasAltitude(pos), - ::team::proto::teamPositionHasAltitude(pos) ? pos.alt_m : 0, - timestamp, - 0, - 0, - 0, - 0, - 0), - 0); - } - - ::sys::EventBus::publish(new ::sys::TeamPositionEvent(event), 0); - } - - void onTeamWaypoint(const ::team::TeamWaypointEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamWaypointEvent(event), 0); - } - - void onTeamTrack(const ::team::TeamTrackEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamTrackEvent(event), 0); - } - - void onTeamChat(const ::team::TeamChatEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamChatEvent(event), 0); - } - - void onTeamError(const ::team::TeamErrorEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamErrorEvent(event), 0); - } -}; - -class LinuxTeamPairingEventQueue final : public ::team::ITeamPairingEventSink -{ - public: - void onTeamPairingStateChanged(const ::team::TeamPairingEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamPairingEvent(event), 0); - } - - void onTeamPairingKeyDist(const ::team::TeamKeyDistEvent& event) override - { - ::sys::EventBus::publish(new ::sys::TeamKeyDistEvent(event), 0); - } -}; - -class LinuxLoopbackTeamPairingTransport final : public ::team::ITeamPairingTransport -{ - public: - bool begin(Receiver& receiver) override - { - receiver_ = &receiver; - pending_packets_.clear(); - synthetic_member_join_sent_ = false; - synthetic_leader_ready_ = false; - return true; - } - - void end() override - { - receiver_ = nullptr; - pending_packets_.clear(); - synthetic_member_join_sent_ = false; - synthetic_leader_ready_ = false; - } - - bool ensurePeer(const uint8_t* mac) override - { - return mac != nullptr; - } - - bool send(const uint8_t* mac, const uint8_t* data, size_t len) override - { - if (!mac || !data || len == 0) - { - return false; - } - - ::team::proto::pairing::MessageType type{}; - if (!::team::proto::pairing::decodeType(data, len, &type)) - { - return false; - } - - if (type == ::team::proto::pairing::MessageType::Beacon) - { - ::team::proto::pairing::BeaconPacket beacon{}; - if (!::team::proto::pairing::decodeBeacon(data, len, &beacon)) - { - return false; - } - if (!synthetic_member_join_sent_) - { - scheduleSyntheticMemberJoin(beacon); - synthetic_member_join_sent_ = true; - } - return true; - } - - if (type == ::team::proto::pairing::MessageType::Join) - { - ::team::proto::pairing::JoinPacket join{}; - if (!::team::proto::pairing::decodeJoin(data, len, &join)) - { - return false; - } - if (synthetic_leader_ready_) - { - scheduleSyntheticLeaderKey(join); - } - return true; - } - - return true; - } - - void scheduleSyntheticLeaderBeacon() - { - synthetic_leader_ready_ = true; - synthetic_leader_team_id_ = makeSyntheticPairTeamId(); - synthetic_leader_key_id_ = kSyntheticPairKeyId; - synthetic_leader_psk_ = makeSyntheticPairPsk(); - synthetic_leader_psk_len_ = static_cast(synthetic_leader_psk_.size()); - - ::team::proto::pairing::BeaconPacket beacon{}; - beacon.team_id = synthetic_leader_team_id_; - beacon.key_id = synthetic_leader_key_id_; - beacon.leader_id = kSyntheticPairLeaderNodeId; - beacon.window_ms = 120000U; - std::strncpy(beacon.team_name, syntheticPairTeamName(), sizeof(beacon.team_name) - 1U); - beacon.team_name[sizeof(beacon.team_name) - 1U] = '\0'; - beacon.has_team_name = true; - schedulePacket(kSyntheticLeaderMac, beacon, kSyntheticPairDelayMs); - } - - void pump() - { - if (receiver_ == nullptr) - { - return; - } - - const uint32_t now_ms = sys::millis_now(); - while (!pending_packets_.empty()) - { - const auto& packet = pending_packets_.front(); - if (packet.due_ms > now_ms) - { - break; - } - receiver_->onPairingReceive(packet.mac.data(), packet.payload.data(), packet.payload.size()); - pending_packets_.pop_front(); - } - } - - private: - struct PendingPacket - { - std::array mac{}; - std::vector payload{}; - uint32_t due_ms = 0; - }; - - void scheduleSyntheticMemberJoin(const ::team::proto::pairing::BeaconPacket& beacon) - { - ::team::proto::pairing::JoinPacket join{}; - join.team_id = beacon.team_id; - join.member_id = kSyntheticPairMemberNodeId; - join.nonce = kSyntheticPairNonce; - schedulePacket(kSyntheticMemberMac, join, kSyntheticPairDelayMs); - } - - void scheduleSyntheticLeaderKey(const ::team::proto::pairing::JoinPacket& join) - { - ::team::proto::pairing::KeyPacket key{}; - key.team_id = synthetic_leader_team_id_; - key.key_id = synthetic_leader_key_id_; - key.nonce = join.nonce; - key.channel_psk = synthetic_leader_psk_; - key.channel_psk_len = synthetic_leader_psk_len_; - schedulePacket(kSyntheticLeaderMac, key, kSyntheticPairDelayMs); - } - - template - void schedulePacket(const std::array& mac, const PacketT& packet, uint32_t delay_ms) - { - std::vector wire; - bool ok = false; - if constexpr (std::is_same_v) - { - ok = ::team::proto::pairing::encodeBeacon(packet, wire); - } - else if constexpr (std::is_same_v) - { - ok = ::team::proto::pairing::encodeJoin(packet, wire); - } - else if constexpr (std::is_same_v) - { - ok = ::team::proto::pairing::encodeKey(packet, wire); - } - - if (!ok) - { - return; - } - - pending_packets_.push_back({ - .mac = mac, - .payload = std::move(wire), - .due_ms = sys::millis_now() + delay_ms, - }); - } - - Receiver* receiver_ = nullptr; - std::deque pending_packets_{}; - bool synthetic_member_join_sent_ = false; - bool synthetic_leader_ready_ = false; - ::team::TeamId synthetic_leader_team_id_{}; - uint32_t synthetic_leader_key_id_ = 0; - std::array synthetic_leader_psk_{}; - uint8_t synthetic_leader_psk_len_ = 0; -}; - -class LinuxLoopbackTeamPairingService final : public ::team::TeamPairingService, - private ::team::ITeamPairingTransport::Receiver -{ - public: - LinuxLoopbackTeamPairingService(::team::ITeamRuntime& runtime, - ::team::ITeamPairingEventSink& event_sink, - LinuxLoopbackTeamPairingTransport& transport) - : transport_(transport), core_(runtime, event_sink, transport) - { - } - - bool startLeader(const ::team::TeamId& team_id, - uint32_t key_id, - const uint8_t* psk, - size_t psk_len, - uint32_t leader_id, - const char* team_name) override - { - if (!ensureTransport()) - { - return false; - } - if (!core_.startLeader(team_id, key_id, psk, psk_len, leader_id, team_name)) - { - shutdownTransport(); - return false; - } - return true; - } - - bool startMember(uint32_t self_id) override - { - if (!ensureTransport()) - { - return false; - } - if (!core_.startMember(self_id)) - { - shutdownTransport(); - return false; - } - transport_.scheduleSyntheticLeaderBeacon(); - return true; - } - - void stop() override - { - core_.stop(); - shutdownTransport(); - } - - void update() override - { - if (!transport_ready_) - { - return; - } - - transport_.pump(); - while (!rx_packets_.empty()) - { - const auto packet = rx_packets_.front(); - rx_packets_.pop_front(); - core_.handleIncomingPacket(packet.mac.data(), packet.payload.data(), packet.payload.size()); - } - - core_.update(); - if (transport_ready_ && core_.getStatus().state == ::team::TeamPairingState::Idle) - { - shutdownTransport(); - } - } - - ::team::TeamPairingStatus getStatus() const override - { - return core_.getStatus(); - } - - private: - struct RxPacket - { - std::array mac{}; - std::vector payload{}; - }; - - bool ensureTransport() - { - if (transport_ready_) - { - return true; - } - if (!transport_.begin(*this)) - { - return false; - } - transport_ready_ = true; - return true; - } - - void shutdownTransport() - { - if (!transport_ready_) - { - return; - } - transport_.end(); - transport_ready_ = false; - rx_packets_.clear(); - } - - void onPairingReceive(const uint8_t* mac, const uint8_t* data, size_t len) override - { - if (!mac || !data || len == 0) - { - return; - } - RxPacket packet{}; - std::copy(mac, mac + 6, packet.mac.begin()); - packet.payload.assign(data, data + len); - rx_packets_.push_back(std::move(packet)); - } - - LinuxLoopbackTeamPairingTransport& transport_; - ::team::TeamPairingCoordinator core_; - bool transport_ready_ = false; - std::deque rx_packets_{}; -}; - -class LinuxNullTrackSource final : public ::team::ITeamTrackSource -{ - public: - bool readTrackPoint(::team::proto::TeamTrackPoint* out_point) override - { - (void)out_point; - return false; - } -}; - -bool dispatchCoreEvent(MinimalLinuxAppFacade& app_facade, ::sys::Event* event) -{ - if (event == nullptr) - { - return true; - } - - switch (event->type) - { - case ::sys::EventType::NodeInfoUpdate: - { - auto* node_event = static_cast<::sys::NodeInfoUpdateEvent*>(event); - ::chat::contacts::NodeUpdate update{}; - update.short_name = node_event->short_name; - update.long_name = node_event->long_name; - update.has_last_seen = true; - update.last_seen = node_event->event_timestamp != 0 ? node_event->event_timestamp : node_event->timestamp; - update.has_snr = true; - update.snr = node_event->snr; - update.has_rssi = true; - update.rssi = node_event->rssi; - update.has_protocol = true; - update.protocol = node_event->protocol; - update.has_role = true; - update.role = node_event->role; - update.has_hops_away = true; - update.hops_away = node_event->hops_away; - update.has_hw_model = true; - update.hw_model = node_event->hw_model; - update.has_channel = true; - update.channel = node_event->channel; - update.has_macaddr = node_event->has_macaddr; - if (node_event->has_macaddr) - { - std::memcpy(update.macaddr, node_event->macaddr, sizeof(update.macaddr)); - } - update.has_via_mqtt = true; - update.via_mqtt = node_event->via_mqtt; - update.has_is_ignored = true; - update.is_ignored = node_event->is_ignored; - update.has_public_key = true; - update.public_key_present = node_event->has_public_key; - update.has_key_manually_verified = true; - update.key_manually_verified = node_event->key_manually_verified; - update.has_device_metrics = node_event->has_device_metrics; - if (node_event->has_device_metrics) - { - update.device_metrics = node_event->device_metrics; - } - app_facade.getContactService().applyNodeUpdate(node_event->node_id, update); - delete event; - return true; - } - case ::sys::EventType::NodeProtocolUpdate: - { - auto* protocol_event = static_cast<::sys::NodeProtocolUpdateEvent*>(event); - app_facade.getContactService().updateNodeProtocol(protocol_event->node_id, - protocol_event->protocol, - protocol_event->event_timestamp != 0 - ? protocol_event->event_timestamp - : protocol_event->timestamp); - delete event; - return true; - } - case ::sys::EventType::NodePositionUpdate: - { - auto* pos_event = static_cast<::sys::NodePositionUpdateEvent*>(event); - ::chat::contacts::NodePosition pos{}; - pos.valid = true; - pos.latitude_i = pos_event->latitude_i; - pos.longitude_i = pos_event->longitude_i; - pos.has_altitude = pos_event->has_altitude; - pos.altitude = pos_event->altitude; - pos.timestamp = pos_event->event_timestamp != 0 ? pos_event->event_timestamp : pos_event->timestamp; - pos.precision_bits = pos_event->precision_bits; - pos.pdop = pos_event->pdop; - pos.hdop = pos_event->hdop; - pos.vdop = pos_event->vdop; - pos.gps_accuracy_mm = pos_event->gps_accuracy_mm; - app_facade.getContactService().updateNodePosition(pos_event->node_id, pos); - delete event; - return true; - } - default: - return false; - } -} - -void applyPairingEventFallback(::chat::contacts::ContactService& contact_service, - ::chat::NodeId self_node_id, - const ::team::TeamPairingEvent& event) -{ - ::team::ui::TeamUiSnapshot snapshot{}; - (void)::team::ui::team_ui_get_store().load(snapshot); - - if (event.has_team_id) - { - snapshot.team_id = event.team_id; - snapshot.has_team_id = true; - if (snapshot.team_name.empty()) - { - snapshot.team_name = syntheticPairTeamName(); - } - } - if (event.has_team_name) - { - snapshot.team_name = event.team_name; - } - - const bool pairing_active = - event.state != ::team::TeamPairingState::Idle && - event.state != ::team::TeamPairingState::Completed && - event.state != ::team::TeamPairingState::Failed; - snapshot.pending_join = pairing_active; - snapshot.pending_join_started_s = pairing_active ? sys::epoch_seconds_now() : 0U; - - auto upsert_member = [&](::chat::NodeId node_id, bool leader) - { - const bool is_self = (node_id == 0 || node_id == self_node_id); - const uint32_t stored_node_id = is_self ? 0U : node_id; - auto it = std::find_if(snapshot.members.begin(), snapshot.members.end(), - [&](const ::team::ui::TeamMemberUi& member) - { - return member.node_id == stored_node_id; - }); - - if (it == snapshot.members.end()) - { - ::team::ui::TeamMemberUi member{}; - member.node_id = stored_node_id; - member.name = is_self ? "You" : contact_service.getContactName(node_id); - if (member.name.empty()) - { - char buffer[16] = {}; - std::snprintf(buffer, sizeof(buffer), "%08lX", static_cast(node_id)); - member.name = buffer; - } - member.leader = leader; - member.last_seen_s = sys::epoch_seconds_now(); - member.color_index = ::team::ui::team_color_index_from_node_id(is_self ? self_node_id : node_id); - snapshot.members.push_back(std::move(member)); - return; - } - - it->leader = leader; - it->last_seen_s = sys::epoch_seconds_now(); - if (it->name.empty()) - { - it->name = is_self ? "You" : contact_service.getContactName(node_id); - } - }; - - if (event.role == ::team::TeamPairingRole::Leader) - { - snapshot.in_team = true; - snapshot.kicked_out = false; - snapshot.self_is_leader = true; - upsert_member(self_node_id, true); - } - - if (event.role == ::team::TeamPairingRole::Leader && - event.peer_id != 0 && - event.state == ::team::TeamPairingState::LeaderBeacon) - { - upsert_member(event.peer_id, false); - snapshot.last_update_s = sys::epoch_seconds_now(); - } - - if (event.state == ::team::TeamPairingState::Completed) - { - snapshot.in_team = true; - snapshot.kicked_out = false; - snapshot.pending_join = false; - snapshot.pending_join_started_s = 0; - snapshot.self_is_leader = (event.role == ::team::TeamPairingRole::Leader); - upsert_member(self_node_id, snapshot.self_is_leader); - } - - if (event.state == ::team::TeamPairingState::Failed) - { - snapshot.pending_join = false; - snapshot.pending_join_started_s = 0; - } - - ::team::ui::team_ui_get_store().save(snapshot); -} - -void applyKeyDistEventFallback(::chat::contacts::ContactService& contact_service, - ::team::TeamController* team_controller, - ::chat::NodeId self_node_id, - const ::team::TeamKeyDistEvent& event) -{ - ::team::ui::TeamUiSnapshot snapshot{}; - (void)::team::ui::team_ui_get_store().load(snapshot); - - snapshot.team_id = event.msg.team_id; - snapshot.has_team_id = true; - snapshot.in_team = true; - snapshot.kicked_out = false; - snapshot.pending_join = false; - snapshot.pending_join_started_s = 0; - snapshot.self_is_leader = false; - snapshot.security_round = event.msg.key_id; - snapshot.last_update_s = event.ctx.timestamp != 0 ? event.ctx.timestamp : sys::epoch_seconds_now(); - snapshot.team_name = syntheticPairTeamName(); - if (event.msg.channel_psk_len > 0) - { - snapshot.team_psk = event.msg.channel_psk; - snapshot.has_team_psk = true; - } - - auto ensure_member = [&](::chat::NodeId node_id, bool leader) - { - const bool is_self = (node_id == 0 || node_id == self_node_id); - const uint32_t stored_node_id = is_self ? 0U : node_id; - auto it = std::find_if(snapshot.members.begin(), snapshot.members.end(), - [&](const ::team::ui::TeamMemberUi& member) - { - return member.node_id == stored_node_id; - }); - if (it == snapshot.members.end()) - { - ::team::ui::TeamMemberUi member{}; - member.node_id = stored_node_id; - member.name = is_self ? "You" : contact_service.getContactName(node_id); - if (member.name.empty()) - { - char buffer[16] = {}; - std::snprintf(buffer, sizeof(buffer), "%08lX", static_cast(node_id)); - member.name = buffer; - } - member.leader = leader; - member.last_seen_s = snapshot.last_update_s; - member.color_index = ::team::ui::team_color_index_from_node_id(is_self ? self_node_id : node_id); - snapshot.members.push_back(std::move(member)); - return; - } - it->leader = leader; - it->last_seen_s = snapshot.last_update_s; - }; - - ensure_member(self_node_id, false); - if (event.ctx.from != 0) - { - ensure_member(event.ctx.from, true); - } - - if (snapshot.has_team_psk) - { - if (team_controller) - { - (void)team_controller->setKeysFromPsk(snapshot.team_id, - snapshot.security_round, - snapshot.team_psk.data(), - snapshot.team_psk.size()); - } - ::team::ui::team_ui_save_keys_now(snapshot.team_id, - snapshot.security_round, - snapshot.team_psk); - } - - ::team::ui::team_ui_get_store().save(snapshot); -} - -bool handleLinuxUiEvent(MinimalLinuxAppFacade& app_facade, ::sys::Event* event) -{ - if (event == nullptr) - { - return true; - } + auto& facade = *static_cast(context); - if (event->type == ::sys::EventType::TeamKick || - event->type == ::sys::EventType::TeamTransferLeader || - event->type == ::sys::EventType::TeamKeyDist || - event->type == ::sys::EventType::TeamStatus || - event->type == ::sys::EventType::TeamPosition || - event->type == ::sys::EventType::TeamWaypoint || - event->type == ::sys::EventType::TeamTrack || - event->type == ::sys::EventType::TeamChat || - event->type == ::sys::EventType::TeamPairing || - event->type == ::sys::EventType::TeamError || - event->type == ::sys::EventType::SystemTick) + if (isTeamUiEvent(*event) && s_team_ui_event_dispatcher != nullptr) { - if (s_team_ui_event_dispatcher) - { - s_team_ui_event_dispatcher(event); - } - else if (event->type == ::sys::EventType::TeamPairing) - { - applyPairingEventFallback(app_facade.getContactService(), - app_facade.getSelfNodeId(), - static_cast<::sys::TeamPairingEvent*>(event)->data); - } - else if (event->type == ::sys::EventType::TeamKeyDist) - { - applyKeyDistEventFallback(app_facade.getContactService(), - app_facade.getTeamController(), - app_facade.getSelfNodeId(), - static_cast<::sys::TeamKeyDistEvent*>(event)->data); - } + (void)s_team_ui_event_dispatcher(event); delete event; return true; } - if (::chat::ui::IChatUiRuntime* chat_ui_runtime = app_facade.getChatUiRuntime()) + if (::chat::ui::IChatUiRuntime* chat_ui_runtime = facade.getChatUiRuntime()) { chat_ui_runtime->onChatEvent(event); return true; } - delete event; - return true; + return false; } } // namespace @@ -1358,185 +77,28 @@ void setTeamUiEventDispatcher(TeamUiEventDispatcher dispatcher) s_team_ui_event_dispatcher = dispatcher; } -struct MinimalLinuxAppFacade::Implementation +MinimalLinuxAppFacade::MinimalLinuxAppFacade() + : services_(::trailmate::linux_app::LinuxAppServicesOptions{ + .default_node_name = "Cardputer Zero", + .default_short_name = "CZ", + .demo_broadcast_text = "Broadcast: Cardputer Zero local mesh online.", + }) { - explicit Implementation(::chat::NodeId self_node_id) - : node_blob_store(), - contact_blob_store(), - node_store(node_blob_store), - contact_store(contact_blob_store), - contact_service(node_store, contact_store), - chat_model(), - chat_store(), - mesh_adapter(self_node_id), - chat_service(chat_model, mesh_adapter, chat_store), - chat_event_bridge(chat_service), - team_runtime(), - team_crypto(), - team_event_sink(), - pairing_event_sink(), - pairing_transport(), - pairing_service(team_runtime, pairing_event_sink, pairing_transport), - track_source(), - team_service(team_crypto, mesh_adapter, team_event_sink, team_runtime), - team_controller(team_service), - team_track_sampler(team_runtime, track_source) - { - } +} - void ensureStarted(::chat::NodeId self_node_id) - { - if (started) - { - mesh_adapter.setSelfNodeId(self_node_id); - return; - } - - mesh_adapter.setSelfNodeId(self_node_id); - node_store.setProtectedNodeChecker( - [self_node_id](uint32_t node_id) - { - return node_id == self_node_id; - }); - contact_service.begin(); - seedDemoWorld(self_node_id); - started = true; - } - - void clearContactAndNodeData() - { - contact_blob_store.clear(); - node_store.clear(); - contact_service.begin(); - demo_seeded = false; - } - - void seedDemoWorld(::chat::NodeId self_node_id) - { - if (demo_seeded) - { - return; - } - - const uint32_t now_secs = sys::epoch_seconds_now(); - const auto seeds = demoPeerSeeds(); - for (size_t i = 0; i < seeds.size(); ++i) - { - const auto& seed = seeds[i]; - contact_service.updateNodeInfo(seed.node_id, - seed.short_name, - seed.long_name, - seed.snr, - seed.rssi, - now_secs > (30U * (i + 1U)) ? now_secs - (30U * static_cast(i + 1U)) : now_secs, - static_cast(::chat::MeshProtocol::Meshtastic)); - - ::chat::contacts::NodePosition pos{}; - pos.valid = true; - pos.latitude_i = seed.lat_e7; - pos.longitude_i = seed.lon_e7; - pos.has_altitude = true; - pos.altitude = 14 + static_cast(i * 3); - pos.timestamp = now_secs; - pos.hdop = 85; - pos.gps_accuracy_mm = 2200; - contact_service.updateNodePosition(seed.node_id, pos); - - if (seed.nickname && seed.nickname[0] != '\0') - { - (void)contact_service.addContact(seed.node_id, seed.nickname); - } - if (seed.ignored) - { - (void)contact_service.setNodeIgnored(seed.node_id, true); - } - } - - (void)contact_service.setNodeKeyManuallyVerified(kDemoAlphaNodeId, true); - - mesh_adapter.queueIncomingText({ - .channel = ::chat::ChannelId::PRIMARY, - .from = kDemoAlphaNodeId, - .to = self_node_id, - .msg_id = 1001U, - .timestamp = now_secs, - .text = "Alice: local link ready.", - }); - mesh_adapter.queueIncomingText({ - .channel = ::chat::ChannelId::PRIMARY, - .from = kDemoBravoNodeId, - .to = self_node_id, - .msg_id = 1002U, - .timestamp = now_secs, - .text = "Bravo: route package synced.", - }); - mesh_adapter.queueIncomingText({ - .channel = ::chat::ChannelId::PRIMARY, - .from = kDemoBroadcastNodeId, - .to = 0, - .msg_id = 1003U, - .timestamp = now_secs, - .text = "Broadcast: Cardputer Zero local mesh online.", - }); - demo_seeded = true; - } - - LinuxNodeBlobStore node_blob_store; - LinuxContactBlobStore contact_blob_store; - ::chat::contacts::NodeStoreCore node_store; - ::chat::contacts::ContactStoreCore contact_store; - ::chat::contacts::ContactService contact_service; - ::chat::ChatModel chat_model; - ::chat::RamStore chat_store; - LinuxLoopbackMeshAdapter mesh_adapter; - ::chat::ChatService chat_service; - LinuxChatEventBusBridge chat_event_bridge; - LinuxTeamRuntime team_runtime; - LinuxTeamCrypto team_crypto; - LinuxTeamEventBusSink team_event_sink; - LinuxTeamPairingEventQueue pairing_event_sink; - LinuxLoopbackTeamPairingTransport pairing_transport; - LinuxLoopbackTeamPairingService pairing_service; - LinuxNullTrackSource track_source; - ::team::TeamService team_service; - ::team::TeamController team_controller; - ::team::TeamTrackSampler team_track_sampler; - bool started = false; - bool demo_seeded = false; -}; - -MinimalLinuxAppFacade::MinimalLinuxAppFacade() = default; MinimalLinuxAppFacade::~MinimalLinuxAppFacade() = default; -MinimalLinuxAppFacade::Implementation& MinimalLinuxAppFacade::impl() -{ - ensureServicesReady(); - return *impl_; -} - -const MinimalLinuxAppFacade::Implementation& MinimalLinuxAppFacade::impl() const -{ - return *impl_; -} - bool MinimalLinuxAppFacade::initialize() { - if (initialized_) + services_.setUiEventDispatcher(handleFacadeUiEvent, this); + if (!services_.initialize()) { - if (!::app::hasAppFacade()) - { - ::app::bindAppFacade(*this); - } - return true; + return false; + } + if (!::app::hasAppFacade()) + { + ::app::bindAppFacade(*this); } - - loadPersistedConfig(); - seedDefaultIdentity(); - (void)::sys::EventBus::init(); - ensureServicesReady(); - syncLocalIdentity(); - ::app::bindAppFacade(*this); - initialized_ = true; return true; } @@ -1546,76 +108,63 @@ void MinimalLinuxAppFacade::shutdown() { ::app::unbindAppFacade(); } - initialized_ = false; + services_.setUiEventDispatcher(nullptr, nullptr); + services_.shutdown(); } bool MinimalLinuxAppFacade::is_initialized() const noexcept { - return initialized_; + return services_.isInitialized(); } ::app::AppConfig& MinimalLinuxAppFacade::getConfig() { - return config_; + return services_.getConfig(); } const ::app::AppConfig& MinimalLinuxAppFacade::getConfig() const { - return config_; + return services_.getConfig(); } void MinimalLinuxAppFacade::saveConfig() { - const PersistedConfigBlob blob{.magic = kConfigBlobMagic, .version = kConfigBlobVersion, .config = config_}; - (void)::platform::ui::settings_store::put_blob( - kConfigNamespace, kConfigBlobKey, &blob, sizeof(blob)); + services_.saveConfig(); } void MinimalLinuxAppFacade::applyMeshConfig() { - ensureServicesReady(); - ::chat::MeshConfig mesh_config{}; - impl().mesh_adapter.applyConfig(mesh_config); - impl().chat_service.setActiveProtocol(config_.mesh_protocol); + services_.applyMeshConfig(); } void MinimalLinuxAppFacade::applyUserInfo() { - seedDefaultIdentity(); - syncLocalIdentity(); + services_.applyUserInfo(); } void MinimalLinuxAppFacade::applyPositionConfig() { - platform::ui::gps::set_enabled(config_.gps_enabled); - platform::ui::gps::set_collection_interval(config_.gps_interval_ms); - platform::ui::gps::set_power_strategy(config_.gps_strategy); - platform::ui::gps::set_gnss_config(config_.gps_mode, config_.gps_sat_mask); - platform::ui::gps::set_external_nmea_config(config_.external_nmea_output_hz, - config_.external_nmea_sentence_mask); - platform::ui::gps::set_motion_idle_timeout(config_.motion_config.idle_timeout_ms); - platform::ui::gps::set_motion_sensor_id(config_.motion_config.sensor_id); + services_.applyPositionConfig(); } void MinimalLinuxAppFacade::applyNetworkLimits() { + services_.applyNetworkLimits(); } void MinimalLinuxAppFacade::applyPrivacyConfig() { + services_.applyPrivacyConfig(); } void MinimalLinuxAppFacade::applyChatDefaults() { - if (config_.chat_channel > 1U) - { - config_.chat_channel = 0U; - } + services_.applyChatDefaults(); } ::chat::MeshProtocol MinimalLinuxAppFacade::getMeshProtocol() const { - return config_.mesh_protocol; + return services_.getMeshProtocol(); } void MinimalLinuxAppFacade::getEffectiveUserInfo(char* out_long, @@ -1623,102 +172,84 @@ void MinimalLinuxAppFacade::getEffectiveUserInfo(char* out_long, char* out_short, std::size_t short_len) const { - copy_bounded(out_long, long_len, config_.node_name); - copy_bounded(out_short, short_len, config_.short_name); + copy_bounded(out_long, long_len, services_.getConfig().node_name); + copy_bounded(out_short, short_len, services_.getConfig().short_name); } -bool MinimalLinuxAppFacade::switchMeshProtocol(::chat::MeshProtocol protocol, bool persist) +bool MinimalLinuxAppFacade::switchMeshProtocol(::chat::MeshProtocol protocol, + bool persist) { - if (!is_supported_protocol(protocol)) - { - return false; - } - - config_.mesh_protocol = protocol; - if (impl_) - { - impl_->chat_service.setActiveProtocol(protocol); - } - if (persist) - { - saveConfig(); - } - return true; + return services_.switchMeshProtocol(protocol, persist); } ::chat::ChatService& MinimalLinuxAppFacade::getChatService() { - return impl().chat_service; + return services_.getChatService(); } ::chat::contacts::ContactService& MinimalLinuxAppFacade::getContactService() { - return impl().contact_service; + return services_.getContactService(); } ::chat::IMeshAdapter* MinimalLinuxAppFacade::getMeshAdapter() { - return &impl().mesh_adapter; + return services_.getMeshAdapter(); } const ::chat::IMeshAdapter* MinimalLinuxAppFacade::getMeshAdapter() const { - return &impl().mesh_adapter; + return services_.getMeshAdapter(); } ::chat::NodeId MinimalLinuxAppFacade::getSelfNodeId() const { - return 0x435A0001U; + return services_.getSelfNodeId(); } ::team::TeamController* MinimalLinuxAppFacade::getTeamController() { - return &impl().team_controller; + return services_.getTeamController(); } ::team::TeamPairingService* MinimalLinuxAppFacade::getTeamPairing() { - return &impl().pairing_service; + return services_.getTeamPairing(); } ::team::TeamService* MinimalLinuxAppFacade::getTeamService() { - return &impl().team_service; + return services_.getTeamService(); } const ::team::TeamService* MinimalLinuxAppFacade::getTeamService() const { - return &impl().team_service; + return services_.getTeamService(); } ::team::TeamTrackSampler* MinimalLinuxAppFacade::getTeamTrackSampler() { - return &impl().team_track_sampler; + return services_.getTeamTrackSampler(); } void MinimalLinuxAppFacade::setTeamModeActive(bool active) { - (void)active; + services_.setTeamModeActive(active); } void MinimalLinuxAppFacade::broadcastNodeInfo() { - syncLocalIdentity(); + services_.broadcastNodeInfo(); } void MinimalLinuxAppFacade::clearNodeDb() { - ensureServicesReady(); - impl().clearContactAndNodeData(); - syncLocalIdentity(); + services_.clearNodeDb(); } void MinimalLinuxAppFacade::clearMessageDb() { - ensureServicesReady(); - impl().chat_model.clearAll(); - impl().chat_store.clearAll(); - ::team::ui::team_ui_get_store().clear(); + services_.clearMessageDb(); } ::ble::BleManager* MinimalLinuxAppFacade::getBleManager() @@ -1733,18 +264,17 @@ const ::ble::BleManager* MinimalLinuxAppFacade::getBleManager() const bool MinimalLinuxAppFacade::isBleEnabled() const { - return config_.ble_enabled; + return services_.isBleEnabled(); } void MinimalLinuxAppFacade::setBleEnabled(bool enabled) { - config_.ble_enabled = enabled; - saveConfig(); + services_.setBleEnabled(enabled); } void MinimalLinuxAppFacade::restartDevice() { - ::platform::ui::device::restart(); + services_.restartDevice(); } ::chat::ui::IChatUiRuntime* MinimalLinuxAppFacade::getChatUiRuntime() @@ -1769,134 +299,17 @@ const ::BoardBase* MinimalLinuxAppFacade::getBoard() const void MinimalLinuxAppFacade::updateCoreServices() { - if (!impl_) - { - return; - } - - ::team::ui::TeamUiSnapshot snap; - const bool has_team = ::team::ui::team_ui_get_store().load(snap) && snap.in_team; - impl_->team_track_sampler.update(&impl_->team_controller, has_team); + services_.updateCoreServices(); } void MinimalLinuxAppFacade::tickEventRuntime() { - if (!impl_) - { - return; - } - - impl_->pairing_service.update(); - ::sys::EventBus::publish(new ::sys::Event(::sys::EventType::SystemTick), 0); + services_.tickEventRuntime(); } void MinimalLinuxAppFacade::dispatchPendingEvents(std::size_t max_events) { - if (!impl_) - { - return; - } - - impl_->mesh_adapter.tick(); - impl_->chat_service.processIncoming(); - impl_->team_service.processIncoming(); - - while (true) - { - ::chat::MessageId msg_id = 0; - bool ok = false; - if (!impl_->mesh_adapter.takePendingSendResult(msg_id, ok)) - { - break; - } - impl_->chat_service.handleSendResult(msg_id, ok); - ::sys::EventBus::publish(new ::sys::ChatSendResultEvent(msg_id, ok), 0); - } - - std::size_t processed = 0; - ::sys::Event* event = nullptr; - while (processed < max_events && ::sys::EventBus::subscribe(&event, 0)) - { - if (event == nullptr) - { - continue; - } - ++processed; - - if (dispatchCoreEvent(*this, event)) - { - continue; - } - - if (handleLinuxUiEvent(*this, event)) - { - continue; - } - - delete event; - } -} - -void MinimalLinuxAppFacade::loadPersistedConfig() -{ - std::vector blob_bytes; - if (!::platform::ui::settings_store::get_blob(kConfigNamespace, kConfigBlobKey, blob_bytes) || - blob_bytes.size() != sizeof(PersistedConfigBlob)) - { - config_ = ::app::AppConfig{}; - return; - } - - PersistedConfigBlob blob{}; - std::memcpy(&blob, blob_bytes.data(), sizeof(blob)); - if (blob.magic != kConfigBlobMagic || blob.version != kConfigBlobVersion || - !is_supported_protocol(blob.config.mesh_protocol)) - { - config_ = ::app::AppConfig{}; - return; - } - - config_ = blob.config; -} - -void MinimalLinuxAppFacade::seedDefaultIdentity() -{ - if (config_.node_name[0] == '\0') - { - copy_bounded(config_.node_name, sizeof(config_.node_name), "Cardputer Zero"); - } - if (config_.short_name[0] == '\0') - { - copy_bounded(config_.short_name, sizeof(config_.short_name), "CZ"); - } - applyChatDefaults(); -} - -void MinimalLinuxAppFacade::syncLocalIdentity() -{ - ensureServicesReady(); - impl().mesh_adapter.setUserInfo(config_.node_name, config_.short_name); - impl().chat_service.setActiveProtocol(config_.mesh_protocol); - impl().contact_service.updateNodeInfo(getSelfNodeId(), - config_.short_name, - config_.node_name, - 0.0f, - 0.0f, - sys::epoch_seconds_now(), - static_cast(config_.mesh_protocol)); - ::chat::contacts::NodeUpdate self_update{}; - self_update.has_is_ignored = true; - self_update.is_ignored = true; - impl().contact_service.applyNodeUpdate(getSelfNodeId(), self_update); -} - -void MinimalLinuxAppFacade::ensureServicesReady() -{ - if (!impl_) - { - impl_ = std::make_unique(getSelfNodeId()); - } - impl_->ensureStarted(getSelfNodeId()); + services_.dispatchPendingEvents(max_events); } } // namespace trailmate::cardputer_zero::linux_ui diff --git a/platform/linux/common/src/app/linux_app_services.cpp b/platform/linux/common/src/app/linux_app_services.cpp new file mode 100644 index 00000000..ee2e35b6 --- /dev/null +++ b/platform/linux/common/src/app/linux_app_services.cpp @@ -0,0 +1,2083 @@ +#include "app/linux_app_services.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "chat/domain/chat_model.h" +#include "chat/infra/contact_store_core.h" +#include "chat/infra/node_store_core.h" +#include "chat/linux_noop_mesh_adapter.h" +#include "chat/linux_raw_lora_mesh_adapter.h" +#include "chat/linux_sqlite_chat_store.h" +#include "chat/ports/i_contact_blob_store.h" +#include "chat/ports/i_node_blob_store.h" +#include "chat/usecase/chat_service.h" +#include "chat/usecase/contact_service.h" +#include "platform/ui/device_runtime.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/team_ui_store_runtime.h" +#include "sys/clock.h" +#include "sys/event_bus.h" +#include "team/ports/i_team_crypto.h" +#include "team/ports/i_team_event_sink.h" +#include "team/ports/i_team_pairing_event_sink.h" +#include "team/ports/i_team_pairing_transport.h" +#include "team/ports/i_team_runtime.h" +#include "team/ports/i_team_track_source.h" +#include "team/protocol/team_pairing_wire.h" +#include "team/protocol/team_position.h" +#include "team/usecase/team_controller.h" +#include "team/usecase/team_pairing_coordinator.h" +#include "team/usecase/team_pairing_service.h" +#include "team/usecase/team_service.h" +#include "team/usecase/team_track_sampler.h" + +namespace trailmate::linux_app +{ +namespace +{ + +constexpr const char* kConfigNamespace = "linux_app_facade"; +constexpr const char* kConfigBlobKey = "app_config_v1"; +constexpr uint32_t kConfigBlobMagic = 0x544D4346U; // TMCF +constexpr uint32_t kConfigBlobVersion = 2U; + +constexpr const char* kNodeStoreNamespace = "linux_contact_nodes"; +constexpr const char* kNodeStoreKey = "nodes_v1"; +constexpr const char* kContactStoreNamespace = "linux_contact_names"; +constexpr const char* kContactStoreKey = "contacts_v1"; + +constexpr ::chat::NodeId kDemoAlphaNodeId = 0x435A1001U; +constexpr ::chat::NodeId kDemoBravoNodeId = 0x435A1002U; +constexpr ::chat::NodeId kDemoScoutNodeId = 0x435A1003U; +constexpr ::chat::NodeId kDemoNearbyNodeId = 0x435A1004U; +constexpr ::chat::NodeId kDemoBroadcastNodeId = kDemoAlphaNodeId; +constexpr ::chat::NodeId kSyntheticPairLeaderNodeId = kDemoAlphaNodeId; +constexpr ::chat::NodeId kSyntheticPairMemberNodeId = kDemoBravoNodeId; + +constexpr std::array kSyntheticLeaderMac{{0x43, 0x5A, 0x20, 0x01, 0x00, 0x01}}; +constexpr std::array kSyntheticMemberMac{{0x43, 0x5A, 0x20, 0x01, 0x00, 0x02}}; + +constexpr uint32_t kSyntheticPairKeyId = 1U; +constexpr uint32_t kSyntheticPairNonce = 0x5A431122U; +constexpr uint32_t kSyntheticPairDelayMs = 220U; +constexpr uint32_t kAutoReplyDelayMs = 700U; + +team::TeamId makeSyntheticPairTeamId() +{ + team::TeamId team_id{}; + const std::array bytes{{'C', 'Z', 'T', 'E', 'A', 'M', '0', '1'}}; + for (size_t i = 0; i < team_id.size() && i < bytes.size(); ++i) + { + team_id[i] = bytes[i]; + } + return team_id; +} + +std::array makeSyntheticPairPsk() +{ + std::array psk{}; + for (size_t i = 0; i < psk.size(); ++i) + { + psk[i] = static_cast(0x30U + (i * 7U + 3U) % 0x4FU); + } + return psk; +} + +const char* syntheticPairTeamName() +{ + return "Field Team"; +} + +std::string makeAutoReplyText(::chat::NodeId peer, const std::string& text) +{ + const std::string trimmed = text.substr(0, std::min(text.size(), 28U)); + switch (peer) + { + case kDemoAlphaNodeId: + return "Alice copied: " + trimmed; + case kDemoBravoNodeId: + return "Bravo link OK: " + trimmed; + case kDemoScoutNodeId: + return "Scout received your ping."; + default: + return "Peer ack: " + trimmed; + } +} + +struct DemoPeerSeed +{ + ::chat::NodeId node_id = 0; + const char* short_name = nullptr; + const char* long_name = nullptr; + const char* nickname = nullptr; + bool ignored = false; + float snr = 0.0f; + float rssi = 0.0f; + int32_t lat_e7 = 0; + int32_t lon_e7 = 0; +}; + +std::array demoPeerSeeds() +{ + return {{ + {kDemoAlphaNodeId, "ALFA", "Alice Local", "Alice", false, 11.2f, -72.0f, 311214000, 1214737000}, + {kDemoBravoNodeId, "BRAV", "Bravo Pager", "Bravo", false, 8.6f, -79.0f, 311218500, 1214749000}, + {kDemoScoutNodeId, "SCOT", "Scout Relay", nullptr, true, 4.1f, -94.0f, 311227000, 1214762000}, + {kDemoNearbyNodeId, "NBY1", "Nearby Relay", nullptr, false, 6.8f, -88.0f, 311231200, 1214756000}, + }}; +} + +struct PersistedConfigBlob +{ + uint32_t magic = kConfigBlobMagic; + uint32_t version = kConfigBlobVersion; + ::app::AppConfig config{}; +}; + +static_assert(std::is_trivially_copyable_v<::app::AppConfig>, + "LinuxAppServices persists AppConfig as an opaque blob."); + +void copy_bounded(char* out, std::size_t out_len, const char* text) +{ + if (out == nullptr || out_len == 0) + { + return; + } + + if (text == nullptr) + { + out[0] = '\0'; + return; + } + + std::strncpy(out, text, out_len - 1U); + out[out_len - 1U] = '\0'; +} + +bool is_supported_protocol(::chat::MeshProtocol protocol) +{ + switch (protocol) + { + case ::chat::MeshProtocol::Meshtastic: + case ::chat::MeshProtocol::MeshCore: + case ::chat::MeshProtocol::RNode: + case ::chat::MeshProtocol::LXMF: + return true; + default: + return false; + } +} + +bool raw_lora_enabled_for_mode(::platform::linux_runtime::LinuxRuntimeMode mode) +{ + if (const char* value = std::getenv("TRAIL_MATE_LORA_DISABLE")) + { + if (std::strcmp(value, "1") == 0 || std::strcmp(value, "true") == 0 || + std::strcmp(value, "TRUE") == 0) + { + return false; + } + } + + if (const char* value = std::getenv("TRAIL_MATE_LORA_ADAPTER")) + { + if (std::strcmp(value, "raw") == 0 || + std::strcmp(value, "sx1262") == 0) + { + return true; + } + if (std::strcmp(value, "noop") == 0 || + std::strcmp(value, "off") == 0) + { + return false; + } + } + + return mode == ::platform::linux_runtime::LinuxRuntimeMode::DeviceRealMesh || + LinuxRawLoraMeshAdapter::hardwareCandidatePresent(); +} + +const ::chat::MeshConfig& mesh_config_for_protocol( + const ::app::AppConfig& config) +{ + switch (config.mesh_protocol) + { + case ::chat::MeshProtocol::MeshCore: + return config.meshcore_config; + case ::chat::MeshProtocol::RNode: + return config.rnode_config; + case ::chat::MeshProtocol::LXMF: + case ::chat::MeshProtocol::Meshtastic: + default: + return config.meshtastic_config; + } +} + +class LinuxNodeBlobStore final : public ::chat::contacts::INodeBlobStore +{ + public: + bool loadBlob(std::vector& out) override + { + return ::platform::ui::settings_store::get_blob(kNodeStoreNamespace, kNodeStoreKey, out); + } + + bool saveBlob(const uint8_t* data, size_t len) override + { + return ::platform::ui::settings_store::put_blob(kNodeStoreNamespace, kNodeStoreKey, data, len); + } + + void clearBlob() override + { + ::platform::ui::settings_store::clear_namespace(kNodeStoreNamespace); + } +}; + +class LinuxContactBlobStore final : public ::chat::IContactBlobStore +{ + public: + bool loadBlob(std::vector& out) override + { + return ::platform::ui::settings_store::get_blob(kContactStoreNamespace, kContactStoreKey, out); + } + + bool saveBlob(const uint8_t* data, size_t len) override + { + return ::platform::ui::settings_store::put_blob(kContactStoreNamespace, kContactStoreKey, data, len); + } + + void clear() + { + ::platform::ui::settings_store::clear_namespace(kContactStoreNamespace); + } +}; + +class LinuxLoopbackMeshAdapter final : public ::chat::IMeshAdapter +{ + public: + explicit LinuxLoopbackMeshAdapter(::chat::NodeId self_node_id) : self_node_id_(self_node_id) {} + + ::chat::MeshCapabilities getCapabilities() const override + { + return { + .supports_unicast_text = true, + .supports_unicast_appdata = true, + .supports_broadcast_appdata = true, + .supports_appdata_ack = true, + .provides_appdata_sender = true, + .supports_node_info = true, + .supports_pki = true, + .supports_discovery_actions = true, + }; + } + + bool sendText(::chat::ChannelId channel, const std::string& text, + ::chat::MessageId* out_msg_id, ::chat::NodeId peer = 0) override + { + return sendTextWithId(channel, text, 0, out_msg_id, peer); + } + + bool sendTextWithId(::chat::ChannelId channel, const std::string& text, + ::chat::MessageId forced_msg_id, + ::chat::MessageId* out_msg_id, ::chat::NodeId peer = 0) override + { + if (text.empty()) + { + return false; + } + + const ::chat::MessageId msg_id = (forced_msg_id != 0) ? forced_msg_id : nextMessageId(); + if (out_msg_id) + { + *out_msg_id = msg_id; + } + + pending_send_results_.push_back({msg_id, true}); + + if (peer != 0 && peer == self_node_id_) + { + ::chat::MeshIncomingText loopback{}; + loopback.channel = channel; + loopback.from = self_node_id_; + loopback.to = self_node_id_; + loopback.msg_id = msg_id; + loopback.timestamp = sys::epoch_seconds_now(); + loopback.text = text; + incoming_texts_.push_back(loopback); + } + else if (peer != 0) + { + scheduleIncomingText({ + .channel = channel, + .from = peer, + .to = self_node_id_, + .msg_id = nextMessageId(), + .timestamp = sys::epoch_seconds_now(), + .text = makeAutoReplyText(peer, text), + .hop_limit = 0xFF, + .encrypted = false, + .rx_meta = {}, + }, + kAutoReplyDelayMs); + } + else + { + scheduleIncomingText({ + .channel = channel, + .from = kDemoBroadcastNodeId, + .to = 0, + .msg_id = nextMessageId(), + .timestamp = sys::epoch_seconds_now(), + .text = "Broadcast heard: " + text.substr(0, std::min(text.size(), 32U)), + .hop_limit = 0xFF, + .encrypted = false, + .rx_meta = {}, + }, + kAutoReplyDelayMs + 120U); + } + + return true; + } + + bool pollIncomingText(::chat::MeshIncomingText* out) override + { + if (out == nullptr || incoming_texts_.empty()) + { + return false; + } + + *out = incoming_texts_.front(); + incoming_texts_.erase(incoming_texts_.begin()); + return true; + } + + bool sendAppData(::chat::ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + ::chat::NodeId dest = 0, bool want_ack = false, + ::chat::MessageId packet_id = 0, + bool want_response = false) override + { + (void)channel; + (void)portnum; + (void)payload; + (void)len; + (void)dest; + (void)want_ack; + (void)packet_id; + (void)want_response; + return true; + } + + bool pollIncomingData(::chat::MeshIncomingData* out) override + { + if (out == nullptr || incoming_data_.empty()) + { + return false; + } + + *out = incoming_data_.front(); + incoming_data_.erase(incoming_data_.begin()); + return true; + } + + bool requestNodeInfo(::chat::NodeId dest, bool want_response) override + { + (void)dest; + (void)want_response; + return true; + } + + bool startKeyVerification(::chat::NodeId dest) override + { + (void)dest; + return true; + } + + bool submitKeyVerificationNumber(::chat::NodeId dest, uint64_t nonce, uint32_t number) override + { + (void)dest; + (void)nonce; + (void)number; + return true; + } + + ::chat::NodeId getNodeId() const override + { + return self_node_id_; + } + + bool isPkiReady() const override + { + return true; + } + + bool hasPkiKey(::chat::NodeId dest) const override + { + return dest != 0; + } + + bool triggerDiscoveryAction(::chat::MeshDiscoveryAction action) override + { + (void)action; + return true; + } + + void applyConfig(const ::chat::MeshConfig& config) override + { + config_ = config; + } + + void setUserInfo(const char* long_name, const char* short_name) override + { + long_name_ = long_name ? long_name : ""; + short_name_ = short_name ? short_name : ""; + } + + bool isReady() const override + { + return true; + } + + bool pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size_t max_len) override + { + (void)out_data; + (void)out_len; + (void)max_len; + return false; + } + + void setSelfNodeId(::chat::NodeId node_id) + { + self_node_id_ = node_id; + } + + void queueIncomingText(const ::chat::MeshIncomingText& msg) + { + incoming_texts_.push_back(msg); + } + + void queueIncomingData(const ::chat::MeshIncomingData& msg) + { + incoming_data_.push_back(msg); + } + + void tick() + { + const uint32_t now_ms = sys::millis_now(); + while (!pending_incoming_texts_.empty()) + { + const auto& pending = pending_incoming_texts_.front(); + if (pending.due_ms > now_ms) + { + break; + } + incoming_texts_.push_back(pending.message); + pending_incoming_texts_.pop_front(); + } + } + + bool takePendingSendResult(::chat::MessageId& out_msg_id, bool& out_ok) + { + if (pending_send_results_.empty()) + { + return false; + } + + const auto result = pending_send_results_.front(); + pending_send_results_.erase(pending_send_results_.begin()); + out_msg_id = result.first; + out_ok = result.second; + return true; + } + + private: + struct DelayedIncomingText + { + uint32_t due_ms = 0; + ::chat::MeshIncomingText message{}; + }; + + ::chat::MessageId nextMessageId() + { + if (next_message_id_ == 0) + { + next_message_id_ = 1; + } + return next_message_id_++; + } + + void scheduleIncomingText(::chat::MeshIncomingText msg, uint32_t delay_ms) + { + msg.timestamp = sys::epoch_seconds_now(); + pending_incoming_texts_.push_back({ + .due_ms = sys::millis_now() + delay_ms, + .message = std::move(msg), + }); + } + + ::chat::NodeId self_node_id_ = 0; + ::chat::MessageId next_message_id_ = 1; + ::chat::MeshConfig config_{}; + std::string long_name_{}; + std::string short_name_{}; + std::vector<::chat::MeshIncomingText> incoming_texts_{}; + std::vector<::chat::MeshIncomingData> incoming_data_{}; + std::vector> pending_send_results_{}; + std::deque pending_incoming_texts_{}; +}; + +class LinuxTeamRuntime final : public ::team::ITeamRuntime +{ + public: + uint32_t nowMillis() override + { + return sys::millis_now(); + } + + uint32_t nowUnixSeconds() override + { + return sys::epoch_seconds_now(); + } + + void fillRandomBytes(uint8_t* out, size_t len) override + { + if (out == nullptr || len == 0) + { + return; + } + + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution dist(0, 255); + for (size_t i = 0; i < len; ++i) + { + out[i] = static_cast(dist(gen)); + } + } +}; + +class LinuxTeamCrypto final : public ::team::ITeamCrypto +{ + public: + bool deriveKey(const uint8_t* key, size_t key_len, + const char* info, + uint8_t* out, size_t out_len) override + { + if (key == nullptr || key_len == 0 || out == nullptr || out_len == 0) + { + return false; + } + + uint32_t state = 2166136261u; + for (size_t i = 0; i < key_len; ++i) + { + state ^= key[i]; + state *= 16777619u; + } + if (info) + { + for (const char* p = info; *p != '\0'; ++p) + { + state ^= static_cast(*p); + state *= 16777619u; + } + } + + for (size_t i = 0; i < out_len; ++i) + { + state ^= static_cast(i + 1U); + state *= 16777619u; + out[i] = static_cast((state >> ((i % 4U) * 8U)) & 0xFFU); + } + return true; + } + + bool aeadEncrypt(const uint8_t* key, size_t key_len, + const uint8_t* nonce, size_t nonce_len, + const uint8_t* aad, size_t aad_len, + const uint8_t* plain, size_t plain_len, + std::vector& out_cipher) override + { + return xorCipher(key, key_len, nonce, nonce_len, aad, aad_len, plain, plain_len, out_cipher); + } + + bool aeadDecrypt(const uint8_t* key, size_t key_len, + const uint8_t* nonce, size_t nonce_len, + const uint8_t* aad, size_t aad_len, + const uint8_t* cipher, size_t cipher_len, + std::vector& out_plain) override + { + return xorCipher(key, key_len, nonce, nonce_len, aad, aad_len, cipher, cipher_len, out_plain); + } + + private: + static bool xorCipher(const uint8_t* key, size_t key_len, + const uint8_t* nonce, size_t nonce_len, + const uint8_t* aad, size_t aad_len, + const uint8_t* input, size_t input_len, + std::vector& output) + { + if (key == nullptr || key_len == 0 || input == nullptr) + { + return false; + } + + uint32_t state = 0x9E3779B9u; + for (size_t i = 0; i < key_len; ++i) + { + state = (state * 33u) ^ key[i]; + } + for (size_t i = 0; i < nonce_len; ++i) + { + state = (state * 33u) ^ nonce[i]; + } + for (size_t i = 0; i < aad_len; ++i) + { + state = (state * 33u) ^ aad[i]; + } + + output.resize(input_len); + for (size_t i = 0; i < input_len; ++i) + { + state = state * 1664525u + 1013904223u; + output[i] = static_cast(input[i] ^ ((state >> 24U) & 0xFFU)); + } + return true; + } +}; + +class LinuxChatEventBusBridge final : public ::chat::ChatService::IncomingMessageObserver +{ + public: + explicit LinuxChatEventBusBridge(::chat::ChatService& service) : service_(service) + { + service_.addIncomingMessageObserver(this); + } + + ~LinuxChatEventBusBridge() override + { + service_.removeIncomingMessageObserver(this); + } + + void onIncomingMessage(const ::chat::ChatMessage& msg, const ::chat::RxMeta* rx_meta) override + { + ::sys::EventBus::publish( + new ::sys::ChatNewMessageEvent(static_cast(msg.channel), + msg.msg_id, + msg.text.c_str(), + rx_meta), + 0); + } + + private: + ::chat::ChatService& service_; +}; + +class LinuxTeamEventBusSink final : public ::team::ITeamEventSink +{ + public: + void onTeamKick(const ::team::TeamKickEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamKickEvent(event), 0); + } + + void onTeamTransferLeader(const ::team::TeamTransferLeaderEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamTransferLeaderEvent(event), 0); + } + + void onTeamKeyDist(const ::team::TeamKeyDistEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamKeyDistEvent(event), 0); + } + + void onTeamStatus(const ::team::TeamStatusEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamStatusEvent(event), 0); + } + + void onTeamPosition(const ::team::TeamPositionEvent& event) override + { + ::team::proto::TeamPositionMessage pos{}; + if (event.ctx.from != 0 && + ::team::proto::decodeTeamPositionMessage(event.payload.data(), + event.payload.size(), + &pos)) + { + const uint32_t timestamp = (pos.ts != 0) ? pos.ts : event.ctx.timestamp; + ::sys::EventBus::publish( + new ::sys::NodePositionUpdateEvent( + event.ctx.from, + pos.lat_e7, + pos.lon_e7, + ::team::proto::teamPositionHasAltitude(pos), + ::team::proto::teamPositionHasAltitude(pos) ? pos.alt_m : 0, + timestamp, + 0, + 0, + 0, + 0, + 0), + 0); + } + + ::sys::EventBus::publish(new ::sys::TeamPositionEvent(event), 0); + } + + void onTeamWaypoint(const ::team::TeamWaypointEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamWaypointEvent(event), 0); + } + + void onTeamTrack(const ::team::TeamTrackEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamTrackEvent(event), 0); + } + + void onTeamChat(const ::team::TeamChatEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamChatEvent(event), 0); + } + + void onTeamError(const ::team::TeamErrorEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamErrorEvent(event), 0); + } +}; + +class LinuxTeamPairingEventQueue final : public ::team::ITeamPairingEventSink +{ + public: + void onTeamPairingStateChanged(const ::team::TeamPairingEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamPairingEvent(event), 0); + } + + void onTeamPairingKeyDist(const ::team::TeamKeyDistEvent& event) override + { + ::sys::EventBus::publish(new ::sys::TeamKeyDistEvent(event), 0); + } +}; + +class LinuxLoopbackTeamPairingTransport final : public ::team::ITeamPairingTransport +{ + public: + bool begin(Receiver& receiver) override + { + receiver_ = &receiver; + pending_packets_.clear(); + synthetic_member_join_sent_ = false; + synthetic_leader_ready_ = false; + return true; + } + + void end() override + { + receiver_ = nullptr; + pending_packets_.clear(); + synthetic_member_join_sent_ = false; + synthetic_leader_ready_ = false; + } + + bool ensurePeer(const uint8_t* mac) override + { + return mac != nullptr; + } + + bool send(const uint8_t* mac, const uint8_t* data, size_t len) override + { + if (!mac || !data || len == 0) + { + return false; + } + + ::team::proto::pairing::MessageType type{}; + if (!::team::proto::pairing::decodeType(data, len, &type)) + { + return false; + } + + if (type == ::team::proto::pairing::MessageType::Beacon) + { + ::team::proto::pairing::BeaconPacket beacon{}; + if (!::team::proto::pairing::decodeBeacon(data, len, &beacon)) + { + return false; + } + if (!synthetic_member_join_sent_) + { + scheduleSyntheticMemberJoin(beacon); + synthetic_member_join_sent_ = true; + } + return true; + } + + if (type == ::team::proto::pairing::MessageType::Join) + { + ::team::proto::pairing::JoinPacket join{}; + if (!::team::proto::pairing::decodeJoin(data, len, &join)) + { + return false; + } + if (synthetic_leader_ready_) + { + scheduleSyntheticLeaderKey(join); + } + return true; + } + + return true; + } + + void scheduleSyntheticLeaderBeacon() + { + synthetic_leader_ready_ = true; + synthetic_leader_team_id_ = makeSyntheticPairTeamId(); + synthetic_leader_key_id_ = kSyntheticPairKeyId; + synthetic_leader_psk_ = makeSyntheticPairPsk(); + synthetic_leader_psk_len_ = static_cast(synthetic_leader_psk_.size()); + + ::team::proto::pairing::BeaconPacket beacon{}; + beacon.team_id = synthetic_leader_team_id_; + beacon.key_id = synthetic_leader_key_id_; + beacon.leader_id = kSyntheticPairLeaderNodeId; + beacon.window_ms = 120000U; + std::strncpy(beacon.team_name, syntheticPairTeamName(), sizeof(beacon.team_name) - 1U); + beacon.team_name[sizeof(beacon.team_name) - 1U] = '\0'; + beacon.has_team_name = true; + schedulePacket(kSyntheticLeaderMac, beacon, kSyntheticPairDelayMs); + } + + void pump() + { + if (receiver_ == nullptr) + { + return; + } + + const uint32_t now_ms = sys::millis_now(); + while (!pending_packets_.empty()) + { + const auto& packet = pending_packets_.front(); + if (packet.due_ms > now_ms) + { + break; + } + receiver_->onPairingReceive(packet.mac.data(), packet.payload.data(), packet.payload.size()); + pending_packets_.pop_front(); + } + } + + private: + struct PendingPacket + { + std::array mac{}; + std::vector payload{}; + uint32_t due_ms = 0; + }; + + void scheduleSyntheticMemberJoin(const ::team::proto::pairing::BeaconPacket& beacon) + { + ::team::proto::pairing::JoinPacket join{}; + join.team_id = beacon.team_id; + join.member_id = kSyntheticPairMemberNodeId; + join.nonce = kSyntheticPairNonce; + schedulePacket(kSyntheticMemberMac, join, kSyntheticPairDelayMs); + } + + void scheduleSyntheticLeaderKey(const ::team::proto::pairing::JoinPacket& join) + { + ::team::proto::pairing::KeyPacket key{}; + key.team_id = synthetic_leader_team_id_; + key.key_id = synthetic_leader_key_id_; + key.nonce = join.nonce; + key.channel_psk = synthetic_leader_psk_; + key.channel_psk_len = synthetic_leader_psk_len_; + schedulePacket(kSyntheticLeaderMac, key, kSyntheticPairDelayMs); + } + + template + void schedulePacket(const std::array& mac, const PacketT& packet, uint32_t delay_ms) + { + std::vector wire; + bool ok = false; + if constexpr (std::is_same_v) + { + ok = ::team::proto::pairing::encodeBeacon(packet, wire); + } + else if constexpr (std::is_same_v) + { + ok = ::team::proto::pairing::encodeJoin(packet, wire); + } + else if constexpr (std::is_same_v) + { + ok = ::team::proto::pairing::encodeKey(packet, wire); + } + + if (!ok) + { + return; + } + + pending_packets_.push_back({ + .mac = mac, + .payload = std::move(wire), + .due_ms = sys::millis_now() + delay_ms, + }); + } + + Receiver* receiver_ = nullptr; + std::deque pending_packets_{}; + bool synthetic_member_join_sent_ = false; + bool synthetic_leader_ready_ = false; + ::team::TeamId synthetic_leader_team_id_{}; + uint32_t synthetic_leader_key_id_ = 0; + std::array synthetic_leader_psk_{}; + uint8_t synthetic_leader_psk_len_ = 0; +}; + +class LinuxLoopbackTeamPairingService final : public ::team::TeamPairingService, + private ::team::ITeamPairingTransport::Receiver +{ + public: + LinuxLoopbackTeamPairingService(::team::ITeamRuntime& runtime, + ::team::ITeamPairingEventSink& event_sink, + LinuxLoopbackTeamPairingTransport& transport) + : transport_(transport), core_(runtime, event_sink, transport) + { + } + + bool startLeader(const ::team::TeamId& team_id, + uint32_t key_id, + const uint8_t* psk, + size_t psk_len, + uint32_t leader_id, + const char* team_name) override + { + if (!ensureTransport()) + { + return false; + } + if (!core_.startLeader(team_id, key_id, psk, psk_len, leader_id, team_name)) + { + shutdownTransport(); + return false; + } + return true; + } + + bool startMember(uint32_t self_id) override + { + if (!ensureTransport()) + { + return false; + } + if (!core_.startMember(self_id)) + { + shutdownTransport(); + return false; + } + transport_.scheduleSyntheticLeaderBeacon(); + return true; + } + + void stop() override + { + core_.stop(); + shutdownTransport(); + } + + void update() override + { + if (!transport_ready_) + { + return; + } + + transport_.pump(); + while (!rx_packets_.empty()) + { + const auto packet = rx_packets_.front(); + rx_packets_.pop_front(); + core_.handleIncomingPacket(packet.mac.data(), packet.payload.data(), packet.payload.size()); + } + + core_.update(); + if (transport_ready_ && core_.getStatus().state == ::team::TeamPairingState::Idle) + { + shutdownTransport(); + } + } + + ::team::TeamPairingStatus getStatus() const override + { + return core_.getStatus(); + } + + private: + struct RxPacket + { + std::array mac{}; + std::vector payload{}; + }; + + bool ensureTransport() + { + if (transport_ready_) + { + return true; + } + if (!transport_.begin(*this)) + { + return false; + } + transport_ready_ = true; + return true; + } + + void shutdownTransport() + { + if (!transport_ready_) + { + return; + } + transport_.end(); + transport_ready_ = false; + rx_packets_.clear(); + } + + void onPairingReceive(const uint8_t* mac, const uint8_t* data, size_t len) override + { + if (!mac || !data || len == 0) + { + return; + } + RxPacket packet{}; + std::copy(mac, mac + 6, packet.mac.begin()); + packet.payload.assign(data, data + len); + rx_packets_.push_back(std::move(packet)); + } + + LinuxLoopbackTeamPairingTransport& transport_; + ::team::TeamPairingCoordinator core_; + bool transport_ready_ = false; + std::deque rx_packets_{}; +}; + +class LinuxNullTrackSource final : public ::team::ITeamTrackSource +{ + public: + bool readTrackPoint(::team::proto::TeamTrackPoint* out_point) override + { + (void)out_point; + return false; + } +}; + +bool dispatchCoreEvent(LinuxAppServices& services, ::sys::Event* event) +{ + if (event == nullptr) + { + return true; + } + + switch (event->type) + { + case ::sys::EventType::NodeInfoUpdate: + { + auto* node_event = static_cast<::sys::NodeInfoUpdateEvent*>(event); + ::chat::contacts::NodeUpdate update{}; + update.short_name = node_event->short_name; + update.long_name = node_event->long_name; + update.has_last_seen = true; + update.last_seen = node_event->event_timestamp != 0 ? node_event->event_timestamp : node_event->timestamp; + update.has_snr = true; + update.snr = node_event->snr; + update.has_rssi = true; + update.rssi = node_event->rssi; + update.has_protocol = true; + update.protocol = node_event->protocol; + update.has_role = true; + update.role = node_event->role; + update.has_hops_away = true; + update.hops_away = node_event->hops_away; + update.has_hw_model = true; + update.hw_model = node_event->hw_model; + update.has_channel = true; + update.channel = node_event->channel; + update.has_macaddr = node_event->has_macaddr; + if (node_event->has_macaddr) + { + std::memcpy(update.macaddr, node_event->macaddr, sizeof(update.macaddr)); + } + update.has_via_mqtt = true; + update.via_mqtt = node_event->via_mqtt; + update.has_is_ignored = true; + update.is_ignored = node_event->is_ignored; + update.has_public_key = true; + update.public_key_present = node_event->has_public_key; + update.has_key_manually_verified = true; + update.key_manually_verified = node_event->key_manually_verified; + update.has_device_metrics = node_event->has_device_metrics; + if (node_event->has_device_metrics) + { + update.device_metrics = node_event->device_metrics; + } + services.getContactService().applyNodeUpdate(node_event->node_id, update); + delete event; + return true; + } + case ::sys::EventType::NodeProtocolUpdate: + { + auto* protocol_event = static_cast<::sys::NodeProtocolUpdateEvent*>(event); + services.getContactService().updateNodeProtocol(protocol_event->node_id, + protocol_event->protocol, + protocol_event->event_timestamp != 0 + ? protocol_event->event_timestamp + : protocol_event->timestamp); + delete event; + return true; + } + case ::sys::EventType::NodePositionUpdate: + { + auto* pos_event = static_cast<::sys::NodePositionUpdateEvent*>(event); + ::chat::contacts::NodePosition pos{}; + pos.valid = true; + pos.latitude_i = pos_event->latitude_i; + pos.longitude_i = pos_event->longitude_i; + pos.has_altitude = pos_event->has_altitude; + pos.altitude = pos_event->altitude; + pos.timestamp = pos_event->event_timestamp != 0 ? pos_event->event_timestamp : pos_event->timestamp; + pos.precision_bits = pos_event->precision_bits; + pos.pdop = pos_event->pdop; + pos.hdop = pos_event->hdop; + pos.vdop = pos_event->vdop; + pos.gps_accuracy_mm = pos_event->gps_accuracy_mm; + services.getContactService().updateNodePosition(pos_event->node_id, pos); + delete event; + return true; + } + default: + return false; + } +} + +void applyPairingEventFallback(::chat::contacts::ContactService& contact_service, + ::chat::NodeId self_node_id, + const ::team::TeamPairingEvent& event) +{ + ::team::ui::TeamUiSnapshot snapshot{}; + (void)::team::ui::team_ui_get_store().load(snapshot); + + if (event.has_team_id) + { + snapshot.team_id = event.team_id; + snapshot.has_team_id = true; + if (snapshot.team_name.empty()) + { + snapshot.team_name = syntheticPairTeamName(); + } + } + if (event.has_team_name) + { + snapshot.team_name = event.team_name; + } + + const bool pairing_active = + event.state != ::team::TeamPairingState::Idle && + event.state != ::team::TeamPairingState::Completed && + event.state != ::team::TeamPairingState::Failed; + snapshot.pending_join = pairing_active; + snapshot.pending_join_started_s = pairing_active ? sys::epoch_seconds_now() : 0U; + + auto upsert_member = [&](::chat::NodeId node_id, bool leader) + { + const bool is_self = (node_id == 0 || node_id == self_node_id); + const uint32_t stored_node_id = is_self ? 0U : node_id; + auto it = std::find_if(snapshot.members.begin(), snapshot.members.end(), + [&](const ::team::ui::TeamMemberUi& member) + { + return member.node_id == stored_node_id; + }); + + if (it == snapshot.members.end()) + { + ::team::ui::TeamMemberUi member{}; + member.node_id = stored_node_id; + member.name = is_self ? "You" : contact_service.getContactName(node_id); + if (member.name.empty()) + { + char buffer[16] = {}; + std::snprintf(buffer, sizeof(buffer), "%08lX", static_cast(node_id)); + member.name = buffer; + } + member.leader = leader; + member.last_seen_s = sys::epoch_seconds_now(); + member.color_index = ::team::ui::team_color_index_from_node_id(is_self ? self_node_id : node_id); + snapshot.members.push_back(std::move(member)); + return; + } + + it->leader = leader; + it->last_seen_s = sys::epoch_seconds_now(); + if (it->name.empty()) + { + it->name = is_self ? "You" : contact_service.getContactName(node_id); + } + }; + + if (event.role == ::team::TeamPairingRole::Leader) + { + snapshot.in_team = true; + snapshot.kicked_out = false; + snapshot.self_is_leader = true; + upsert_member(self_node_id, true); + } + + if (event.role == ::team::TeamPairingRole::Leader && + event.peer_id != 0 && + event.state == ::team::TeamPairingState::LeaderBeacon) + { + upsert_member(event.peer_id, false); + snapshot.last_update_s = sys::epoch_seconds_now(); + } + + if (event.state == ::team::TeamPairingState::Completed) + { + snapshot.in_team = true; + snapshot.kicked_out = false; + snapshot.pending_join = false; + snapshot.pending_join_started_s = 0; + snapshot.self_is_leader = (event.role == ::team::TeamPairingRole::Leader); + upsert_member(self_node_id, snapshot.self_is_leader); + } + + if (event.state == ::team::TeamPairingState::Failed) + { + snapshot.pending_join = false; + snapshot.pending_join_started_s = 0; + } + + ::team::ui::team_ui_get_store().save(snapshot); +} + +void applyKeyDistEventFallback(::chat::contacts::ContactService& contact_service, + ::team::TeamController* team_controller, + ::chat::NodeId self_node_id, + const ::team::TeamKeyDistEvent& event) +{ + ::team::ui::TeamUiSnapshot snapshot{}; + (void)::team::ui::team_ui_get_store().load(snapshot); + + snapshot.team_id = event.msg.team_id; + snapshot.has_team_id = true; + snapshot.in_team = true; + snapshot.kicked_out = false; + snapshot.pending_join = false; + snapshot.pending_join_started_s = 0; + snapshot.self_is_leader = false; + snapshot.security_round = event.msg.key_id; + snapshot.last_update_s = event.ctx.timestamp != 0 ? event.ctx.timestamp : sys::epoch_seconds_now(); + snapshot.team_name = syntheticPairTeamName(); + if (event.msg.channel_psk_len > 0) + { + snapshot.team_psk = event.msg.channel_psk; + snapshot.has_team_psk = true; + } + + auto ensure_member = [&](::chat::NodeId node_id, bool leader) + { + const bool is_self = (node_id == 0 || node_id == self_node_id); + const uint32_t stored_node_id = is_self ? 0U : node_id; + auto it = std::find_if(snapshot.members.begin(), snapshot.members.end(), + [&](const ::team::ui::TeamMemberUi& member) + { + return member.node_id == stored_node_id; + }); + if (it == snapshot.members.end()) + { + ::team::ui::TeamMemberUi member{}; + member.node_id = stored_node_id; + member.name = is_self ? "You" : contact_service.getContactName(node_id); + if (member.name.empty()) + { + char buffer[16] = {}; + std::snprintf(buffer, sizeof(buffer), "%08lX", static_cast(node_id)); + member.name = buffer; + } + member.leader = leader; + member.last_seen_s = snapshot.last_update_s; + member.color_index = ::team::ui::team_color_index_from_node_id(is_self ? self_node_id : node_id); + snapshot.members.push_back(std::move(member)); + return; + } + it->leader = leader; + it->last_seen_s = snapshot.last_update_s; + }; + + ensure_member(self_node_id, false); + if (event.ctx.from != 0) + { + ensure_member(event.ctx.from, true); + } + + if (snapshot.has_team_psk) + { + if (team_controller) + { + (void)team_controller->setKeysFromPsk(snapshot.team_id, + snapshot.security_round, + snapshot.team_psk.data(), + snapshot.team_psk.size()); + } + ::team::ui::team_ui_save_keys_now(snapshot.team_id, + snapshot.security_round, + snapshot.team_psk); + } + + ::team::ui::team_ui_get_store().save(snapshot); +} + +bool isTeamUiEvent(const ::sys::Event& event) +{ + return event.type == ::sys::EventType::TeamKick || + event.type == ::sys::EventType::TeamTransferLeader || + event.type == ::sys::EventType::TeamKeyDist || + event.type == ::sys::EventType::TeamStatus || + event.type == ::sys::EventType::TeamPosition || + event.type == ::sys::EventType::TeamWaypoint || + event.type == ::sys::EventType::TeamTrack || + event.type == ::sys::EventType::TeamChat || + event.type == ::sys::EventType::TeamPairing || + event.type == ::sys::EventType::TeamError || + event.type == ::sys::EventType::SystemTick; +} + +bool handleLinuxUiEvent(LinuxAppServices& services, ::sys::Event* event) +{ + if (event == nullptr) + { + return true; + } + + if (services.dispatchUiEvent(event)) + { + return true; + } + + if (isTeamUiEvent(*event)) + { + if (event->type == ::sys::EventType::TeamPairing) + { + applyPairingEventFallback(services.getContactService(), + services.getSelfNodeId(), + static_cast<::sys::TeamPairingEvent*>(event)->data); + } + else if (event->type == ::sys::EventType::TeamKeyDist) + { + applyKeyDistEventFallback(services.getContactService(), + services.getTeamController(), + services.getSelfNodeId(), + static_cast<::sys::TeamKeyDistEvent*>(event)->data); + } + delete event; + return true; + } + + delete event; + return true; +} + +} // namespace + +struct LinuxAppServices::Implementation +{ + explicit Implementation(LinuxAppServicesOptions options_in, ::chat::NodeId self_node_id) + : options(options_in), + demo_world_enabled( + ::platform::linux_runtime::demo_world_enabled(options.runtime_mode)), + node_blob_store(), + contact_blob_store(), + node_store(node_blob_store), + contact_store(contact_blob_store), + contact_service(node_store, contact_store), + chat_model(), + chat_store(), + raw_lora_enabled(raw_lora_enabled_for_mode(options.runtime_mode)), + noop_mesh_adapter(), + raw_lora_mesh_adapter(self_node_id), + loopback_mesh_adapter(self_node_id), + mesh_adapter(demo_world_enabled + ? static_cast<::chat::IMeshAdapter&>( + loopback_mesh_adapter) + : raw_lora_enabled + ? static_cast<::chat::IMeshAdapter&>( + raw_lora_mesh_adapter) + : static_cast<::chat::IMeshAdapter&>( + noop_mesh_adapter)), + chat_service(chat_model, mesh_adapter, chat_store), + chat_event_bridge(chat_service), + team_runtime(), + team_crypto(), + team_event_sink(), + pairing_event_sink(), + pairing_transport(), + pairing_service(team_runtime, pairing_event_sink, pairing_transport), + track_source(), + team_service(team_crypto, mesh_adapter, team_event_sink, team_runtime), + team_controller(team_service), + team_track_sampler(team_runtime, track_source) + { + } + + void ensureStarted(::chat::NodeId self_node_id) + { + if (started) + { + noop_mesh_adapter.setSelfNodeId(self_node_id); + raw_lora_mesh_adapter.setSelfNodeId(self_node_id); + loopback_mesh_adapter.setSelfNodeId(self_node_id); + return; + } + + noop_mesh_adapter.setSelfNodeId(self_node_id); + raw_lora_mesh_adapter.setSelfNodeId(self_node_id); + loopback_mesh_adapter.setSelfNodeId(self_node_id); + node_store.setProtectedNodeChecker( + [self_node_id](uint32_t node_id) + { + return node_id == self_node_id; + }); + contact_service.begin(); + if (demo_world_enabled) + { + seedDemoWorld(self_node_id); + } + started = true; + } + + void clearContactAndNodeData() + { + contact_blob_store.clear(); + node_store.clear(); + contact_service.begin(); + demo_seeded = false; + } + + void seedDemoWorld(::chat::NodeId self_node_id) + { + if (!demo_world_enabled || demo_seeded) + { + return; + } + + const uint32_t now_secs = sys::epoch_seconds_now(); + const auto seeds = demoPeerSeeds(); + for (size_t i = 0; i < seeds.size(); ++i) + { + const auto& seed = seeds[i]; + contact_service.updateNodeInfo(seed.node_id, + seed.short_name, + seed.long_name, + seed.snr, + seed.rssi, + now_secs > (30U * (i + 1U)) ? now_secs - (30U * static_cast(i + 1U)) : now_secs, + static_cast(::chat::MeshProtocol::Meshtastic)); + + ::chat::contacts::NodePosition pos{}; + pos.valid = true; + pos.latitude_i = seed.lat_e7; + pos.longitude_i = seed.lon_e7; + pos.has_altitude = true; + pos.altitude = 14 + static_cast(i * 3); + pos.timestamp = now_secs; + pos.hdop = 85; + pos.gps_accuracy_mm = 2200; + contact_service.updateNodePosition(seed.node_id, pos); + + if (seed.nickname && seed.nickname[0] != '\0') + { + (void)contact_service.addContact(seed.node_id, seed.nickname); + } + if (seed.ignored) + { + (void)contact_service.setNodeIgnored(seed.node_id, true); + } + } + + (void)contact_service.setNodeKeyManuallyVerified(kDemoAlphaNodeId, true); + + loopback_mesh_adapter.queueIncomingText({ + .channel = ::chat::ChannelId::PRIMARY, + .from = kDemoAlphaNodeId, + .to = self_node_id, + .msg_id = 1001U, + .timestamp = now_secs, + .text = "Alice: local link ready.", + .hop_limit = 0xFF, + .encrypted = false, + .rx_meta = {}, + }); + loopback_mesh_adapter.queueIncomingText({ + .channel = ::chat::ChannelId::PRIMARY, + .from = kDemoBravoNodeId, + .to = self_node_id, + .msg_id = 1002U, + .timestamp = now_secs, + .text = "Bravo: route package synced.", + .hop_limit = 0xFF, + .encrypted = false, + .rx_meta = {}, + }); + loopback_mesh_adapter.queueIncomingText({ + .channel = ::chat::ChannelId::PRIMARY, + .from = kDemoBroadcastNodeId, + .to = 0, + .msg_id = 1003U, + .timestamp = now_secs, + .text = options.demo_broadcast_text ? options.demo_broadcast_text + : "Broadcast: Linux local mesh online.", + .hop_limit = 0xFF, + .encrypted = false, + .rx_meta = {}, + }); + demo_seeded = true; + } + + void tickMeshAdapter() + { + if (demo_world_enabled) + { + loopback_mesh_adapter.tick(); + return; + } + if (raw_lora_enabled) + { + raw_lora_mesh_adapter.tick(); + return; + } + noop_mesh_adapter.tick(); + } + + bool takePendingSendResult(::chat::MessageId& out_msg_id, bool& out_ok) + { + if (demo_world_enabled) + { + return loopback_mesh_adapter.takePendingSendResult(out_msg_id, + out_ok); + } + if (raw_lora_enabled) + { + return raw_lora_mesh_adapter.takePendingSendResult(out_msg_id, + out_ok); + } + return noop_mesh_adapter.takePendingSendResult(out_msg_id, out_ok); + } + + LinuxAppServicesOptions options{}; + bool demo_world_enabled = false; + LinuxNodeBlobStore node_blob_store; + LinuxContactBlobStore contact_blob_store; + ::chat::contacts::NodeStoreCore node_store; + ::chat::contacts::ContactStoreCore contact_store; + ::chat::contacts::ContactService contact_service; + ::chat::ChatModel chat_model; + LinuxSqliteChatStore chat_store; + bool raw_lora_enabled = false; + ::trailmate::cardputer_zero::linux_ui::LinuxNoopMeshAdapter noop_mesh_adapter; + LinuxRawLoraMeshAdapter raw_lora_mesh_adapter; + LinuxLoopbackMeshAdapter loopback_mesh_adapter; + ::chat::IMeshAdapter& mesh_adapter; + ::chat::ChatService chat_service; + LinuxChatEventBusBridge chat_event_bridge; + LinuxTeamRuntime team_runtime; + LinuxTeamCrypto team_crypto; + LinuxTeamEventBusSink team_event_sink; + LinuxTeamPairingEventQueue pairing_event_sink; + LinuxLoopbackTeamPairingTransport pairing_transport; + LinuxLoopbackTeamPairingService pairing_service; + LinuxNullTrackSource track_source; + ::team::TeamService team_service; + ::team::TeamController team_controller; + ::team::TeamTrackSampler team_track_sampler; + bool started = false; + bool demo_seeded = false; +}; + +LinuxAppServices::LinuxAppServices(LinuxAppServicesOptions options) + : options_(options) +{ +} + +LinuxAppServices::~LinuxAppServices() +{ + shutdown(); +} + +LinuxAppServices::Implementation& LinuxAppServices::impl() +{ + ensureServicesReady(); + return *impl_; +} + +const LinuxAppServices::Implementation& LinuxAppServices::impl() const +{ + return *impl_; +} + +bool LinuxAppServices::initialize() +{ + if (initialized_) + { + return true; + } + + loadPersistedConfig(); + seedDefaultIdentity(); + (void)::sys::EventBus::init(); + ensureServicesReady(); + syncLocalIdentity(); + applyMeshConfig(); + applyPositionConfig(); + applyChatDefaults(); + initialized_ = true; + return true; +} + +void LinuxAppServices::shutdown() +{ + initialized_ = false; +} + +bool LinuxAppServices::isInitialized() const noexcept +{ + return initialized_; +} + +void LinuxAppServices::setUiEventDispatcher(UiEventDispatcher dispatcher, + void* context) noexcept +{ + ui_event_dispatcher_ = dispatcher; + ui_event_context_ = context; +} + +bool LinuxAppServices::dispatchUiEvent(::sys::Event* event) +{ + if (ui_event_dispatcher_ == nullptr) + { + return false; + } + return ui_event_dispatcher_(ui_event_context_, event); +} + +void LinuxAppServices::tick(std::size_t max_events) +{ + if (!initialized_) return; + updateCoreServices(); + tickEventRuntime(); + dispatchPendingEvents(max_events); +} + +::app::AppConfig& LinuxAppServices::config() +{ + return getConfig(); +} + +const ::app::AppConfig& LinuxAppServices::config() const +{ + return getConfig(); +} + +::app::AppConfig& LinuxAppServices::getConfig() +{ + return config_; +} + +const ::app::AppConfig& LinuxAppServices::getConfig() const +{ + return config_; +} + +void LinuxAppServices::saveConfig() +{ + const PersistedConfigBlob blob{.magic = kConfigBlobMagic, + .version = kConfigBlobVersion, + .config = config_}; + (void)::platform::ui::settings_store::put_blob( + kConfigNamespace, kConfigBlobKey, &blob, sizeof(blob)); +} + +void LinuxAppServices::applyMeshConfig() +{ + ensureServicesReady(); + if (impl().raw_lora_enabled && !impl().demo_world_enabled) + { + impl().raw_lora_mesh_adapter.applyProtocolConfig( + config_.mesh_protocol, mesh_config_for_protocol(config_)); + } + else + { + impl().mesh_adapter.applyConfig(mesh_config_for_protocol(config_)); + } + impl().chat_service.setActiveProtocol(config_.mesh_protocol); +} + +void LinuxAppServices::applyUserInfo() +{ + seedDefaultIdentity(); + syncLocalIdentity(); +} + +void LinuxAppServices::applyPositionConfig() +{ + platform::ui::gps::set_enabled(config_.gps_enabled); + platform::ui::gps::set_collection_interval(config_.gps_interval_ms); + platform::ui::gps::set_power_strategy(config_.gps_strategy); + platform::ui::gps::set_gnss_config(config_.gps_mode, config_.gps_sat_mask); + platform::ui::gps::set_external_nmea_config(config_.external_nmea_output_hz, + config_.external_nmea_sentence_mask); + platform::ui::gps::set_motion_idle_timeout(config_.motion_config.idle_timeout_ms); + platform::ui::gps::set_motion_sensor_id(config_.motion_config.sensor_id); +} + +void LinuxAppServices::applyNetworkLimits() +{ +} + +void LinuxAppServices::applyPrivacyConfig() +{ +} + +void LinuxAppServices::applyChatDefaults() +{ + if (config_.chat_channel > 1U) + { + config_.chat_channel = 0U; + } + if (impl_) + { + impl_->chat_model.setPolicy(config_.chat_policy); + } +} + +::chat::MeshProtocol LinuxAppServices::meshProtocol() const +{ + return getMeshProtocol(); +} + +::chat::MeshProtocol LinuxAppServices::getMeshProtocol() const +{ + return config_.mesh_protocol; +} + +bool LinuxAppServices::switchMeshProtocol(::chat::MeshProtocol protocol, + bool persist) +{ + if (!is_supported_protocol(protocol)) + { + return false; + } + + config_.mesh_protocol = protocol; + if (impl_) + { + impl_->chat_service.setActiveProtocol(protocol); + } + if (persist) + { + saveConfig(); + } + return true; +} + +::chat::ChatService& LinuxAppServices::chat() +{ + return getChatService(); +} + +::chat::ChatService& LinuxAppServices::getChatService() +{ + return impl().chat_service; +} + +::chat::contacts::ContactService& LinuxAppServices::contacts() +{ + return getContactService(); +} + +::chat::contacts::ContactService& LinuxAppServices::getContactService() +{ + return impl().contact_service; +} + +::chat::IMeshAdapter* LinuxAppServices::meshAdapter() +{ + return getMeshAdapter(); +} + +::chat::IMeshAdapter* LinuxAppServices::getMeshAdapter() +{ + return &impl().mesh_adapter; +} + +const ::chat::IMeshAdapter* LinuxAppServices::meshAdapter() const +{ + return getMeshAdapter(); +} + +const ::chat::IMeshAdapter* LinuxAppServices::getMeshAdapter() const +{ + return &impl().mesh_adapter; +} + +::chat::NodeId LinuxAppServices::selfNodeId() const +{ + return getSelfNodeId(); +} + +::chat::NodeId LinuxAppServices::getSelfNodeId() const +{ + return 0x435A0001U; +} + +::team::TeamController* LinuxAppServices::teamController() +{ + return getTeamController(); +} + +::team::TeamController* LinuxAppServices::getTeamController() +{ + return &impl().team_controller; +} + +::team::TeamPairingService* LinuxAppServices::teamPairing() +{ + return getTeamPairing(); +} + +::team::TeamPairingService* LinuxAppServices::getTeamPairing() +{ + return &impl().pairing_service; +} + +::team::TeamService* LinuxAppServices::teamService() +{ + return getTeamService(); +} + +::team::TeamService* LinuxAppServices::getTeamService() +{ + return &impl().team_service; +} + +const ::team::TeamService* LinuxAppServices::teamService() const +{ + return getTeamService(); +} + +const ::team::TeamService* LinuxAppServices::getTeamService() const +{ + return &impl().team_service; +} + +::team::TeamTrackSampler* LinuxAppServices::teamTrackSampler() +{ + return getTeamTrackSampler(); +} + +::team::TeamTrackSampler* LinuxAppServices::getTeamTrackSampler() +{ + return &impl().team_track_sampler; +} + +void LinuxAppServices::setTeamModeActive(bool active) +{ + (void)active; +} + +void LinuxAppServices::broadcastNodeInfo() +{ + syncLocalIdentity(); +} + +void LinuxAppServices::clearNodeDb() +{ + ensureServicesReady(); + impl().clearContactAndNodeData(); + syncLocalIdentity(); +} + +void LinuxAppServices::clearMessageDb() +{ + ensureServicesReady(); + impl().chat_model.clearAll(); + impl().chat_store.clearAll(); + ::team::ui::team_ui_get_store().clear(); +} + +bool LinuxAppServices::isBleEnabled() const +{ + return false; +} + +void LinuxAppServices::setBleEnabled(bool enabled) +{ + (void)enabled; + config_.ble_enabled = false; + saveConfig(); +} + +void LinuxAppServices::restartDevice() +{ + ::platform::ui::device::restart(); +} + +void LinuxAppServices::updateCoreServices() +{ + if (!impl_) + { + return; + } + + ::team::ui::TeamUiSnapshot snap; + const bool has_team = ::team::ui::team_ui_get_store().load(snap) && snap.in_team; + impl_->team_track_sampler.update(&impl_->team_controller, has_team); +} + +void LinuxAppServices::tickEventRuntime() +{ + if (!impl_) + { + return; + } + + impl_->pairing_service.update(); + ::sys::EventBus::publish(new ::sys::Event(::sys::EventType::SystemTick), 0); +} + +void LinuxAppServices::dispatchPendingEvents(std::size_t max_events) +{ + if (!impl_) + { + return; + } + + impl_->tickMeshAdapter(); + impl_->chat_service.processIncoming(); + impl_->team_service.processIncoming(); + + while (true) + { + ::chat::MessageId msg_id = 0; + bool ok = false; + if (!impl_->takePendingSendResult(msg_id, ok)) + { + break; + } + impl_->chat_service.handleSendResult(msg_id, ok); + ::sys::EventBus::publish(new ::sys::ChatSendResultEvent(msg_id, ok), 0); + } + + std::size_t processed = 0; + ::sys::Event* event = nullptr; + while (processed < max_events && ::sys::EventBus::subscribe(&event, 0)) + { + if (event == nullptr) + { + continue; + } + ++processed; + + if (dispatchCoreEvent(*this, event)) + { + continue; + } + + if (handleLinuxUiEvent(*this, event)) + { + continue; + } + + delete event; + } +} + +void LinuxAppServices::loadPersistedConfig() +{ + std::vector blob_bytes; + if (!::platform::ui::settings_store::get_blob(kConfigNamespace, kConfigBlobKey, blob_bytes) || + blob_bytes.size() != sizeof(PersistedConfigBlob)) + { + config_ = ::app::AppConfig{}; + config_.ble_enabled = false; + return; + } + + PersistedConfigBlob blob{}; + std::memcpy(&blob, blob_bytes.data(), sizeof(blob)); + if (blob.magic != kConfigBlobMagic || blob.version != kConfigBlobVersion || + !is_supported_protocol(blob.config.mesh_protocol)) + { + config_ = ::app::AppConfig{}; + config_.ble_enabled = false; + return; + } + + config_ = blob.config; + config_.ble_enabled = false; +} + +void LinuxAppServices::seedDefaultIdentity() +{ + if (config_.node_name[0] == '\0') + { + copy_bounded(config_.node_name, sizeof(config_.node_name), options_.default_node_name); + } + if (config_.short_name[0] == '\0') + { + copy_bounded(config_.short_name, sizeof(config_.short_name), options_.default_short_name); + } + config_.ble_enabled = false; + applyChatDefaults(); +} + +void LinuxAppServices::syncLocalIdentity() +{ + ensureServicesReady(); + impl().mesh_adapter.setUserInfo(config_.node_name, config_.short_name); + impl().chat_service.setActiveProtocol(config_.mesh_protocol); + impl().contact_service.updateNodeInfo(getSelfNodeId(), + config_.short_name, + config_.node_name, + 0.0f, + 0.0f, + sys::epoch_seconds_now(), + static_cast(config_.mesh_protocol)); + ::chat::contacts::NodeUpdate self_update{}; + self_update.has_is_ignored = true; + self_update.is_ignored = true; + impl().contact_service.applyNodeUpdate(getSelfNodeId(), self_update); +} + +void LinuxAppServices::ensureServicesReady() +{ + if (!impl_) + { + impl_ = std::make_unique(options_, getSelfNodeId()); + } + impl_->ensureStarted(getSelfNodeId()); +} + +} // namespace trailmate::linux_app diff --git a/platform/linux/common/src/chat/linux_noop_mesh_adapter.cpp b/platform/linux/common/src/chat/linux_noop_mesh_adapter.cpp index 9f9a6247..cbb70202 100644 --- a/platform/linux/common/src/chat/linux_noop_mesh_adapter.cpp +++ b/platform/linux/common/src/chat/linux_noop_mesh_adapter.cpp @@ -56,7 +56,7 @@ bool LinuxNoopMeshAdapter::submitKeyVerificationNumber(::chat::NodeId, ::chat::NodeId LinuxNoopMeshAdapter::getNodeId() const { - return 0; + return self_id_; } bool LinuxNoopMeshAdapter::isPkiReady() const diff --git a/platform/linux/common/src/chat/linux_raw_lora_mesh_adapter.cpp b/platform/linux/common/src/chat/linux_raw_lora_mesh_adapter.cpp new file mode 100644 index 00000000..274093c2 --- /dev/null +++ b/platform/linux/common/src/chat/linux_raw_lora_mesh_adapter.cpp @@ -0,0 +1,2235 @@ +#include "chat/linux_raw_lora_mesh_adapter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "chat/infra/mesh_protocol_utils.h" +#include "chat/infra/meshtastic/mt_codec_pb.h" +#include "chat/infra/meshtastic/mt_node_payload.h" +#include "chat/infra/meshtastic/mt_packet_wire.h" +#include "chat/infra/meshtastic/mt_protocol_helpers.h" +#include "chat/infra/meshtastic/mt_radio_config.h" +#include "chat/time_utils.h" +#include "platform/linux/runtime_packet_log.h" +#include "sys/event_bus.h" + +#if defined(TRAIL_MATE_HAS_OPENSSL) +#include +#endif + +namespace trailmate::linux_app +{ +namespace +{ + +constexpr std::uint8_t kMagic0 = 'T'; +constexpr std::uint8_t kMagic1 = 'M'; +constexpr std::uint8_t kMagic2 = 'L'; +constexpr std::uint8_t kMagic3 = '1'; +constexpr std::uint8_t kVersion = 1; +constexpr std::uint8_t kBroadcastFlags = 0x01; +constexpr std::size_t kHeaderSize = 26; +constexpr std::size_t kMaxPayloadSize = 190; +constexpr std::uint32_t kBroadcastNodeId = 0xFFFFFFFFUL; +constexpr std::uint8_t kDefaultPskIndex = 1; +constexpr char kSecondaryChannelName[] = "Squad"; +constexpr std::uint32_t kRxMonitorHeartbeatSeconds = 15; + +struct MeshtasticAirPlan +{ + ::chat::meshtastic::RadioConfig radio{}; + std::uint8_t primary_psk[16]{}; + std::size_t primary_psk_len = 0; + std::uint8_t secondary_psk[16]{}; + std::size_t secondary_psk_len = 0; + std::uint8_t primary_channel_hash = 0; + std::uint8_t secondary_channel_hash = 0; +}; + +std::uint32_t now_seconds() +{ + using clock = std::chrono::system_clock; + return static_cast( + std::chrono::duration_cast( + clock::now().time_since_epoch()) + .count()); +} + +void write_u16(std::uint8_t* out, std::uint16_t value) +{ + out[0] = static_cast(value & 0xFF); + out[1] = static_cast((value >> 8) & 0xFF); +} + +void write_u32(std::uint8_t* out, std::uint32_t value) +{ + out[0] = static_cast(value & 0xFF); + out[1] = static_cast((value >> 8) & 0xFF); + out[2] = static_cast((value >> 16) & 0xFF); + out[3] = static_cast((value >> 24) & 0xFF); +} + +std::uint16_t read_u16(const std::uint8_t* data) +{ + return static_cast(data[0]) | + (static_cast(data[1]) << 8); +} + +std::uint32_t read_u32(const std::uint8_t* data) +{ + return static_cast(data[0]) | + (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | + (static_cast(data[3]) << 24); +} + +std::string hex_u8(std::uint8_t value) +{ + char buffer[5] = {}; + std::snprintf(buffer, sizeof(buffer), "0x%02X", + static_cast(value)); + return buffer; +} + +std::string node_hex(std::uint32_t value) +{ + char buffer[9] = {}; + std::snprintf(buffer, + sizeof(buffer), + "%08lX", + static_cast(value)); + return buffer; +} + +std::string compact_text(const std::string& text, std::size_t max_len = 80) +{ + if (text.size() <= max_len) + { + return text; + } + return text.substr(0, max_len) + "..."; +} + +const char* meshtastic_port_label(std::uint32_t portnum) +{ + switch (static_cast(portnum)) + { + case meshtastic_PortNum_TEXT_MESSAGE_APP: + return "TEXT"; + case meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP: + return "TEXT_COMPRESSED"; + case meshtastic_PortNum_NODEINFO_APP: + return "NODEINFO"; + case meshtastic_PortNum_POSITION_APP: + return "POSITION"; + case meshtastic_PortNum_ROUTING_APP: + return "ROUTING"; + default: + return "APPDATA"; + } +} + +std::string packet_signal_text( + const ::platform::linux_runtime::Sx126xPacket& packet) +{ + char buffer[96] = {}; + std::snprintf(buffer, + sizeof(buffer), + "RSSI %.1f dBm / SNR %.1f dB / %.3f MHz / BW %.1f kHz / SF%u / CR 4/%u", + static_cast(packet.rssi_dbm), + static_cast(packet.snr_db), + static_cast(packet.freq_hz) / 1000000.0, + static_cast(packet.bw_hz) / 1000.0, + static_cast(packet.sf), + static_cast(packet.cr)); + return buffer; +} + +std::string pb_error_text(const pb_istream_t& stream) +{ + const char* error = PB_GET_ERROR(&stream); + return (error != nullptr && error[0] != '\0') ? error : "unknown"; +} + +std::string describe_nodeinfo_payload_failure(const meshtastic_Data& data) +{ + meshtastic_NodeInfo node = meshtastic_NodeInfo_init_default; + pb_istream_t node_stream = + pb_istream_from_buffer(data.payload.bytes, data.payload.size); + if (pb_decode(&node_stream, meshtastic_NodeInfo_fields, &node)) + { + return "NodeInfo protobuf decoded but no meaningful node facts were present."; + } + + const std::string node_error = pb_error_text(node_stream); + meshtastic_User user = meshtastic_User_init_default; + pb_istream_t user_stream = + pb_istream_from_buffer(data.payload.bytes, data.payload.size); + if (pb_decode(&user_stream, meshtastic_User_fields, &user)) + { + return "Legacy User protobuf decoded but no meaningful user facts were present."; + } + + return "NodeInfo pb=" + node_error + + " / User pb=" + pb_error_text(user_stream); +} + +std::string describe_position_payload_failure(const meshtastic_Data& data) +{ + meshtastic_Position pos = meshtastic_Position_init_zero; + pb_istream_t stream = + pb_istream_from_buffer(data.payload.bytes, data.payload.size); + if (pb_decode(&stream, meshtastic_Position_fields, &pos)) + { + return "Position protobuf decoded but latitude/longitude were missing or invalid."; + } + return "Position pb=" + pb_error_text(stream); +} + +MeshtasticAirPlan build_meshtastic_air_plan( + const ::chat::MeshConfig& config) +{ + MeshtasticAirPlan plan{}; + plan.radio = ::chat::meshtastic::deriveRadioConfig(config); + + if (::chat::meshtastic::isZeroKey(config.primary_key, + sizeof(config.primary_key))) + { + ::chat::meshtastic::expandShortPsk(kDefaultPskIndex, + plan.primary_psk, + &plan.primary_psk_len); + } + else + { + std::memcpy(plan.primary_psk, + config.primary_key, + sizeof(plan.primary_psk)); + plan.primary_psk_len = sizeof(plan.primary_psk); + } + + if (!::chat::meshtastic::isZeroKey(config.secondary_key, + sizeof(config.secondary_key))) + { + std::memcpy(plan.secondary_psk, + config.secondary_key, + sizeof(plan.secondary_psk)); + plan.secondary_psk_len = sizeof(plan.secondary_psk); + } + + plan.primary_channel_hash = ::chat::meshtastic::computeChannelHash( + ::chat::meshtastic::primaryChannelName(config), + plan.primary_psk, + plan.primary_psk_len); + plan.secondary_channel_hash = ::chat::meshtastic::computeChannelHash( + kSecondaryChannelName, + plan.secondary_psk_len > 0 ? plan.secondary_psk : nullptr, + plan.secondary_psk_len); + return plan; +} + +const std::uint8_t* psk_for_channel(const MeshtasticAirPlan& plan, + ::chat::ChannelId channel, + std::size_t* out_len) +{ + if (channel == ::chat::ChannelId::SECONDARY) + { + if (out_len != nullptr) + { + *out_len = plan.secondary_psk_len; + } + return plan.secondary_psk_len > 0 ? plan.secondary_psk : nullptr; + } + if (out_len != nullptr) + { + *out_len = plan.primary_psk_len; + } + return plan.primary_psk_len > 0 ? plan.primary_psk : nullptr; +} + +std::uint8_t channel_hash_for(const MeshtasticAirPlan& plan, + ::chat::ChannelId channel) +{ + return channel == ::chat::ChannelId::SECONDARY + ? plan.secondary_channel_hash + : plan.primary_channel_hash; +} + +bool channel_from_hash(const MeshtasticAirPlan& plan, + std::uint8_t hash, + ::chat::ChannelId* out) +{ + if (hash == plan.primary_channel_hash) + { + if (out != nullptr) + { + *out = ::chat::ChannelId::PRIMARY; + } + return true; + } + if (hash == plan.secondary_channel_hash && plan.secondary_psk_len > 0) + { + if (out != nullptr) + { + *out = ::chat::ChannelId::SECONDARY; + } + return true; + } + return false; +} + +std::string describe_meshtastic_air_plan(const MeshtasticAirPlan& plan) +{ + const auto* region = ::chat::meshtastic::findRegion(plan.radio.region_code); + const char* region_label = + region != nullptr && region->label != nullptr ? region->label : "unknown"; + char buffer[260] = {}; + std::snprintf(buffer, + sizeof(buffer), + "Meshtastic region %s / preset %s / slot %lu / primary hash %s / secondary hash %s / %.3f MHz / BW %.1f kHz / SF%u / CR 4/%u / sync 0x%02X / preamble %u / TX %d dBm", + region_label, + plan.radio.channel_name == nullptr ? "Custom" + : plan.radio.channel_name, + static_cast(plan.radio.channel_slot), + hex_u8(plan.primary_channel_hash).c_str(), + hex_u8(plan.secondary_channel_hash).c_str(), + static_cast(plan.radio.freq_mhz), + static_cast(plan.radio.bw_khz), + static_cast(plan.radio.sf), + static_cast(plan.radio.cr_denom), + static_cast(plan.radio.sync_word), + static_cast(plan.radio.preamble_len), + static_cast(plan.radio.tx_power_dbm)); + return buffer; +} + +::chat::RxMeta make_meshtastic_rx_meta( + const ::chat::meshtastic::PacketHeaderWire& header, + const ::platform::linux_runtime::Sx126xPacket& packet) +{ + ::chat::RxMeta meta{}; + meta.rx_timestamp_ms = ::sys::millis_now(); + const std::uint32_t epoch = ::chat::now_epoch_seconds(); + if (::chat::is_valid_epoch(epoch)) + { + meta.rx_timestamp_s = epoch; + meta.time_source = ::chat::RxTimeSource::DeviceUtc; + } + else + { + meta.rx_timestamp_s = ::chat::now_uptime_seconds(); + meta.time_source = ::chat::RxTimeSource::Uptime; + } + meta.origin = ::chat::RxOrigin::Mesh; + meta.channel_hash = header.channel; + meta.wire_flags = header.flags; + meta.next_hop = header.next_hop; + meta.relay_node = header.relay_node; + meta.hop_count = ::chat::meshtastic::computeHopsAway(header.flags); + meta.hop_limit = + header.flags & ::chat::meshtastic::PACKET_FLAGS_HOP_LIMIT_MASK; + meta.direct = meta.hop_count == 0; + meta.from_is = false; + meta.rssi_dbm_x10 = + static_cast(std::lround(packet.rssi_dbm * 10.0f)); + meta.snr_db_x10 = + static_cast(std::lround(packet.snr_db * 10.0f)); + meta.freq_hz = packet.freq_hz; + meta.bw_hz = packet.bw_hz; + meta.sf = packet.sf; + meta.cr = packet.cr; + return meta; +} + +bool aes_ctr_crypt(const std::uint8_t* key, + std::size_t key_len, + const std::uint8_t* nonce, + std::uint8_t* buffer, + std::size_t len) +{ + if (key == nullptr || nonce == nullptr || buffer == nullptr || len == 0) + { + return false; + } +#if defined(TRAIL_MATE_HAS_OPENSSL) + const EVP_CIPHER* cipher = nullptr; + if (key_len == 16) + { + cipher = EVP_aes_128_ctr(); + } + else if (key_len == 32) + { + cipher = EVP_aes_256_ctr(); + } + if (cipher == nullptr) + { + return false; + } + + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (ctx == nullptr) + { + return false; + } + const std::vector input(buffer, buffer + len); + int out_len = 0; + int final_len = 0; + const bool ok = + EVP_EncryptInit_ex(ctx, cipher, nullptr, key, nonce) == 1 && + EVP_CIPHER_CTX_set_padding(ctx, 0) == 1 && + EVP_EncryptUpdate(ctx, + buffer, + &out_len, + input.data(), + static_cast(len)) == 1 && + EVP_EncryptFinal_ex(ctx, buffer + out_len, &final_len) == 1 && + static_cast(out_len + final_len) == len; + EVP_CIPHER_CTX_free(ctx); + return ok; +#else + (void)key_len; + return false; +#endif +} + +bool meshtastic_crypto_available() +{ +#if defined(TRAIL_MATE_HAS_OPENSSL) + return true; +#else + return false; +#endif +} + +void make_meshtastic_nonce(const ::chat::meshtastic::PacketHeaderWire& header, + std::uint8_t* out_nonce, + std::size_t nonce_len) +{ + if (out_nonce == nullptr || nonce_len < 16) + { + return; + } + std::memset(out_nonce, 0, nonce_len); + const std::uint64_t packet_id = static_cast(header.id); + std::memcpy(out_nonce, &packet_id, sizeof(packet_id)); + std::memcpy(out_nonce + sizeof(packet_id), &header.from, sizeof(header.from)); +} + +bool parse_meshtastic_wire_packet( + const std::uint8_t* buffer, + std::size_t size, + ::chat::meshtastic::PacketHeaderWire* out_header, + std::uint8_t* out_payload, + std::size_t* out_payload_size) +{ + if (buffer == nullptr || out_header == nullptr || out_payload == nullptr || + out_payload_size == nullptr || + size < sizeof(::chat::meshtastic::PacketHeaderWire)) + { + return false; + } + std::memcpy(out_header, + buffer, + sizeof(::chat::meshtastic::PacketHeaderWire)); + const std::size_t payload_size = + size - sizeof(::chat::meshtastic::PacketHeaderWire); + if (*out_payload_size < payload_size) + { + *out_payload_size = payload_size; + return false; + } + std::memcpy(out_payload, + buffer + sizeof(::chat::meshtastic::PacketHeaderWire), + payload_size); + *out_payload_size = payload_size; + return true; +} + +bool decrypt_meshtastic_payload( + const ::chat::meshtastic::PacketHeaderWire& header, + const std::uint8_t* cipher, + std::size_t cipher_len, + const std::uint8_t* psk, + std::size_t psk_len, + std::uint8_t* out_plaintext, + std::size_t* out_plain_len) +{ + if (cipher == nullptr || psk == nullptr || out_plaintext == nullptr || + out_plain_len == nullptr || cipher_len == 0) + { + return false; + } + if (*out_plain_len < cipher_len) + { + *out_plain_len = cipher_len; + return false; + } + std::memcpy(out_plaintext, cipher, cipher_len); + std::uint8_t nonce[16] = {}; + make_meshtastic_nonce(header, nonce, sizeof(nonce)); + if (!aes_ctr_crypt(psk, psk_len, nonce, out_plaintext, cipher_len)) + { + return false; + } + *out_plain_len = cipher_len; + return true; +} + +bool build_meshtastic_wire_packet(const std::uint8_t* data_payload, + std::size_t data_len, + std::uint32_t from_node, + std::uint32_t packet_id, + std::uint32_t dest_node, + std::uint8_t channel_hash, + std::uint8_t hop_limit, + bool want_ack, + const std::uint8_t* psk, + std::size_t psk_len, + std::uint8_t* out_buffer, + std::size_t* out_size) +{ + if (data_payload == nullptr || data_len == 0 || out_buffer == nullptr || + out_size == nullptr || data_len > 256) + { + return false; + } + + std::uint8_t payload[256] = {}; + std::memcpy(payload, data_payload, data_len); + + ::chat::meshtastic::PacketHeaderWire header{}; + header.to = dest_node; + header.from = from_node; + header.id = packet_id; + const std::uint8_t hop_start = hop_limit; + header.flags = + (hop_limit & ::chat::meshtastic::PACKET_FLAGS_HOP_LIMIT_MASK) | + ((hop_start << ::chat::meshtastic::PACKET_FLAGS_HOP_START_SHIFT) & + ::chat::meshtastic::PACKET_FLAGS_HOP_START_MASK); + if (want_ack) + { + header.flags |= ::chat::meshtastic::PACKET_FLAGS_WANT_ACK_MASK; + } + header.channel = channel_hash; + header.next_hop = 0; + header.relay_node = static_cast(from_node & 0xFF); + + if (psk != nullptr && psk_len > 0) + { + std::uint8_t nonce[16] = {}; + make_meshtastic_nonce(header, nonce, sizeof(nonce)); + if (!aes_ctr_crypt(psk, psk_len, nonce, payload, data_len)) + { + return false; + } + } + + const std::size_t required = + sizeof(::chat::meshtastic::PacketHeaderWire) + data_len; + if (*out_size < required) + { + *out_size = required; + return false; + } + std::memcpy(out_buffer, + &header, + sizeof(::chat::meshtastic::PacketHeaderWire)); + std::memcpy(out_buffer + sizeof(::chat::meshtastic::PacketHeaderWire), + payload, + data_len); + *out_size = required; + return true; +} + +::platform::linux_runtime::PacketLogEntry make_lora_log( + ::platform::linux_runtime::PacketLogDirection direction, + const std::uint8_t* frame, + std::size_t frame_size, + const char* title, + const char* summary) +{ + ::platform::linux_runtime::PacketLogEntry entry{}; + entry.source = ::platform::linux_runtime::PacketLogSource::Lora; + entry.direction = direction; + entry.title = title == nullptr ? "LoRa frame" : title; + entry.summary = summary == nullptr ? "" : summary; + entry.raw_hex = ::platform::linux_runtime::hex_bytes(frame, frame_size); + const std::size_t header_len = std::min(kHeaderSize, frame_size); + if (header_len > 0) + { + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "head", + .text = ::platform::linux_runtime::hex_bytes(frame, header_len), + }); + } + if (frame_size > kHeaderSize) + { + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "body", + .text = ::platform::linux_runtime::hex_bytes( + frame + kHeaderSize, frame_size - kHeaderSize), + }); + } + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Checksum, + .label = "crc", + .text = "radio", + }); + return entry; +} + +::platform::linux_runtime::PacketLogEntry make_meshtastic_wire_log( + ::platform::linux_runtime::PacketLogDirection direction, + const std::uint8_t* frame, + std::size_t frame_size, + const char* title, + const char* summary) +{ + ::platform::linux_runtime::PacketLogEntry entry{}; + entry.source = ::platform::linux_runtime::PacketLogSource::Lora; + entry.direction = direction; + entry.title = title == nullptr ? "Meshtastic air frame" : title; + entry.summary = summary == nullptr ? "" : summary; + entry.raw_hex = ::platform::linux_runtime::hex_bytes(frame, frame_size); + + const std::size_t header_len = + std::min(sizeof(::chat::meshtastic::PacketHeaderWire), + frame_size); + if (header_len > 0) + { + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "air head", + .text = ::platform::linux_runtime::hex_bytes(frame, header_len), + }); + } + if (frame_size > header_len) + { + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "cipher/data", + .text = ::platform::linux_runtime::hex_bytes(frame + header_len, + frame_size - header_len), + }); + } + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Checksum, + .label = "crc", + .text = "SX1262", + }); + return entry; +} + +void append_lora_system_log(const char* title, + const std::string& summary, + const std::vector< + ::platform::linux_runtime::PacketLogSegment>& + segments = {}) +{ + ::platform::linux_runtime::PacketLogEntry entry{}; + entry.source = ::platform::linux_runtime::PacketLogSource::Lora; + entry.direction = ::platform::linux_runtime::PacketLogDirection::System; + entry.title = title == nullptr ? "LoRa" : title; + entry.summary = summary; + entry.segments = segments; + ::platform::linux_runtime::append_packet_log(std::move(entry)); +} + +void append_mqtt_system_log(const char* title, + const std::string& summary, + const std::vector< + ::platform::linux_runtime::PacketLogSegment>& + segments = {}) +{ + ::platform::linux_runtime::PacketLogEntry entry{}; + entry.source = ::platform::linux_runtime::PacketLogSource::Mqtt; + entry.direction = ::platform::linux_runtime::PacketLogDirection::System; + entry.title = title == nullptr ? "MQTT" : title; + entry.summary = summary; + entry.segments = segments; + ::platform::linux_runtime::append_packet_log(std::move(entry)); +} + +::platform::linux_runtime::Sx126xLoRaConfig to_sx126x_lora_config( + const ::chat::meshtastic::RadioConfig& config) +{ + auto out = + ::platform::linux_runtime::Sx126xRadio::defaultLoRaConfigFromEnvironment(); + out.freq_mhz = config.freq_mhz; + out.bw_khz = config.bw_khz; + out.sf = config.sf; + out.cr = config.cr_denom; + out.tx_power_dbm = config.tx_power_dbm; + out.preamble_len = config.preamble_len; + out.sync_word = config.sync_word; + out.crc_len = config.crc_len; + return out; +} + +::platform::linux_runtime::Sx126xLoRaConfig derive_radio_config( + ::chat::MeshProtocol protocol, + const ::chat::MeshConfig& config) +{ + if (protocol == ::chat::MeshProtocol::Meshtastic) + { + return to_sx126x_lora_config( + ::chat::meshtastic::deriveRadioConfig(config)); + } + + auto out = ::platform::linux_runtime::Sx126xRadio:: + defaultLoRaConfigFromEnvironment(); + if (config.override_frequency_mhz > 0.0f) + { + out.freq_mhz = config.override_frequency_mhz; + } + else if (config.meshcore_freq_mhz > 0.0f && + config.meshcore_freq_mhz < 1000.0f) + { + out.freq_mhz = config.meshcore_freq_mhz; + } + out.freq_mhz += config.frequency_offset_mhz; + if (config.bandwidth_khz > 0.0f) + { + out.bw_khz = config.bandwidth_khz; + } + if (config.spread_factor >= 5 && config.spread_factor <= 12) + { + out.sf = config.spread_factor; + } + if (config.coding_rate >= 5 && config.coding_rate <= 8) + { + out.cr = config.coding_rate; + } + if (config.tx_power != 0) + { + out.tx_power_dbm = std::clamp(config.tx_power, -9, 22); + } + return out; +} + +std::string describe_radio_config( + ::chat::MeshProtocol protocol, + const ::platform::linux_runtime::Sx126xLoRaConfig& config) +{ + char buffer[192] = {}; + std::snprintf(buffer, + sizeof(buffer), + "%s %.3f MHz / BW %.1f kHz / SF%u / CR 4/%u / sync 0x%02X / preamble %u / TX %d dBm", + ::chat::infra::meshProtocolName(protocol), + static_cast(config.freq_mhz), + static_cast(config.bw_khz), + static_cast(config.sf), + static_cast(config.cr), + static_cast(config.sync_word), + static_cast(config.preamble_len), + static_cast(config.tx_power_dbm)); + return buffer; +} + +std::string describe_active_radio_config( + ::chat::MeshProtocol protocol, + const ::chat::MeshConfig& mesh_config, + const ::platform::linux_runtime::Sx126xLoRaConfig& config) +{ + if (protocol == ::chat::MeshProtocol::Meshtastic) + { + return describe_meshtastic_air_plan( + build_meshtastic_air_plan(mesh_config)); + } + return describe_radio_config(protocol, config); +} + +std::string describe_radio_stats( + const ::platform::linux_runtime::Sx126xRadioStats& stats) +{ + char buffer[224] = {}; + std::snprintf(buffer, + sizeof(buffer), + "rx=%lu tx=%lu crc=%lu header=%lu timeout=%lu invalid=%lu read=%lu irq=0x%04lX", + static_cast(stats.rx_packets), + static_cast(stats.tx_packets), + static_cast(stats.rx_crc_errors), + static_cast(stats.rx_header_errors), + static_cast(stats.rx_timeouts), + static_cast(stats.rx_invalid_lengths), + static_cast(stats.rx_read_errors), + static_cast(stats.last_irq_flags)); + return buffer; +} + +std::string format_irq_flags(std::uint32_t flags) +{ + char buffer[9] = {}; + std::snprintf(buffer, + sizeof(buffer), + "%04lX", + static_cast(flags)); + return std::string("0x") + buffer; +} + +void append_meshtastic_air_log( + const ::platform::linux_runtime::Sx126xPacket& packet) +{ + if (packet.size < sizeof(::chat::meshtastic::PacketHeaderWire)) + { + append_lora_system_log( + "Meshtastic RX ignored", + "Packet is shorter than the Meshtastic air header.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "len", + .text = std::to_string(packet.size), + }}); + return; + } + + ::chat::meshtastic::PacketHeaderWire header{}; + std::memcpy(&header, packet.data.data(), sizeof(header)); + + char summary[192] = {}; + std::snprintf(summary, + sizeof(summary), + "Meshtastic wire packet from %08lX to %08lX id %08lX channel 0x%02X.", + static_cast(header.from), + static_cast(header.to), + static_cast(header.id), + static_cast(header.channel)); + + char head[160] = {}; + std::snprintf(head, + sizeof(head), + "to=%08lX from=%08lX id=%08lX flags=0x%02X ch=0x%02X next=%u relay=%u", + static_cast(header.to), + static_cast(header.from), + static_cast(header.id), + static_cast(header.flags), + static_cast(header.channel), + static_cast(header.next_hop), + static_cast(header.relay_node)); + + const std::size_t header_len = sizeof(header); + const std::size_t body_len = packet.size > header_len + ? packet.size - header_len + : 0U; + append_lora_system_log( + "Meshtastic air RX", + summary, + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "head", + .text = head, + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "payload", + .text = ::platform::linux_runtime::hex_bytes( + packet.data.data() + header_len, body_len), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "decode", + .text = "air header parsed; payload decoder not yet bound", + }}); +} + +} // namespace + +LinuxRawLoraMeshAdapter::LinuxRawLoraMeshAdapter(::chat::NodeId self_node_id) + : radio_(::platform::linux_runtime::Sx126xRadio::instance()), + self_node_id_(self_node_id) +{ +} + +bool LinuxRawLoraMeshAdapter::hardwareCandidatePresent() +{ + return ::platform::linux_runtime::Sx126xRadio::hardwareCandidatePresent(); +} + +bool LinuxRawLoraMeshAdapter::begin() +{ + return ensureRadioReady(); +} + +void LinuxRawLoraMeshAdapter::tick() +{ + if (!ensureRadioReady()) + { + return; + } + for (int i = 0; i < 4; ++i) + { + ::platform::linux_runtime::Sx126xPacket packet{}; + if (!radio_.pollReceive(&packet)) + { + break; + } + const bool meshtastic = + active_protocol_ == ::chat::MeshProtocol::Meshtastic; + ::platform::linux_runtime::append_packet_log( + meshtastic + ? make_meshtastic_wire_log( + ::platform::linux_runtime::PacketLogDirection::Rx, + packet.data.data(), + packet.size, + "Meshtastic raw RX", + packet_signal_text(packet).c_str()) + : make_lora_log( + ::platform::linux_runtime::PacketLogDirection::Rx, + packet.data.data(), + packet.size, + "LoRa raw RX", + "SX1262 packet received.")); + raw_packets_.push_back(packet); + const bool parsed_trailmate = parseFrame(packet); + if (!parsed_trailmate && meshtastic && + !parseMeshtasticPacket(packet)) + { + append_meshtastic_air_log(packet); + } + } + logRadioStatsChanges(); + logRxMonitorHeartbeat(); +} + +bool LinuxRawLoraMeshAdapter::takePendingSendResult(::chat::MessageId& out_msg_id, + bool& out_ok) +{ + if (pending_results_.empty()) + { + return false; + } + const PendingResult result = pending_results_.front(); + pending_results_.pop_front(); + out_msg_id = result.msg_id; + out_ok = result.ok; + return true; +} + +::chat::MeshCapabilities LinuxRawLoraMeshAdapter::getCapabilities() const +{ + return { + .supports_unicast_text = true, + .supports_unicast_appdata = true, + .supports_broadcast_appdata = true, + .supports_appdata_ack = false, + .provides_appdata_sender = true, + .supports_node_info = true, + .supports_pki = false, + .supports_discovery_actions = false, + }; +} + +bool LinuxRawLoraMeshAdapter::sendText(::chat::ChannelId channel, + const std::string& text, + ::chat::MessageId* out_msg_id, + ::chat::NodeId peer) +{ + return sendTextWithId(channel, text, 0, out_msg_id, peer); +} + +bool LinuxRawLoraMeshAdapter::sendTextWithId(::chat::ChannelId channel, + const std::string& text, + ::chat::MessageId forced_msg_id, + ::chat::MessageId* out_msg_id, + ::chat::NodeId peer) +{ + if (text.empty()) + { + return false; + } + const ::chat::MessageId msg_id = + forced_msg_id != 0 ? forced_msg_id : nextMessageId(); + if (out_msg_id != nullptr) + { + *out_msg_id = msg_id; + } + + const auto* bytes = reinterpret_cast(text.data()); + const ::chat::NodeId dest = peer == 0 ? kBroadcastNodeId : peer; + const bool ok = + active_protocol_ == ::chat::MeshProtocol::Meshtastic + ? sendMeshtasticPayload(channel, + dest, + msg_id, + meshtastic_PortNum_TEXT_MESSAGE_APP, + bytes, + text.size(), + false, + false) + : sendFrame(PacketKind::Text, + channel, + dest, + msg_id, + 0, + bytes, + text.size()); + if (msg_id != 0) + { + pending_results_.push_back({.msg_id = msg_id, .ok = ok}); + } + return ok; +} + +bool LinuxRawLoraMeshAdapter::pollIncomingText(::chat::MeshIncomingText* out) +{ + tick(); + if (incoming_text_.empty()) + { + return false; + } + if (out != nullptr) + { + *out = incoming_text_.front(); + } + incoming_text_.pop_front(); + return true; +} + +bool LinuxRawLoraMeshAdapter::sendAppData(::chat::ChannelId channel, + std::uint32_t portnum, + const std::uint8_t* payload, + std::size_t len, + ::chat::NodeId dest, + bool want_ack, + ::chat::MessageId packet_id, + bool want_response) +{ + (void)want_ack; + (void)want_response; + if (payload == nullptr || len == 0) + { + return false; + } + const ::chat::NodeId effective_dest = dest == 0 ? kBroadcastNodeId : dest; + const ::chat::MessageId effective_id = + packet_id != 0 ? packet_id : nextMessageId(); + if (active_protocol_ == ::chat::MeshProtocol::Meshtastic) + { + return sendMeshtasticPayload(channel, + effective_dest, + effective_id, + portnum, + payload, + len, + want_ack, + want_response); + } + return sendFrame(PacketKind::AppData, + channel, + effective_dest, + effective_id, + portnum, + payload, + len); +} + +bool LinuxRawLoraMeshAdapter::pollIncomingData(::chat::MeshIncomingData* out) +{ + tick(); + if (incoming_data_.empty()) + { + return false; + } + if (out != nullptr) + { + *out = incoming_data_.front(); + } + incoming_data_.pop_front(); + return true; +} + +bool LinuxRawLoraMeshAdapter::requestNodeInfo(::chat::NodeId dest, + bool want_response) +{ + if (active_protocol_ != ::chat::MeshProtocol::Meshtastic) + { + append_lora_system_log( + "NodeInfo request unsupported", + "Linux raw LoRa NodeInfo exchange is currently Meshtastic only."); + return false; + } + + const ::chat::NodeId target = dest == 0 ? kBroadcastNodeId : dest; + return sendMeshtasticNodeInfoTo(target, + want_response, + ::chat::ChannelId::PRIMARY); +} + +void LinuxRawLoraMeshAdapter::applyConfig(const ::chat::MeshConfig& config) +{ + applyProtocolConfig(active_protocol_, config); +} + +void LinuxRawLoraMeshAdapter::applyProtocolConfig( + ::chat::MeshProtocol protocol, + const ::chat::MeshConfig& config) +{ + active_protocol_ = protocol; + config_ = config; + tx_enabled_ = config.tx_enabled; + if (!started_) + { + (void)ensureRadioReady(); + return; + } + if (started_) + { + const auto radio_config = + derive_radio_config(active_protocol_, config_); + if (radio_.configureLoRa(radio_config)) + { + last_rx_monitor_log_s_ = 0; + stats_logged_ = false; + last_logged_stats_ = {}; + append_lora_system_log( + "LoRa radio configured", + describe_active_radio_config(active_protocol_, + config_, + radio_config)); + } + else + { + append_lora_system_log( + "LoRa radio config failed", + radio_.lastError(), + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "driver", + .text = radio_.lastError(), + }}); + } + } +} + +void LinuxRawLoraMeshAdapter::setUserInfo(const char* long_name, + const char* short_name) +{ + long_name_ = long_name == nullptr ? "" : long_name; + short_name_ = short_name == nullptr ? "" : short_name; +} + +::chat::NodeId LinuxRawLoraMeshAdapter::getNodeId() const +{ + return self_node_id_; +} + +bool LinuxRawLoraMeshAdapter::isReady() const +{ + return started_ && radio_.isOnline(); +} + +bool LinuxRawLoraMeshAdapter::pollIncomingRawPacket(std::uint8_t* out_data, + std::size_t& out_len, + std::size_t max_len) +{ + tick(); + if (raw_packets_.empty()) + { + return false; + } + const auto packet = raw_packets_.front(); + raw_packets_.pop_front(); + out_len = std::min(max_len, packet.size); + if (out_data != nullptr && out_len > 0) + { + std::memcpy(out_data, packet.data.data(), out_len); + } + return true; +} + +void LinuxRawLoraMeshAdapter::processSendQueue() +{ + tick(); +} + +void LinuxRawLoraMeshAdapter::setSelfNodeId(::chat::NodeId id) +{ + self_node_id_ = id; +} + +std::string LinuxRawLoraMeshAdapter::statusText() const +{ + if (started_ && radio_.isOnline()) + { + return "SX1262 raw LoRa transport ready."; + } + return last_status_; +} + +std::string LinuxRawLoraMeshAdapter::radioConfigText() const +{ + const auto config = + started_ && radio_.isOnline() + ? radio_.appliedLoRaConfig() + : derive_radio_config(active_protocol_, config_); + return describe_active_radio_config(active_protocol_, config_, config); +} + +std::string LinuxRawLoraMeshAdapter::radioStatsText() const +{ + return describe_radio_stats(radio_.stats()); +} + +std::vector LinuxRawLoraMeshAdapter::diagnosticLines() const +{ + std::vector out; + out.push_back(statusText()); + out.push_back("RF: " + radioConfigText()); + out.push_back("Counters: " + radioStatsText()); + const char* error = radio_.lastError(); + if (error != nullptr && error[0] != '\0') + { + out.push_back(std::string("Driver: ") + error); + } + return out; +} + +void LinuxRawLoraMeshAdapter::logStatusIfChanged(const char* title, + const std::string& status) +{ + if (status.empty() || status == last_logged_status_) + { + return; + } + last_logged_status_ = status; + append_lora_system_log(title == nullptr ? "LoRa status" : title, status); +} + +void LinuxRawLoraMeshAdapter::logRadioStatsChanges() +{ + const auto stats = radio_.stats(); + if (!stats_logged_) + { + last_logged_stats_ = stats; + stats_logged_ = true; + return; + } + + const bool error_changed = + stats.rx_crc_errors != last_logged_stats_.rx_crc_errors || + stats.rx_header_errors != last_logged_stats_.rx_header_errors || + stats.rx_timeouts != last_logged_stats_.rx_timeouts || + stats.rx_invalid_lengths != last_logged_stats_.rx_invalid_lengths || + stats.rx_read_errors != last_logged_stats_.rx_read_errors; + const bool traffic_changed = + stats.rx_packets != last_logged_stats_.rx_packets || + stats.tx_packets != last_logged_stats_.tx_packets; + if (!error_changed && !traffic_changed) + { + return; + } + + if (error_changed) + { + append_lora_system_log( + "LoRa RX diagnostic", + describe_radio_stats(stats), + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "irq", + .text = format_irq_flags(stats.last_irq_flags), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "crc/header/timeout", + .text = std::to_string(stats.rx_crc_errors) + "/" + + std::to_string(stats.rx_header_errors) + "/" + + std::to_string(stats.rx_timeouts), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "invalid/read", + .text = std::to_string(stats.rx_invalid_lengths) + "/" + + std::to_string(stats.rx_read_errors), + }}); + } + else if (traffic_changed) + { + append_lora_system_log( + "LoRa traffic counters", + describe_radio_stats(stats), + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "irq", + .text = format_irq_flags(stats.last_irq_flags), + }}); + } + last_logged_stats_ = stats; +} + +void LinuxRawLoraMeshAdapter::logRxMonitorHeartbeat() +{ + if (!started_ || !radio_.isOnline()) + { + return; + } + const std::uint32_t now = now_seconds(); + if (last_rx_monitor_log_s_ != 0 && + now - last_rx_monitor_log_s_ < kRxMonitorHeartbeatSeconds) + { + return; + } + last_rx_monitor_log_s_ = now; + const auto stats = radio_.stats(); + append_lora_system_log( + stats.rx_packets == 0 ? "LoRa RX monitor" + : "LoRa RX monitor heartbeat", + describe_active_radio_config(active_protocol_, + config_, + stats.lora_config) + + " / " + describe_radio_stats(stats), + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "irq", + .text = format_irq_flags(stats.last_irq_flags), + }}); +} + +bool LinuxRawLoraMeshAdapter::ensureRadioReady() +{ + if (started_ && radio_.isOnline()) + { + return true; + } + if (!hardwareCandidatePresent()) + { + last_status_ = "No Linux LoRa SPI endpoint is present."; + logStatusIfChanged("LoRa endpoint missing", last_status_); + return false; + } + if (!radio_.acquire()) + { + last_status_ = std::string("SX1262 acquire failed: ") + + radio_.lastError(); + logStatusIfChanged("LoRa acquire failed", last_status_); + return false; + } + const auto radio_config = derive_radio_config(active_protocol_, config_); + if (!radio_.configureLoRa(radio_config)) + { + last_status_ = std::string("SX1262 LoRa config failed: ") + + radio_.lastError(); + logStatusIfChanged("LoRa radio config failed", last_status_); + return false; + } + started_ = true; + last_rx_monitor_log_s_ = 0; + stats_logged_ = false; + last_logged_stats_ = {}; + last_status_ = "SX1262 raw LoRa transport ready."; + append_lora_system_log("LoRa radio configured", + describe_active_radio_config(active_protocol_, + config_, + radio_config)); + return true; +} + +::chat::MessageId LinuxRawLoraMeshAdapter::nextMessageId() +{ + ++next_msg_id_; + if (next_msg_id_ == 0) + { + next_msg_id_ = 1; + } + return next_msg_id_; +} + +bool LinuxRawLoraMeshAdapter::sendMeshtasticPayload( + ::chat::ChannelId channel, + ::chat::NodeId dest, + ::chat::MessageId msg_id, + std::uint32_t portnum, + const std::uint8_t* payload, + std::size_t len, + bool want_ack, + bool want_response) +{ + if (!tx_enabled_ || payload == nullptr || len == 0 || !ensureRadioReady()) + { + return false; + } + + std::uint8_t data_payload[256] = {}; + std::size_t data_size = sizeof(data_payload); + bool encoded = false; + if (portnum == meshtastic_PortNum_TEXT_MESSAGE_APP) + { + const std::string text(reinterpret_cast(payload), len); + encoded = ::chat::meshtastic::encodeTextMessage(channel, + text, + self_node_id_, + msg_id, + dest, + data_payload, + &data_size); + } + else + { + encoded = ::chat::meshtastic::encodeAppData(portnum, + payload, + len, + want_response, + data_payload, + &data_size); + } + if (!encoded) + { + append_lora_system_log( + "Meshtastic TX encode failed", + std::string("port ") + meshtastic_port_label(portnum) + + " len " + std::to_string(len), + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "protobuf", + .text = "Data payload could not be encoded", + }}); + return false; + } + + const MeshtasticAirPlan plan = build_meshtastic_air_plan(config_); + std::size_t psk_len = 0; + const std::uint8_t* psk = psk_for_channel(plan, channel, &psk_len); + const std::uint8_t channel_hash = channel_hash_for(plan, channel); + if (psk != nullptr && psk_len > 0 && !meshtastic_crypto_available()) + { + append_lora_system_log( + "Meshtastic TX crypto unavailable", + "OpenSSL libcrypto was not linked, so encrypted Meshtastic channels cannot be transmitted.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "dependency", + .text = "install libssl-dev before building trailmate-uconsole", + }}); + return false; + } + std::uint8_t wire[255] = {}; + std::size_t wire_size = sizeof(wire); + const bool air_want_ack = + ::chat::meshtastic::shouldSetAirWantAck(dest, want_ack); + if (!build_meshtastic_wire_packet(data_payload, + data_size, + self_node_id_, + msg_id, + dest, + channel_hash, + config_.hop_limit, + air_want_ack, + psk, + psk_len, + wire, + &wire_size)) + { + append_lora_system_log( + "Meshtastic TX packet build failed", + "Encoded Data payload does not fit in one LoRa packet.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "size", + .text = std::to_string(wire_size), + }}); + return false; + } + + const std::string summary = + std::string("Meshtastic ") + meshtastic_port_label(portnum) + + " TX to " + node_hex(dest) + " id " + node_hex(msg_id) + + " channel " + hex_u8(channel_hash) + "."; + auto entry = make_meshtastic_wire_log( + ::platform::linux_runtime::PacketLogDirection::Tx, + wire, + wire_size, + portnum == meshtastic_PortNum_TEXT_MESSAGE_APP ? "Meshtastic text TX" + : "Meshtastic appdata TX", + summary.c_str()); + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "air", + .text = describe_meshtastic_air_plan(plan), + }); + ::platform::linux_runtime::append_packet_log(std::move(entry)); + + const bool ok = radio_.transmit(wire, wire_size); + if (!ok) + { + append_lora_system_log( + "Meshtastic TX failed", + radio_.lastError(), + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "driver", + .text = radio_.lastError(), + }}); + } + return ok; +} + +bool LinuxRawLoraMeshAdapter::sendMeshtasticNodeInfoTo( + ::chat::NodeId dest, + bool want_response, + ::chat::ChannelId channel) +{ + if (!tx_enabled_ || !ensureRadioReady()) + { + return false; + } + + char user_id[16] = {}; + std::snprintf(user_id, + sizeof(user_id), + "!%08lX", + static_cast(self_node_id_)); + + char long_name[32] = {}; + char short_name[5] = {}; + const std::uint16_t suffix = + static_cast(self_node_id_ & 0xFFFFU); + if (!long_name_.empty()) + { + std::strncpy(long_name, long_name_.c_str(), sizeof(long_name) - 1); + } + else + { + std::snprintf(long_name, sizeof(long_name), "uconsole-%04X", suffix); + } + if (!short_name_.empty()) + { + std::strncpy(short_name, short_name_.c_str(), sizeof(short_name) - 1); + } + else + { + std::snprintf(short_name, sizeof(short_name), "%04X", suffix); + } + + const std::uint8_t mac_addr[6] = {}; + std::uint8_t data_payload[256] = {}; + std::size_t data_size = sizeof(data_payload); + if (!::chat::meshtastic::encodeNodeInfoMessage( + user_id, + long_name, + short_name, + meshtastic_HardwareModel_PRIVATE_HW, + mac_addr, + nullptr, + 0, + want_response, + data_payload, + &data_size)) + { + append_lora_system_log( + "Meshtastic NodeInfo encode failed", + "Local user info could not be encoded."); + return false; + } + + const MeshtasticAirPlan plan = build_meshtastic_air_plan(config_); + std::size_t psk_len = 0; + const std::uint8_t* psk = psk_for_channel(plan, channel, &psk_len); + const std::uint8_t channel_hash = channel_hash_for(plan, channel); + if (psk != nullptr && psk_len > 0 && !meshtastic_crypto_available()) + { + append_lora_system_log( + "Meshtastic NodeInfo crypto unavailable", + "OpenSSL libcrypto was not linked, so encrypted NodeInfo cannot be transmitted.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "dependency", + .text = "install libssl-dev before building trailmate-uconsole", + }}); + return false; + } + + const ::chat::MessageId msg_id = nextMessageId(); + std::uint8_t wire[255] = {}; + std::size_t wire_size = sizeof(wire); + const bool want_ack = want_response && dest != kBroadcastNodeId; + if (!build_meshtastic_wire_packet(data_payload, + data_size, + self_node_id_, + msg_id, + dest, + channel_hash, + config_.hop_limit, + want_ack, + psk, + psk_len, + wire, + &wire_size)) + { + append_lora_system_log( + "Meshtastic NodeInfo packet build failed", + "Encoded NodeInfo payload does not fit in one LoRa packet."); + return false; + } + + const std::string summary = + "Meshtastic NODEINFO TX to " + node_hex(dest) + " id " + + node_hex(msg_id) + " channel " + hex_u8(channel_hash) + + (want_response ? " want-response." : "."); + auto entry = make_meshtastic_wire_log( + ::platform::linux_runtime::PacketLogDirection::Tx, + wire, + wire_size, + "Meshtastic nodeinfo TX", + summary.c_str()); + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "user", + .text = std::string(short_name) + " / " + long_name, + }); + ::platform::linux_runtime::append_packet_log(std::move(entry)); + + const bool ok = radio_.transmit(wire, wire_size); + if (!ok) + { + append_lora_system_log( + "Meshtastic NodeInfo TX failed", + radio_.lastError(), + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "driver", + .text = radio_.lastError(), + }}); + } + return ok; +} + +bool LinuxRawLoraMeshAdapter::sendFrame(PacketKind kind, + ::chat::ChannelId channel, + ::chat::NodeId dest, + ::chat::MessageId msg_id, + std::uint32_t portnum, + const std::uint8_t* payload, + std::size_t len) +{ + if (!tx_enabled_ || payload == nullptr || len == 0 || + len > kMaxPayloadSize || !ensureRadioReady()) + { + return false; + } + + std::uint8_t frame[255] = {}; + frame[0] = kMagic0; + frame[1] = kMagic1; + frame[2] = kMagic2; + frame[3] = kMagic3; + frame[4] = kVersion; + frame[5] = static_cast(kind); + frame[6] = static_cast(channel); + frame[7] = dest == 0xFFFFFFFFUL ? kBroadcastFlags : 0; + write_u32(frame + 8, self_node_id_); + write_u32(frame + 12, dest); + write_u32(frame + 16, msg_id); + write_u32(frame + 20, portnum); + write_u16(frame + 24, static_cast(len)); + std::memcpy(frame + kHeaderSize, payload, len); + const std::size_t frame_size = kHeaderSize + len; + ::platform::linux_runtime::append_packet_log( + make_lora_log(::platform::linux_runtime::PacketLogDirection::Tx, + frame, + frame_size, + kind == PacketKind::Text ? "LoRa text TX" + : "LoRa appdata TX", + "Trail Mate raw LoRa frame queued.")); + return radio_.transmit(frame, frame_size); +} + +bool LinuxRawLoraMeshAdapter::parseFrame( + const ::platform::linux_runtime::Sx126xPacket& packet) +{ + if (packet.size < kHeaderSize) + { + return false; + } + const auto* data = packet.data.data(); + if (data[0] != kMagic0 || data[1] != kMagic1 || data[2] != kMagic2 || + data[3] != kMagic3 || data[4] != kVersion) + { + return false; + } + + const auto kind = static_cast(data[5]); + const auto channel = static_cast<::chat::ChannelId>(data[6]); + const ::chat::NodeId from = read_u32(data + 8); + const ::chat::NodeId to = read_u32(data + 12); + const ::chat::MessageId msg_id = read_u32(data + 16); + const std::uint32_t portnum = read_u32(data + 20); + const std::uint16_t payload_len = read_u16(data + 24); + if (payload_len > packet.size - kHeaderSize) + { + return false; + } + if (from == self_node_id_ || + (to != 0xFFFFFFFFUL && to != 0 && to != self_node_id_)) + { + return false; + } + + ::chat::RxMeta meta{}; + meta.origin = ::chat::RxOrigin::Mesh; + meta.rx_timestamp_s = now_seconds(); + meta.time_source = ::chat::RxTimeSource::DeviceUtc; + meta.direct = to == self_node_id_; + meta.rssi_dbm_x10 = static_cast(packet.rssi_dbm * 10.0f); + meta.snr_db_x10 = static_cast(packet.snr_db * 10.0f); + meta.freq_hz = packet.freq_hz; + meta.bw_hz = packet.bw_hz; + meta.sf = packet.sf; + meta.cr = packet.cr; + + const auto* payload = data + kHeaderSize; + if (kind == PacketKind::Text) + { + ::chat::MeshIncomingText text{}; + text.channel = channel; + text.from = from; + text.to = to; + text.msg_id = msg_id; + text.timestamp = meta.rx_timestamp_s; + text.text.assign(reinterpret_cast(payload), payload_len); + text.hop_limit = 0; + text.encrypted = false; + text.rx_meta = meta; + incoming_text_.push_back(std::move(text)); + return true; + } + if (kind == PacketKind::AppData) + { + ::chat::MeshIncomingData app{}; + app.portnum = portnum; + app.from = from; + app.to = to; + app.packet_id = msg_id; + app.channel = channel; + app.hop_limit = 0; + app.payload.assign(payload, payload + payload_len); + app.rx_meta = meta; + incoming_data_.push_back(std::move(app)); + return true; + } + return false; +} + +bool LinuxRawLoraMeshAdapter::parseMeshtasticPacket( + const ::platform::linux_runtime::Sx126xPacket& packet) +{ + if (packet.size < sizeof(::chat::meshtastic::PacketHeaderWire)) + { + return false; + } + + ::chat::meshtastic::PacketHeaderWire header{}; + std::uint8_t payload[256] = {}; + std::size_t payload_size = sizeof(payload); + if (!parse_meshtastic_wire_packet(packet.data.data(), + packet.size, + &header, + payload, + &payload_size)) + { + append_lora_system_log( + "Meshtastic RX parse failed", + "LoRa packet has enough bytes for an air header but could not be split.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "len", + .text = std::to_string(packet.size), + }}); + return true; + } + + const MeshtasticAirPlan plan = build_meshtastic_air_plan(config_); + ::chat::ChannelId channel = ::chat::ChannelId::PRIMARY; + if (!channel_from_hash(plan, header.channel, &channel)) + { + append_lora_system_log( + "Meshtastic RX unknown channel", + "Header parsed but channel hash does not match configured primary or secondary channel.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "from/to/id", + .text = node_hex(header.from) + "/" + node_hex(header.to) + + "/" + node_hex(header.id), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "hash", + .text = hex_u8(header.channel) + " expected " + + hex_u8(plan.primary_channel_hash) + " or " + + hex_u8(plan.secondary_channel_hash), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "signal", + .text = packet_signal_text(packet), + }}); + return true; + } + + if (config_.ignore_mqtt && + (header.flags & ::chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0) + { + append_lora_system_log( + "Meshtastic RX ignored", + "Packet has via-MQTT bit and Ignore MQTT is enabled.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "from", + .text = node_hex(header.from), + }}); + append_mqtt_system_log( + "Meshtastic MQTT RX ignored", + "Packet from " + node_hex(header.from) + + " was marked via MQTT and Ignore MQTT is enabled.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "air", + .text = "ch=" + hex_u8(header.channel) + + " flags=" + hex_u8(header.flags), + }}); + return true; + } + + if (header.from == self_node_id_) + { + append_lora_system_log( + "Meshtastic RX self ignored", + "Radio heard a packet from the local node id.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "id", + .text = node_hex(header.id), + }}); + return true; + } + + std::size_t psk_len = 0; + const std::uint8_t* psk = psk_for_channel(plan, channel, &psk_len); + std::uint8_t plaintext[256] = {}; + std::size_t plaintext_len = sizeof(plaintext); + if (psk != nullptr && psk_len > 0) + { + if (!meshtastic_crypto_available()) + { + append_lora_system_log( + "Meshtastic RX crypto unavailable", + "OpenSSL libcrypto was not linked, so encrypted Meshtastic payloads cannot be decoded.", + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "dependency", + .text = "install libssl-dev before building trailmate-uconsole", + }}); + return true; + } + if (!decrypt_meshtastic_payload(header, + payload, + payload_size, + psk, + psk_len, + plaintext, + &plaintext_len)) + { + append_lora_system_log( + "Meshtastic RX decrypt failed", + "Channel hash matched but payload decryption failed.", + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "cipher", + .text = std::to_string(payload_size) + " bytes", + }}); + return true; + } + } + else + { + plaintext_len = payload_size; + std::memcpy(plaintext, payload, payload_size); + } + + meshtastic_Data decoded = meshtastic_Data_init_default; + pb_istream_t stream = pb_istream_from_buffer(plaintext, plaintext_len); + if (!pb_decode(&stream, meshtastic_Data_fields, &decoded)) + { + const std::string pb_error = pb_error_text(stream); + append_lora_system_log( + "Meshtastic RX protobuf failed", + "Channel hash matched but Data protobuf did not decode. Check PSK/channel settings if this repeats.", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "from/to/id", + .text = node_hex(header.from) + "/" + node_hex(header.to) + + "/" + node_hex(header.id), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "plain", + .text = ::platform::linux_runtime::hex_bytes(plaintext, + plaintext_len), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "pb", + .text = pb_error, + }}); + return true; + } + + const ::chat::RxMeta rx_meta = make_meshtastic_rx_meta(header, packet); + const bool encrypted = psk != nullptr && psk_len > 0; + const auto channel_index = static_cast(channel); + const bool via_mqtt_packet = + (header.flags & ::chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0; + + if (decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP || + decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) + { + ::chat::MeshIncomingText text{}; + if (!::chat::meshtastic::decodeTextPayload(decoded, &text)) + { + append_lora_system_log( + "Meshtastic text decode failed", + "Data protobuf decoded but text payload was invalid.", + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "port", + .text = meshtastic_port_label(decoded.portnum), + }}); + return true; + } + text.channel = channel; + text.from = header.from; + text.to = header.to; + text.msg_id = header.id; + text.timestamp = rx_meta.rx_timestamp_s; + text.hop_limit = + header.flags & ::chat::meshtastic::PACKET_FLAGS_HOP_LIMIT_MASK; + text.encrypted = encrypted; + text.rx_meta = rx_meta; + incoming_text_.push_back(text); + + append_lora_system_log( + "Meshtastic text RX", + "From " + node_hex(header.from) + " to " + node_hex(header.to) + + " id " + node_hex(header.id) + ": " + + compact_text(text.text), + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "air", + .text = "ch=" + hex_u8(header.channel) + + " flags=" + hex_u8(header.flags), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "text", + .text = compact_text(text.text), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "signal", + .text = packet_signal_text(packet), + }}); + if (via_mqtt_packet) + { + append_mqtt_system_log( + "Meshtastic MQTT text RX", + "From " + node_hex(header.from) + " to " + + node_hex(header.to) + " id " + node_hex(header.id) + + ": " + compact_text(text.text), + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "air", + .text = "ch=" + hex_u8(header.channel) + + " flags=" + hex_u8(header.flags), + }, + { + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "text", + .text = compact_text(text.text), + }}); + } + return true; + } + + if (decoded.portnum == meshtastic_PortNum_NODEINFO_APP && + decoded.payload.size > 0) + { + ::chat::meshtastic::NodePayloadDecodeContext context{}; + context.fallback_node_id = header.from; + context.snr = packet.snr_db; + context.rssi = packet.rssi_dbm; + context.timestamp = rx_meta.rx_timestamp_s; + context.hops_away = rx_meta.hop_count; + context.channel = channel_index; + context.via_mqtt = via_mqtt_packet; + + ::chat::meshtastic::DecodedNodePayload node{}; + if (::chat::meshtastic::decodeNodeInfoPayload(decoded, context, &node)) + { + ::sys::EventBus::publish( + new ::sys::NodeInfoUpdateEvent( + node.node_id, + node.short_name.c_str(), + node.long_name.c_str(), + node.snr, + node.rssi, + node.timestamp, + node.protocol, + node.role, + node.hops_away, + node.hw_model, + node.channel, + node.has_macaddr, + node.has_macaddr ? node.macaddr.data() : nullptr, + node.via_mqtt, + node.is_ignored, + node.has_public_key, + node.key_manually_verified, + node.has_device_metrics, + node.has_device_metrics ? &node.device_metrics : nullptr), + 0); + if (node.has_position) + { + ::sys::EventBus::publish( + new ::sys::NodePositionUpdateEvent( + node.node_id, + node.position.latitude_i, + node.position.longitude_i, + node.position.has_altitude, + node.position.altitude, + node.position.timestamp, + node.position.precision_bits, + node.position.pdop, + node.position.hdop, + node.position.vdop, + node.position.gps_accuracy_mm), + 0); + } + append_lora_system_log( + "Meshtastic nodeinfo RX", + "From " + node_hex(header.from) + " node " + + node_hex(node.node_id) + " / " + node.short_name + " / " + + node.long_name, + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "air", + .text = "ch=" + hex_u8(header.channel) + + " flags=" + hex_u8(header.flags), + }, + { + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "node", + .text = std::string(node.via_mqtt ? "mqtt " : "mesh ") + + (node.has_position ? "position " : "") + + (node.has_device_metrics ? "metrics" : ""), + }, + { + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "signal", + .text = packet_signal_text(packet), + }}); + if (via_mqtt_packet || node.via_mqtt) + { + append_mqtt_system_log( + "Meshtastic MQTT nodeinfo RX", + "From " + node_hex(header.from) + " node " + + node_hex(node.node_id) + " / " + node.short_name + + " / " + node.long_name, + {{ + .kind = ::platform::linux_runtime:: + PacketLogSegmentKind::Header, + .label = "air", + .text = "ch=" + hex_u8(header.channel) + + " flags=" + hex_u8(header.flags), + }, + { + .kind = ::platform::linux_runtime:: + PacketLogSegmentKind::Body, + .label = "node", + .text = std::string(node.has_position ? "position " : "") + + (node.has_device_metrics ? "metrics" : ""), + }}); + } + } + else + { + const std::string reason = describe_nodeinfo_payload_failure(decoded); + append_lora_system_log( + "Meshtastic nodeinfo decode failed", + "NODEINFO_APP payload was neither Meshtastic NodeInfo nor legacy User.", + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "reason", + .text = reason, + }, + { + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "payload", + .text = ::platform::linux_runtime::hex_bytes( + decoded.payload.bytes, + decoded.payload.size), + }}); + } + return true; + } + + if (decoded.portnum == meshtastic_PortNum_POSITION_APP && + decoded.payload.size > 0) + { + ::chat::meshtastic::DecodedPositionPayload position{}; + if (::chat::meshtastic::decodePositionPayload(decoded, + header.from, + rx_meta.rx_timestamp_s, + &position)) + { + ::sys::EventBus::publish( + new ::sys::NodePositionUpdateEvent( + position.node_id, + position.position.latitude_i, + position.position.longitude_i, + position.position.has_altitude, + position.position.altitude, + position.position.timestamp, + position.position.precision_bits, + position.position.pdop, + position.position.hdop, + position.position.vdop, + position.position.gps_accuracy_mm), + 0); + append_lora_system_log( + "Meshtastic position RX", + "From " + node_hex(header.from) + " position payload.", + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "signal", + .text = packet_signal_text(packet), + }}); + if (via_mqtt_packet) + { + append_mqtt_system_log( + "Meshtastic MQTT position RX", + "From " + node_hex(header.from) + " position payload.", + {{ + .kind = ::platform::linux_runtime:: + PacketLogSegmentKind::Body, + .label = "pos", + .text = ::platform::linux_runtime::hex_bytes( + decoded.payload.bytes, + decoded.payload.size), + }}); + } + } + else + { + const std::string reason = describe_position_payload_failure(decoded); + append_lora_system_log( + "Meshtastic position decode failed", + "POSITION_APP payload did not contain a valid latitude and longitude.", + {{ + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "reason", + .text = reason, + }, + { + .kind = + ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "payload", + .text = ::platform::linux_runtime::hex_bytes( + decoded.payload.bytes, + decoded.payload.size), + }}); + } + return true; + } + + if (decoded.portnum == meshtastic_PortNum_ROUTING_APP) + { + append_lora_system_log( + "Meshtastic routing RX", + "Routing payload from " + node_hex(header.from) + ".", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "signal", + .text = packet_signal_text(packet), + }}); + return true; + } + + ::chat::MeshIncomingData app{}; + if (::chat::meshtastic::decodeAppPayload(decoded, &app)) + { + app.from = header.from; + app.to = header.to; + app.packet_id = header.id; + app.channel = channel; + app.channel_hash = header.channel; + app.hop_limit = + header.flags & ::chat::meshtastic::PACKET_FLAGS_HOP_LIMIT_MASK; + app.rx_meta = rx_meta; + incoming_data_.push_back(std::move(app)); + } + append_lora_system_log( + "Meshtastic appdata RX", + std::string("Port ") + meshtastic_port_label(decoded.portnum) + + " from " + node_hex(header.from) + " len " + + std::to_string(decoded.payload.size) + ".", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "air", + .text = "ch=" + hex_u8(header.channel) + + " flags=" + hex_u8(header.flags), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "payload", + .text = ::platform::linux_runtime::hex_bytes(decoded.payload.bytes, + decoded.payload.size), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Meta, + .label = "signal", + .text = packet_signal_text(packet), + }}); + if (via_mqtt_packet) + { + append_mqtt_system_log( + "Meshtastic MQTT appdata RX", + std::string("Port ") + meshtastic_port_label(decoded.portnum) + + " from " + node_hex(header.from) + " len " + + std::to_string(decoded.payload.size) + ".", + {{ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "air", + .text = "ch=" + hex_u8(header.channel) + + " flags=" + hex_u8(header.flags), + }, + { + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "payload", + .text = ::platform::linux_runtime::hex_bytes( + decoded.payload.bytes, + decoded.payload.size), + }}); + } + return true; +} + +} // namespace trailmate::linux_app diff --git a/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp b/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp new file mode 100644 index 00000000..36d98ae3 --- /dev/null +++ b/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp @@ -0,0 +1,691 @@ +#include "chat/linux_sqlite_chat_store.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "platform/linux/runtime_paths.h" + +namespace trailmate::linux_app +{ +namespace +{ + +sqlite3* openDatabase() +{ + const std::filesystem::path db_path = + ::platform::linux_runtime::sqlite_database_path(); + if (!::platform::linux_runtime::ensure_directory(db_path.parent_path())) + { + return nullptr; + } + + sqlite3* db = nullptr; + const int rc = sqlite3_open_v2(db_path.string().c_str(), + &db, + SQLITE_OPEN_READWRITE | + SQLITE_OPEN_CREATE | + SQLITE_OPEN_FULLMUTEX, + nullptr); + if (rc != SQLITE_OK) + { + if (db != nullptr) + { + sqlite3_close(db); + } + return nullptr; + } + + sqlite3_busy_timeout(db, 5000); + return db; +} + +bool execSql(sqlite3* db, const char* sql) +{ + if (db == nullptr || sql == nullptr) + { + return false; + } + + char* error = nullptr; + const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &error); + if (error != nullptr) + { + sqlite3_free(error); + } + return rc == SQLITE_OK; +} + +bool ensureSchema(sqlite3* db) +{ + return execSql(db, "PRAGMA busy_timeout=5000;") && + execSql(db, "PRAGMA journal_mode=WAL;") && + execSql(db, + "CREATE TABLE IF NOT EXISTS chat_messages (" + "sequence INTEGER PRIMARY KEY AUTOINCREMENT," + "protocol INTEGER NOT NULL," + "channel INTEGER NOT NULL," + "peer INTEGER NOT NULL," + "from_node INTEGER NOT NULL," + "msg_id INTEGER NOT NULL," + "timestamp INTEGER NOT NULL," + "text TEXT NOT NULL," + "team_location_icon INTEGER NOT NULL DEFAULT 0," + "has_geo INTEGER NOT NULL DEFAULT 0," + "geo_lat_e7 INTEGER NOT NULL DEFAULT 0," + "geo_lon_e7 INTEGER NOT NULL DEFAULT 0," + "status INTEGER NOT NULL" + ");") && + execSql(db, + "CREATE UNIQUE INDEX IF NOT EXISTS " + "chat_messages_unique_incoming " + "ON chat_messages(protocol, channel, peer, from_node, " + "msg_id) " + "WHERE status=0 AND msg_id != 0;") && + execSql(db, + "CREATE INDEX IF NOT EXISTS " + "chat_messages_conversation_idx " + "ON chat_messages(protocol, channel, peer, sequence);") && + execSql(db, + "CREATE INDEX IF NOT EXISTS chat_messages_msg_id_idx " + "ON chat_messages(msg_id, from_node, sequence);") && + execSql(db, + "CREATE TABLE IF NOT EXISTS chat_unread (" + "protocol INTEGER NOT NULL," + "channel INTEGER NOT NULL," + "peer INTEGER NOT NULL," + "unread INTEGER NOT NULL DEFAULT 0," + "PRIMARY KEY(protocol, channel, peer)" + ");"); +} + +struct DatabaseHandle +{ + sqlite3* db = nullptr; + + DatabaseHandle() + { + db = openDatabase(); + if (db != nullptr && !ensureSchema(db)) + { + sqlite3_close(db); + db = nullptr; + } + } + + ~DatabaseHandle() + { + if (db != nullptr) + { + sqlite3_close(db); + } + } + + DatabaseHandle(const DatabaseHandle&) = delete; + DatabaseHandle& operator=(const DatabaseHandle&) = delete; + + explicit operator bool() const noexcept + { + return db != nullptr; + } +}; + +int protocolValue(::chat::MeshProtocol protocol) +{ + return static_cast(protocol); +} + +int channelValue(::chat::ChannelId channel) +{ + return static_cast(channel); +} + +int statusValue(::chat::MessageStatus status) +{ + return static_cast(status); +} + +::chat::MeshProtocol protocolFromInt(int value) +{ + switch (value) + { + case static_cast(::chat::MeshProtocol::MeshCore): + return ::chat::MeshProtocol::MeshCore; + case static_cast(::chat::MeshProtocol::RNode): + return ::chat::MeshProtocol::RNode; + case static_cast(::chat::MeshProtocol::LXMF): + return ::chat::MeshProtocol::LXMF; + case static_cast(::chat::MeshProtocol::Meshtastic): + default: + return ::chat::MeshProtocol::Meshtastic; + } +} + +::chat::ChannelId channelFromInt(int value) +{ + switch (value) + { + case static_cast(::chat::ChannelId::SECONDARY): + return ::chat::ChannelId::SECONDARY; + case static_cast(::chat::ChannelId::PRIMARY): + default: + return ::chat::ChannelId::PRIMARY; + } +} + +::chat::MessageStatus statusFromInt(int value) +{ + switch (value) + { + case static_cast(::chat::MessageStatus::Queued): + return ::chat::MessageStatus::Queued; + case static_cast(::chat::MessageStatus::Sent): + return ::chat::MessageStatus::Sent; + case static_cast(::chat::MessageStatus::Failed): + return ::chat::MessageStatus::Failed; + case static_cast(::chat::MessageStatus::Incoming): + default: + return ::chat::MessageStatus::Incoming; + } +} + +bool bindConversation(sqlite3_stmt* stmt, + int first_index, + const ::chat::ConversationId& conv) +{ + return sqlite3_bind_int(stmt, first_index, protocolValue(conv.protocol)) == + SQLITE_OK && + sqlite3_bind_int(stmt, + first_index + 1, + channelValue(conv.channel)) == SQLITE_OK && + sqlite3_bind_int64(stmt, + first_index + 2, + static_cast(conv.peer)) == + SQLITE_OK; +} + +bool bindMessage(sqlite3_stmt* stmt, const ::chat::ChatMessage& msg) +{ + return sqlite3_bind_int(stmt, 1, protocolValue(msg.protocol)) == + SQLITE_OK && + sqlite3_bind_int(stmt, 2, channelValue(msg.channel)) == SQLITE_OK && + sqlite3_bind_int64(stmt, + 3, + static_cast(msg.peer)) == + SQLITE_OK && + sqlite3_bind_int64(stmt, + 4, + static_cast(msg.from)) == + SQLITE_OK && + sqlite3_bind_int64(stmt, + 5, + static_cast(msg.msg_id)) == + SQLITE_OK && + sqlite3_bind_int64(stmt, + 6, + static_cast(msg.timestamp)) == + SQLITE_OK && + sqlite3_bind_text(stmt, + 7, + msg.text.c_str(), + -1, + SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_bind_int(stmt, 8, msg.team_location_icon) == SQLITE_OK && + sqlite3_bind_int(stmt, 9, msg.has_geo ? 1 : 0) == SQLITE_OK && + sqlite3_bind_int64(stmt, + 10, + static_cast(msg.geo_lat_e7)) == + SQLITE_OK && + sqlite3_bind_int64(stmt, + 11, + static_cast(msg.geo_lon_e7)) == + SQLITE_OK && + sqlite3_bind_int(stmt, 12, statusValue(msg.status)) == SQLITE_OK; +} + +::chat::ChatMessage readMessage(sqlite3_stmt* stmt, int first_column) +{ + ::chat::ChatMessage msg{}; + msg.protocol = protocolFromInt(sqlite3_column_int(stmt, first_column)); + msg.channel = channelFromInt(sqlite3_column_int(stmt, first_column + 1)); + msg.peer = static_cast<::chat::NodeId>( + sqlite3_column_int64(stmt, first_column + 2)); + msg.from = static_cast<::chat::NodeId>( + sqlite3_column_int64(stmt, first_column + 3)); + msg.msg_id = static_cast<::chat::MessageId>( + sqlite3_column_int64(stmt, first_column + 4)); + msg.timestamp = static_cast( + sqlite3_column_int64(stmt, first_column + 5)); + const unsigned char* text = sqlite3_column_text(stmt, first_column + 6); + msg.text = text != nullptr + ? reinterpret_cast(text) + : ""; + msg.team_location_icon = + static_cast(sqlite3_column_int(stmt, first_column + 7)); + msg.has_geo = sqlite3_column_int(stmt, first_column + 8) != 0; + msg.geo_lat_e7 = static_cast( + sqlite3_column_int64(stmt, first_column + 9)); + msg.geo_lon_e7 = static_cast( + sqlite3_column_int64(stmt, first_column + 10)); + msg.status = statusFromInt(sqlite3_column_int(stmt, first_column + 11)); + return msg; +} + +std::string conversationName(const ::chat::ConversationId& conv) +{ + if (conv.peer == 0) + { + return "Broadcast"; + } + + char buffer[16] = {}; + std::snprintf(buffer, + sizeof(buffer), + "%04lX", + static_cast(conv.peer & 0xFFFFU)); + return buffer; +} + +} // namespace + +LinuxSqliteChatStore::LinuxSqliteChatStore() = default; + +LinuxSqliteChatStore::~LinuxSqliteChatStore() = default; + +void LinuxSqliteChatStore::append(const ::chat::ChatMessage& msg) +{ + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + return; + } + + (void)execSql(handle.db, "BEGIN IMMEDIATE;"); + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "INSERT OR IGNORE INTO chat_messages(" + "protocol, channel, peer, from_node, msg_id, timestamp, text, " + "team_location_icon, has_geo, geo_lat_e7, geo_lon_e7, status) " + "VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12);"; + bool inserted = false; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == SQLITE_OK) + { + inserted = + bindMessage(stmt, msg) && sqlite3_step(stmt) == SQLITE_DONE && + sqlite3_changes(handle.db) > 0; + } + sqlite3_finalize(stmt); + + if (inserted && msg.status == ::chat::MessageStatus::Incoming) + { + const ::chat::ConversationId conv(msg.channel, msg.peer, msg.protocol); + constexpr const char* kUnreadSql = + "INSERT INTO chat_unread(protocol, channel, peer, unread) " + "VALUES(?1, ?2, ?3, 1) " + "ON CONFLICT(protocol, channel, peer) DO UPDATE SET " + "unread=chat_unread.unread + 1;"; + if (sqlite3_prepare_v2(handle.db, + kUnreadSql, + -1, + &stmt, + nullptr) == SQLITE_OK) + { + (void)(bindConversation(stmt, 1, conv) && + sqlite3_step(stmt) == SQLITE_DONE); + } + sqlite3_finalize(stmt); + } + + (void)execSql(handle.db, "COMMIT;"); +} + +std::vector<::chat::ChatMessage> LinuxSqliteChatStore::loadRecent( + const ::chat::ConversationId& conv, + std::size_t n) +{ + if (n == 0U) + { + return {}; + } + + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + return {}; + } + + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "SELECT protocol, channel, peer, from_node, msg_id, timestamp, text, " + "team_location_icon, has_geo, geo_lat_e7, geo_lon_e7, status " + "FROM chat_messages " + "WHERE protocol=?1 AND channel=?2 AND peer=?3 " + "ORDER BY sequence DESC LIMIT ?4;"; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) != SQLITE_OK) + { + return {}; + } + + std::vector<::chat::ChatMessage> result; + if (bindConversation(stmt, 1, conv) && + sqlite3_bind_int64(stmt, 4, static_cast(n)) == + SQLITE_OK) + { + while (sqlite3_step(stmt) == SQLITE_ROW) + { + result.push_back(readMessage(stmt, 0)); + } + } + sqlite3_finalize(stmt); + std::reverse(result.begin(), result.end()); + return result; +} + +std::vector<::chat::ConversationMeta> LinuxSqliteChatStore::loadConversationPage( + std::size_t offset, + std::size_t limit, + std::size_t* total) +{ + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + if (total != nullptr) + { + *total = 0; + } + return {}; + } + + if (total != nullptr) + { + sqlite3_stmt* count_stmt = nullptr; + constexpr const char* kCountSql = + "SELECT COUNT(*) FROM (" + "SELECT 1 FROM chat_messages GROUP BY protocol, channel, peer" + ");"; + *total = 0; + if (sqlite3_prepare_v2(handle.db, + kCountSql, + -1, + &count_stmt, + nullptr) == SQLITE_OK && + sqlite3_step(count_stmt) == SQLITE_ROW) + { + *total = static_cast( + std::max(0, sqlite3_column_int64(count_stmt, 0))); + } + sqlite3_finalize(count_stmt); + } + + std::string sql = + "WITH latest AS (" + "SELECT protocol, channel, peer, MAX(sequence) AS sequence " + "FROM chat_messages GROUP BY protocol, channel, peer" + ") " + "SELECT m.protocol, m.channel, m.peer, m.text, m.timestamp, " + "COALESCE(u.unread, 0), m.sequence " + "FROM latest l " + "JOIN chat_messages m ON m.sequence=l.sequence " + "LEFT JOIN chat_unread u ON u.protocol=m.protocol " + "AND u.channel=m.channel AND u.peer=m.peer " + "ORDER BY m.timestamp DESC, m.sequence DESC"; + if (limit != 0U) + { + sql += " LIMIT ?1 OFFSET ?2"; + } + else if (offset != 0U) + { + sql += " LIMIT -1 OFFSET ?1"; + } + sql += ";"; + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(handle.db, sql.c_str(), -1, &stmt, nullptr) != + SQLITE_OK) + { + return {}; + } + + bool ok = true; + if (limit != 0U) + { + ok = sqlite3_bind_int64(stmt, + 1, + static_cast(limit)) == + SQLITE_OK && + sqlite3_bind_int64(stmt, + 2, + static_cast(offset)) == + SQLITE_OK; + } + else if (offset != 0U) + { + ok = sqlite3_bind_int64(stmt, + 1, + static_cast(offset)) == + SQLITE_OK; + } + + std::vector<::chat::ConversationMeta> list; + if (ok) + { + while (sqlite3_step(stmt) == SQLITE_ROW) + { + ::chat::ConversationMeta meta{}; + meta.id.protocol = protocolFromInt(sqlite3_column_int(stmt, 0)); + meta.id.channel = channelFromInt(sqlite3_column_int(stmt, 1)); + meta.id.peer = static_cast<::chat::NodeId>( + sqlite3_column_int64(stmt, 2)); + const unsigned char* preview = sqlite3_column_text(stmt, 3); + meta.preview = preview != nullptr + ? reinterpret_cast(preview) + : ""; + meta.last_timestamp = static_cast( + sqlite3_column_int64(stmt, 4)); + meta.unread = sqlite3_column_int(stmt, 5); + meta.name = conversationName(meta.id); + list.push_back(std::move(meta)); + } + } + sqlite3_finalize(stmt); + return list; +} + +void LinuxSqliteChatStore::setUnread(const ::chat::ConversationId& conv, + int unread) +{ + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + return; + } + + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "INSERT INTO chat_unread(protocol, channel, peer, unread) " + "VALUES(?1, ?2, ?3, ?4) " + "ON CONFLICT(protocol, channel, peer) DO UPDATE SET " + "unread=excluded.unread;"; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == SQLITE_OK) + { + (void)(bindConversation(stmt, 1, conv) && + sqlite3_bind_int(stmt, 4, std::max(0, unread)) == SQLITE_OK && + sqlite3_step(stmt) == SQLITE_DONE); + } + sqlite3_finalize(stmt); +} + +int LinuxSqliteChatStore::getUnread( + const ::chat::ConversationId& conv) const +{ + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + return 0; + } + + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "SELECT unread FROM chat_unread " + "WHERE protocol=?1 AND channel=?2 AND peer=?3;"; + int unread = 0; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == SQLITE_OK && + bindConversation(stmt, 1, conv) && + sqlite3_step(stmt) == SQLITE_ROW) + { + unread = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return std::max(0, unread); +} + +void LinuxSqliteChatStore::clearConversation( + const ::chat::ConversationId& conv) +{ + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + return; + } + + (void)execSql(handle.db, "BEGIN IMMEDIATE;"); + sqlite3_stmt* stmt = nullptr; + constexpr const char* kDeleteMessages = + "DELETE FROM chat_messages " + "WHERE protocol=?1 AND channel=?2 AND peer=?3;"; + if (sqlite3_prepare_v2(handle.db, + kDeleteMessages, + -1, + &stmt, + nullptr) == SQLITE_OK) + { + (void)(bindConversation(stmt, 1, conv) && + sqlite3_step(stmt) == SQLITE_DONE); + } + sqlite3_finalize(stmt); + + constexpr const char* kDeleteUnread = + "DELETE FROM chat_unread " + "WHERE protocol=?1 AND channel=?2 AND peer=?3;"; + if (sqlite3_prepare_v2(handle.db, + kDeleteUnread, + -1, + &stmt, + nullptr) == SQLITE_OK) + { + (void)(bindConversation(stmt, 1, conv) && + sqlite3_step(stmt) == SQLITE_DONE); + } + sqlite3_finalize(stmt); + (void)execSql(handle.db, "COMMIT;"); +} + +void LinuxSqliteChatStore::clearAll() +{ + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + return; + } + + (void)execSql(handle.db, "BEGIN IMMEDIATE;"); + (void)execSql(handle.db, "DELETE FROM chat_messages;"); + (void)execSql(handle.db, "DELETE FROM chat_unread;"); + (void)execSql(handle.db, + "DELETE FROM sqlite_sequence WHERE name='chat_messages';"); + (void)execSql(handle.db, "COMMIT;"); +} + +bool LinuxSqliteChatStore::updateMessageStatus( + ::chat::MessageId msg_id, + ::chat::MessageStatus status) +{ + if (msg_id == 0) + { + return false; + } + + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + return false; + } + + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "UPDATE chat_messages SET status=?1 " + "WHERE sequence=(" + "SELECT sequence FROM chat_messages " + "WHERE msg_id=?2 AND from_node=0 " + "ORDER BY sequence DESC LIMIT 1" + ");"; + bool ok = false; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == SQLITE_OK) + { + ok = sqlite3_bind_int(stmt, 1, statusValue(status)) == SQLITE_OK && + sqlite3_bind_int64(stmt, + 2, + static_cast(msg_id)) == + SQLITE_OK && + sqlite3_step(stmt) == SQLITE_DONE && + sqlite3_changes(handle.db) > 0; + } + sqlite3_finalize(stmt); + return ok; +} + +bool LinuxSqliteChatStore::getMessage(::chat::MessageId msg_id, + ::chat::ChatMessage* out) const +{ + if (msg_id == 0) + { + return false; + } + + std::lock_guard lock(mutex_); + DatabaseHandle handle; + if (!handle) + { + return false; + } + + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "SELECT protocol, channel, peer, from_node, msg_id, timestamp, text, " + "team_location_icon, has_geo, geo_lat_e7, geo_lon_e7, status " + "FROM chat_messages " + "WHERE msg_id=?1 ORDER BY sequence DESC LIMIT 1;"; + bool found = false; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == SQLITE_OK && + sqlite3_bind_int64(stmt, 1, static_cast(msg_id)) == + SQLITE_OK && + sqlite3_step(stmt) == SQLITE_ROW) + { + if (out != nullptr) + { + *out = readMessage(stmt, 0); + } + found = true; + } + sqlite3_finalize(stmt); + return found; +} + +void LinuxSqliteChatStore::flush() +{ +} + +} // namespace trailmate::linux_app diff --git a/platform/linux/common/src/platform/linux/map_contour_tile_generator.cpp b/platform/linux/common/src/platform/linux/map_contour_tile_generator.cpp new file mode 100644 index 00000000..df6b0ad3 --- /dev/null +++ b/platform/linux/common/src/platform/linux/map_contour_tile_generator.cpp @@ -0,0 +1,1080 @@ +#include "platform/linux/map_contour_tile_generator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +#include + +#include "platform/linux/map_diagnostics.h" +#include "platform/linux/runtime_paths.h" + +namespace platform::linux_runtime +{ +namespace +{ + +constexpr int kTileSize = 256; +constexpr double kPi = 3.14159265358979323846; +constexpr long kCmrTimeoutSeconds = 30; +constexpr long kDemDownloadTimeoutSeconds = 300; +constexpr const char* kUserAgent = "TrailMate-uConsole/0.1"; +constexpr const char* kCmrGranulesUrl = + "https://cmr.earthdata.nasa.gov/search/granules.json"; + +struct TileBounds +{ + double west = 0.0; + double south = 0.0; + double east = 0.0; + double north = 0.0; +}; + +struct HttpResult +{ + bool ok = false; + long status = 0; + std::string body{}; + std::string error{}; +}; + +std::filesystem::path dem_root() +{ + return resolve_paths().sd_root / "maps" / "dem"; +} + +std::filesystem::path contour_work_root() +{ + return resolve_paths().cache_root / "contour-work"; +} + +std::string trim_copy(std::string value) +{ + const auto not_space = [](unsigned char ch) + { + return std::isspace(ch) == 0; + }; + value.erase(value.begin(), + std::find_if(value.begin(), value.end(), not_space)); + value.erase(std::find_if(value.rbegin(), value.rend(), not_space).base(), + value.end()); + return value; +} + +std::string fmt_double(double value) +{ + char buffer[64] = {}; + std::snprintf(buffer, sizeof(buffer), "%.17g", value); + return std::string(buffer); +} + +std::string shell_quote(std::string_view value) +{ + if (value.empty()) + { + return "''"; + } + + std::string out = "'"; + for (const char ch : value) + { + if (ch == '\'') + { + out += "'\\''"; + } + else + { + out.push_back(ch); + } + } + out.push_back('\''); + return out; +} + +std::string path_quote(const std::filesystem::path& path) +{ + return shell_quote(path.string()); +} + +int run_command(const std::string& command) +{ + const int rc = std::system(command.c_str()); + if (rc == -1) + { + append_map_diagnostic("contour", + "command failed to start: " + command); + return -1; + } +#if !defined(_WIN32) + if (WIFEXITED(rc)) + { + const int exit_code = WEXITSTATUS(rc); + if (exit_code != 0) + { + append_map_diagnostic( + "contour", + "command exit " + std::to_string(exit_code) + ": " + + command); + } + return exit_code; + } +#endif + if (rc != 0) + { + append_map_diagnostic("contour", + "command rc " + std::to_string(rc) + ": " + + command); + } + return rc; +} + +bool command_available(const char* name) +{ + std::string command = "command -v "; + command += shell_quote(name); + command += " >/dev/null 2>&1"; + return run_command(command) == 0; +} + +TileBounds tile_to_bounds(int x, int y, int zoom) +{ + const double n = std::pow(2.0, static_cast(zoom)); + TileBounds out{}; + out.west = static_cast(x) / n * 360.0 - 180.0; + out.east = static_cast(x + 1) / n * 360.0 - 180.0; + out.north = + std::atan(std::sinh(kPi * (1.0 - 2.0 * y / n))) * 180.0 / + kPi; + out.south = + std::atan(std::sinh(kPi * (1.0 - 2.0 * (y + 1) / n))) * + 180.0 / kPi; + return out; +} + +std::size_t write_string_callback(char* ptr, + std::size_t size, + std::size_t nmemb, + void* userdata) +{ + auto* out = static_cast(userdata); + const std::size_t bytes = size * nmemb; + if (out == nullptr) + { + return 0; + } + out->append(ptr, bytes); + return bytes; +} + +struct FileDownloadContext +{ + std::ofstream stream; +}; + +std::size_t write_file_callback(char* ptr, + std::size_t size, + std::size_t nmemb, + void* userdata) +{ + auto* ctx = static_cast(userdata); + const std::size_t bytes = size * nmemb; + if (ctx == nullptr || !ctx->stream.is_open()) + { + return 0; + } + ctx->stream.write(ptr, static_cast(bytes)); + return ctx->stream.good() ? bytes : 0; +} + +std::uintmax_t file_size_or_zero(const std::filesystem::path& path) +{ + std::error_code ec; + const auto size = std::filesystem::file_size(path, ec); + return ec ? 0U : size; +} + +std::string curl_escape(const std::string& value) +{ + CURL* curl = curl_easy_init(); + if (curl == nullptr) + { + return value; + } + char* escaped = curl_easy_escape(curl, + value.c_str(), + static_cast(value.size())); + std::string out = escaped != nullptr ? escaped : value; + if (escaped != nullptr) + { + curl_free(escaped); + } + curl_easy_cleanup(curl); + return out; +} + +HttpResult fetch_string(const std::string& url) +{ + HttpResult out{}; + CURL* curl = curl_easy_init(); + if (curl == nullptr) + { + out.error = "Cannot initialize libcurl."; + return out; + } + + struct curl_slist* headers = nullptr; + headers = curl_slist_append(headers, "Accept: application/json"); + char error_buffer[CURL_ERROR_SIZE] = {}; + apply_map_curl_resolver(curl); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_USERAGENT, kUserAgent); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, kCmrTimeoutSeconds); + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, error_buffer); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_string_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out.body); + + const CURLcode rc = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out.status); + if (headers != nullptr) + { + curl_slist_free_all(headers); + } + curl_easy_cleanup(curl); + + if (rc != CURLE_OK) + { + out.error = curl_error_message(rc, error_buffer); + append_map_diagnostic("contour", + "CMR GET failed: " + out.error + " / " + + url); + return out; + } + if (out.status < 200 || out.status >= 300) + { + out.error = "HTTP " + std::to_string(out.status); + append_map_diagnostic("contour", + "CMR GET returned " + out.error + " / " + + url); + return out; + } + out.ok = true; + append_map_diagnostic("contour", + "CMR GET ok HTTP " + std::to_string(out.status) + + " / " + std::to_string(out.body.size()) + + " bytes / " + url); + return out; +} + +HttpResult download_file(const std::string& url, + const std::filesystem::path& path, + const std::string& bearer_token) +{ + HttpResult out{}; + if (!ensure_directory(path.parent_path())) + { + out.error = "Cannot create DEM cache directory."; + return out; + } + + const auto temp_path = path.string() + ".tmp"; + FileDownloadContext ctx{}; + ctx.stream.open(temp_path, std::ios::binary | std::ios::trunc); + if (!ctx.stream.is_open()) + { + out.error = "Cannot create DEM temp file."; + return out; + } + + CURL* curl = curl_easy_init(); + if (curl == nullptr) + { + ctx.stream.close(); + std::error_code ec; + std::filesystem::remove(temp_path, ec); + out.error = "Cannot initialize libcurl."; + return out; + } + + struct curl_slist* headers = nullptr; + if (!bearer_token.empty()) + { + const std::string auth = "Authorization: Bearer " + bearer_token; + headers = curl_slist_append(headers, auth.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + } + + char error_buffer[CURL_ERROR_SIZE] = {}; + apply_map_curl_resolver(curl); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_USERAGENT, kUserAgent); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, kDemDownloadTimeoutSeconds); + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, error_buffer); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); + + const CURLcode rc = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out.status); + if (headers != nullptr) + { + curl_slist_free_all(headers); + } + curl_easy_cleanup(curl); + ctx.stream.close(); + + if (rc != CURLE_OK) + { + std::error_code ec; + std::filesystem::remove(temp_path, ec); + out.error = curl_error_message(rc, error_buffer); + append_map_diagnostic("contour", + "DEM download failed: " + out.error + " / " + + url); + return out; + } + if (out.status < 200 || out.status >= 300) + { + std::error_code ec; + std::filesystem::remove(temp_path, ec); + out.error = "HTTP " + std::to_string(out.status); + append_map_diagnostic("contour", + "DEM download returned " + out.error + " / " + + url); + return out; + } + + std::error_code ec; + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(temp_path, path, ec); + if (ec) + { + std::filesystem::remove(temp_path, ec); + out.error = "Cannot commit DEM file."; + append_map_diagnostic("contour", + out.error + " / " + path.string()); + return out; + } + + out.ok = true; + append_map_diagnostic("contour", + "DEM cached HTTP " + std::to_string(out.status) + + " / " + std::to_string(file_size_or_zero(path)) + + " bytes / " + path.string()); + return out; +} + +bool file_nonempty(const std::filesystem::path& path) +{ + std::error_code ec; + return std::filesystem::exists(path, ec) && + std::filesystem::file_size(path, ec) > 0U && !ec; +} + +std::string json_unescape_url(std::string value) +{ + std::string out; + out.reserve(value.size()); + for (std::size_t i = 0; i < value.size(); ++i) + { + if (value[i] != '\\' || i + 1 >= value.size()) + { + out.push_back(value[i]); + continue; + } + + const char escaped = value[++i]; + switch (escaped) + { + case '/': + case '\\': + case '"': + out.push_back(escaped); + break; + case 'n': + out.push_back('\n'); + break; + case 'r': + out.push_back('\r'); + break; + case 't': + out.push_back('\t'); + break; + default: + out.push_back(escaped); + break; + } + } + return out; +} + +bool ends_with_ci(std::string_view text, std::string_view suffix) +{ + if (text.size() < suffix.size()) + { + return false; + } + const auto start = text.size() - suffix.size(); + for (std::size_t i = 0; i < suffix.size(); ++i) + { + const auto lhs = + static_cast(text[start + i]); + const auto rhs = static_cast(suffix[i]); + if (std::tolower(lhs) != std::tolower(rhs)) + { + return false; + } + } + return true; +} + +bool contains_ci(std::string_view text, std::string_view needle) +{ + auto it = std::search(text.begin(), + text.end(), + needle.begin(), + needle.end(), + [](char lhs, char rhs) + { + return std::tolower( + static_cast(lhs)) == + std::tolower( + static_cast(rhs)); + }); + return it != text.end(); +} + +bool likely_dem_url(const std::string& url) +{ + return (ends_with_ci(url, ".zip") || ends_with_ci(url, ".hgt") || + ends_with_ci(url, ".tif")) && + (contains_ci(url, "lpdaac") || contains_ci(url, "e4ftl") || + contains_ci(url, "data.lpdaac")); +} + +int score_dem_url(const std::string& url) +{ + if ((contains_ci(url, "e4ftl") || contains_ci(url, "lpdaac")) && + !contains_ci(url, "earthdatacloud")) + { + return 0; + } + if (contains_ci(url, "earthdatacloud")) + { + return 2; + } + return 1; +} + +std::vector parse_dem_urls(const std::string& json) +{ + static const std::regex kHrefPattern( + R"TM("href"\s*:\s*"((?:[^"\\]|\\.)*)")TM", + std::regex_constants::icase); + std::vector urls; + std::set seen; + + for (auto it = std::sregex_iterator(json.begin(), json.end(), kHrefPattern); + it != std::sregex_iterator(); + ++it) + { + std::string url = json_unescape_url((*it)[1].str()); + if (!contains_ci(url, "https://") || !likely_dem_url(url)) + { + continue; + } + if (seen.insert(url).second) + { + urls.push_back(std::move(url)); + } + } + + std::sort(urls.begin(), + urls.end(), + [](const std::string& lhs, const std::string& rhs) + { + const int lhs_score = score_dem_url(lhs); + const int rhs_score = score_dem_url(rhs); + if (lhs_score != rhs_score) + { + return lhs_score < rhs_score; + } + return lhs < rhs; + }); + return urls; +} + +std::vector find_dem_urls(const TileBounds& bounds, + std::string& error) +{ + const std::string bbox = fmt_double(bounds.west) + "," + + fmt_double(bounds.south) + "," + + fmt_double(bounds.east) + "," + + fmt_double(bounds.north); + const std::string url = + std::string(kCmrGranulesUrl) + + "?short_name=NASADEM_HGT&version=001&bounding_box=" + + curl_escape(bbox) + "&page_size=2000"; + append_map_diagnostic("contour", "CMR search bbox " + bbox); + const HttpResult response = fetch_string(url); + if (!response.ok) + { + error = "CMR search failed: " + response.error; + return {}; + } + auto urls = parse_dem_urls(response.body); + if (urls.empty()) + { + error = "No NASADEM granules in current tile."; + } + append_map_diagnostic("contour", + "CMR search found " + std::to_string(urls.size()) + + " DEM URL(s) for bbox " + bbox); + return urls; +} + +std::string file_name_from_url(std::string url) +{ + const auto hash = url.find('#'); + if (hash != std::string::npos) + { + url.erase(hash); + } + const auto query = url.find('?'); + if (query != std::string::npos) + { + url.erase(query); + } + const auto slash = url.find_last_of('/'); + return slash == std::string::npos ? url : url.substr(slash + 1); +} + +std::vector find_files_with_extensions( + const std::filesystem::path& root, + const std::vector& extensions) +{ + std::vector out; + std::error_code ec; + if (!std::filesystem::exists(root, ec)) + { + return out; + } + + for (std::filesystem::recursive_directory_iterator it(root, ec), end; + it != end && !ec; + it.increment(ec)) + { + if (!it->is_regular_file(ec)) + { + continue; + } + const std::string ext = it->path().extension().string(); + if (std::find_if(extensions.begin(), + extensions.end(), + [&](const std::string& allowed) + { + return ends_with_ci(ext, allowed); + }) != extensions.end()) + { + out.push_back(it->path()); + } + } + return out; +} + +std::vector resolve_dem_files( + const std::filesystem::path& path, + std::string& error) +{ + if (ends_with_ci(path.string(), ".hgt") || ends_with_ci(path.string(), ".tif")) + { + return {path}; + } + if (!ends_with_ci(path.string(), ".zip")) + { + return {}; + } + if (!command_available("unzip")) + { + error = "unzip not found. Install unzip to extract NASADEM archives."; + return {}; + } + + const auto extracted_dir = + dem_root() / "extracted" / path.stem().string(); + if (!ensure_directory(extracted_dir)) + { + error = "Cannot create DEM extract directory."; + return {}; + } + + auto files = find_files_with_extensions(extracted_dir, {".hgt", ".tif"}); + if (!files.empty()) + { + return files; + } + + const std::string command = "unzip -o -q " + path_quote(path) + " -d " + + path_quote(extracted_dir); + if (run_command(command) != 0) + { + error = "Failed to extract NASADEM archive."; + append_map_diagnostic("contour", + error + " / " + path.string()); + return {}; + } + files = find_files_with_extensions(extracted_dir, {".hgt", ".tif"}); + if (files.empty()) + { + error = "NASADEM archive did not contain HGT/TIF files."; + append_map_diagnostic("contour", + error + " / " + path.string()); + } + return files; +} + +std::vector ensure_dem_files(const TileBounds& bounds, + const std::string& token, + std::string& error) +{ + if (!ensure_directory(dem_root())) + { + error = "Cannot create DEM cache directory."; + return {}; + } + + auto urls = find_dem_urls(bounds, error); + if (urls.empty()) + { + return {}; + } + + std::vector files; + std::set seen; + for (const auto& url : urls) + { + const std::string file_name = file_name_from_url(url); + if (file_name.empty()) + { + continue; + } + + const std::filesystem::path local_path = dem_root() / file_name; + if (!file_nonempty(local_path)) + { + append_map_diagnostic("contour", + "DEM selected " + file_name + " / " + url); + HttpResult download = download_file(url, local_path, token); + if (!download.ok && download.status == 400 && !token.empty()) + { + append_map_diagnostic( + "contour", + "DEM bearer download returned HTTP 400; retrying without token / " + + url); + download = download_file(url, local_path, ""); + } + if (!download.ok) + { + error = "DEM download failed: " + download.error; + return {}; + } + } + + std::string resolve_error{}; + auto local_files = resolve_dem_files(local_path, resolve_error); + if (!resolve_error.empty()) + { + error = resolve_error; + return {}; + } + for (const auto& file : local_files) + { + const std::string key = file.string(); + if (seen.insert(key).second) + { + files.push_back(file); + } + } + } + + if (files.empty() && error.empty()) + { + error = "No usable DEM files for current tile."; + } + return files; +} + +std::pair resolve_contour_source( + const MapContourProfile& profile, + const std::vector& profiles) +{ + if (profile.kind != MapContourKind::Major) + { + return {profile.interval_m, ""}; + } + + int minor_interval = 0; + for (const auto& candidate : profiles) + { + if (candidate.kind == MapContourKind::Minor && + candidate.interval_m < profile.interval_m && + profile.interval_m % candidate.interval_m == 0) + { + minor_interval = minor_interval == 0 + ? candidate.interval_m + : std::min(minor_interval, + candidate.interval_m); + } + } + + if (minor_interval <= 0) + { + return {profile.interval_m, ""}; + } + return {minor_interval, + "CAST(elev AS INTEGER) % " + std::to_string(profile.interval_m) + + " = 0"}; +} + +std::string profile_label(const MapContourProfile& profile) +{ + return map_contour_profile_key(profile); +} + +std::filesystem::path tile_work_dir(int z, int x, int y) +{ + return contour_work_root() / + (std::to_string(z) + "_" + std::to_string(x) + "_" + + std::to_string(y)); +} + +bool ensure_gdal_available(std::string& error) +{ + static const std::array kTools = { + "gdalbuildvrt", + "gdal_contour", + "gdal_rasterize", + "gdal_translate", + }; + if (!command_available("gdalinfo")) + { + error = "GDAL not found. Install gdal-bin."; + return false; + } + for (const auto* tool : kTools) + { + if (!command_available(tool)) + { + error = std::string(tool) + " not found. Install gdal-bin."; + return false; + } + } + return true; +} + +bool build_vrt(const TileBounds& bounds, + const std::filesystem::path& vrt_path, + const std::vector& dem_files, + std::string& error) +{ + std::ostringstream command; + command << "gdalbuildvrt -q -overwrite -te " << fmt_double(bounds.west) + << ' ' << fmt_double(bounds.south) << ' ' + << fmt_double(bounds.east) << ' ' << fmt_double(bounds.north) + << ' ' << path_quote(vrt_path); + for (const auto& file : dem_files) + { + command << ' ' << path_quote(file); + } + + if (run_command(command.str()) != 0) + { + error = "gdalbuildvrt failed."; + return false; + } + return true; +} + +bool generate_profile_png(const MapContourProfile& profile, + const std::vector& profiles, + const std::filesystem::path& work_dir, + const std::filesystem::path& vrt_path, + const TileBounds& bounds, + const std::filesystem::path& output_path, + std::map& contour_paths, + std::string& error) +{ + const auto [contour_interval, where_clause] = + resolve_contour_source(profile, profiles); + auto contour_it = contour_paths.find(contour_interval); + if (contour_it == contour_paths.end()) + { + const auto contour_path = + work_dir / ("contours_i" + std::to_string(contour_interval) + + ".geojson"); + if (!file_nonempty(contour_path)) + { + const std::string command = + "gdal_contour -q -i " + std::to_string(contour_interval) + + " -a elev -f GeoJSON " + path_quote(vrt_path) + " " + + path_quote(contour_path); + if (run_command(command) != 0) + { + error = "gdal_contour failed for " + + std::to_string(contour_interval) + " m."; + return false; + } + } + contour_it = contour_paths.emplace(contour_interval, contour_path).first; + } + + if (!ensure_directory(output_path.parent_path())) + { + error = "Cannot create contour output directory."; + return false; + } + + const auto raster_path = + work_dir / ("contours_" + profile_label(profile) + ".tif"); + const auto [r, g, b, a] = + profile.kind == MapContourKind::Major + ? std::array{214, 193, 145, 220} + : std::array{167, 149, 108, 190}; + + std::ostringstream rasterize; + rasterize << "gdal_rasterize -q "; + if (!where_clause.empty()) + { + rasterize << "-where " << shell_quote(where_clause) << ' '; + } + rasterize << "-burn " << r << " -burn " << g << " -burn " << b + << " -burn " << a + << " -init 0 0 0 0 -ts " << kTileSize << ' ' << kTileSize + << " -te " << fmt_double(bounds.west) << ' ' + << fmt_double(bounds.south) << ' ' << fmt_double(bounds.east) + << ' ' << fmt_double(bounds.north) + << " -a_nodata 0 -ot Byte -of GTiff " + << path_quote(contour_it->second) << ' ' + << path_quote(raster_path); + if (run_command(rasterize.str()) != 0) + { + error = "gdal_rasterize failed for " + profile_label(profile) + "."; + return false; + } + + const std::string translate = + "gdal_translate -q -of PNG -co ZLEVEL=1 " + path_quote(raster_path) + + " " + path_quote(output_path); + if (run_command(translate) != 0) + { + error = "gdal_translate failed for " + profile_label(profile) + "."; + return false; + } + return true; +} + +struct TileBatch +{ + int z = 0; + int x = 0; + int y = 0; + std::vector profiles{}; +}; + +bool same_profile(const MapContourProfile& lhs, const MapContourProfile& rhs) +{ + return lhs.kind == rhs.kind && lhs.interval_m == rhs.interval_m; +} + +std::vector group_tiles(const std::vector& tiles) +{ + std::map, std::vector> grouped; + for (auto tile : tiles) + { + normalize_map_contour_tile(tile); + auto& profiles = grouped[{tile.z, tile.x, tile.y}]; + if (std::find_if(profiles.begin(), + profiles.end(), + [&](const MapContourProfile& existing) + { + return same_profile(existing, tile.profile); + }) == profiles.end()) + { + profiles.push_back(tile.profile); + } + } + + std::vector out; + out.reserve(grouped.size()); + for (auto& [key, profiles] : grouped) + { + std::sort(profiles.begin(), + profiles.end(), + [](const MapContourProfile& lhs, + const MapContourProfile& rhs) + { + if (lhs.interval_m != rhs.interval_m) + { + return lhs.interval_m < rhs.interval_m; + } + return static_cast(lhs.kind) < + static_cast(rhs.kind); + }); + auto [z, x, y] = key; + out.push_back(TileBatch{ + .z = z, + .x = x, + .y = y, + .profiles = std::move(profiles), + }); + } + return out; +} + +} // namespace + +MapContourTileGenerator::MapContourTileGenerator() = default; + +MapContourGenerationResult MapContourTileGenerator::ensure_tiles( + const std::vector& tiles, + const std::string& earthdata_token) const +{ + MapContourGenerationResult result{}; + result.requested_tiles = tiles.size(); + if (tiles.empty()) + { + result.message = "No visible contour tiles to fill."; + return result; + } + + const std::string token = trim_copy(earthdata_token); + if (token.empty()) + { + result.failed_tiles = tiles.size(); + result.message = "Earthdata token missing."; + return result; + } + + std::string dependency_error{}; + if (!ensure_gdal_available(dependency_error)) + { + result.failed_tiles = tiles.size(); + result.message = dependency_error; + return result; + } + + std::string last_error{}; + for (const auto& batch : group_tiles(tiles)) + { + const auto bounds = tile_to_bounds(batch.x, batch.y, batch.z); + const auto work_dir = tile_work_dir(batch.z, batch.x, batch.y); + const auto vrt_path = work_dir / "dem.vrt"; + if (!ensure_directory(work_dir)) + { + last_error = "Cannot create contour work directory."; + result.failed_tiles += batch.profiles.size(); + continue; + } + + std::vector missing_profiles; + missing_profiles.reserve(batch.profiles.size()); + for (const auto& profile : batch.profiles) + { + MapContourTileId id{.profile = profile, + .z = batch.z, + .x = batch.x, + .y = batch.y}; + if (store_.tile_available(id)) + { + ++result.cached_tiles; + } + else + { + missing_profiles.push_back(profile); + } + } + if (missing_profiles.empty()) + { + continue; + } + + std::string dem_error{}; + const auto dem_files = ensure_dem_files(bounds, token, dem_error); + if (dem_files.empty()) + { + last_error = dem_error.empty() ? "No DEM files available." + : dem_error; + result.failed_tiles += missing_profiles.size(); + continue; + } + + if (!file_nonempty(vrt_path) && + !build_vrt(bounds, vrt_path, dem_files, last_error)) + { + result.failed_tiles += missing_profiles.size(); + continue; + } + + std::map contour_paths; + for (const auto& profile : missing_profiles) + { + MapContourTileId id{.profile = profile, + .z = batch.z, + .x = batch.x, + .y = batch.y}; + const auto output_path = store_.tile_path(id); + std::string profile_error{}; + if (generate_profile_png(profile, + missing_profiles, + work_dir, + vrt_path, + bounds, + output_path, + contour_paths, + profile_error)) + { + ++result.generated_tiles; + } + else + { + last_error = profile_error; + ++result.failed_tiles; + } + } + } + + std::ostringstream message; + message << "Contour fill: generated " << result.generated_tiles + << ", cached " << result.cached_tiles << ", failed " + << result.failed_tiles << "."; + if (!last_error.empty()) + { + message << ' ' << last_error; + } + result.message = message.str(); + return result; +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/linux/map_diagnostics.cpp b/platform/linux/common/src/platform/linux/map_diagnostics.cpp new file mode 100644 index 00000000..ebc00d7a --- /dev/null +++ b/platform/linux/common/src/platform/linux/map_diagnostics.cpp @@ -0,0 +1,122 @@ +#include "platform/linux/map_diagnostics.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform/linux/runtime_paths.h" + +namespace platform::linux_runtime +{ +namespace +{ + +std::mutex s_log_mutex; + +std::string lower_copy(std::string value) +{ + std::transform(value.begin(), + value.end(), + value.begin(), + [](unsigned char ch) + { + return static_cast(std::tolower(ch)); + }); + return value; +} + +bool disables_doh(const std::string& value) +{ + const std::string lower = lower_copy(value); + return lower == "0" || lower == "false" || lower == "off" || + lower == "none" || lower == "disabled"; +} + +std::string timestamp_utc() +{ + using clock = std::chrono::system_clock; + const std::time_t now = clock::to_time_t(clock::now()); + std::tm utc{}; +#if defined(_WIN32) + gmtime_s(&utc, &now); +#else + gmtime_r(&now, &utc); +#endif + char buffer[32] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc); + return buffer; +} + +} // namespace + +std::filesystem::path map_diagnostic_log_path() +{ + return resolve_paths().settings_root / "logs" / "map.log"; +} + +void append_map_diagnostic(std::string_view category, + std::string_view message) +{ + const auto path = map_diagnostic_log_path(); + if (!ensure_directory(path.parent_path())) + { + return; + } + + std::lock_guard lock(s_log_mutex); + std::ofstream out(path, std::ios::app); + if (!out.is_open()) + { + return; + } + out << timestamp_utc() << " [" << category << "] " << message << '\n'; +} + +std::string map_curl_doh_url() +{ + if (const char* specific = std::getenv("TRAIL_MATE_MAP_DOH_URL")) + { + const std::string value = specific; + return disables_doh(value) ? std::string() : value; + } + if (const char* shared = std::getenv("TRAIL_MATE_CURL_DOH_URL")) + { + const std::string value = shared; + return disables_doh(value) ? std::string() : value; + } + return "https://1.1.1.1/dns-query"; +} + +void apply_map_curl_resolver(CURL* curl) +{ + if (curl == nullptr) + { + return; + } + const std::string doh = map_curl_doh_url(); + if (doh.empty()) + { + return; + } +#if LIBCURL_VERSION_NUM >= 0x073E00 + curl_easy_setopt(curl, CURLOPT_DOH_URL, doh.c_str()); +#else + (void)doh; +#endif +} + +std::string curl_error_message(CURLcode code, const char* error_buffer) +{ + if (error_buffer != nullptr && error_buffer[0] != '\0') + { + return error_buffer; + } + return curl_easy_strerror(code); +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/linux/map_tile_cache.cpp b/platform/linux/common/src/platform/linux/map_tile_cache.cpp new file mode 100644 index 00000000..ce79a47e --- /dev/null +++ b/platform/linux/common/src/platform/linux/map_tile_cache.cpp @@ -0,0 +1,819 @@ +#include "platform/linux/map_tile_cache.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "platform/linux/env_config.h" +#include "platform/linux/map_diagnostics.h" +#include "platform/linux/runtime_paths.h" + +namespace platform::linux_runtime +{ +namespace +{ + +constexpr double kPi = 3.14159265358979323846; +constexpr double kMaxMercatorLat = 85.05112878; +constexpr int kMaxZoom = 19; +constexpr long kConnectTimeoutSeconds = 5; +constexpr long kTransferTimeoutSeconds = 12; +constexpr const char* kUserAgent = "TrailMate-uConsole/0.1"; + +std::filesystem::path default_root() +{ + return resolve_paths().sd_root / "maps" / "base"; +} + +std::filesystem::path default_contour_root() +{ + return resolve_paths().sd_root / "maps" / "contour"; +} + +std::filesystem::path legacy_center_contour_root() +{ + return resolve_paths().sd_root / "contours" / "tiles"; +} + +sqlite3* open_database() +{ + const std::filesystem::path db_path = sqlite_database_path(); + if (!ensure_directory(db_path.parent_path())) + { + return nullptr; + } + + sqlite3* db = nullptr; + const int rc = sqlite3_open_v2(db_path.string().c_str(), + &db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | + SQLITE_OPEN_FULLMUTEX, + nullptr); + if (rc != SQLITE_OK) + { + if (db != nullptr) + { + sqlite3_close(db); + } + return nullptr; + } + + sqlite3_busy_timeout(db, 5000); + return db; +} + +bool exec_sql(sqlite3* db, const char* sql) +{ + char* error = nullptr; + const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &error); + if (error != nullptr) + { + sqlite3_free(error); + } + return rc == SQLITE_OK; +} + +bool ensure_schema(sqlite3* db) +{ + return db != nullptr && exec_sql(db, "PRAGMA busy_timeout=5000;") && + exec_sql(db, "PRAGMA journal_mode=WAL;") && + exec_sql(db, + "CREATE TABLE IF NOT EXISTS map_tile_cache (" + "source TEXT NOT NULL," + "z INTEGER NOT NULL," + "x INTEGER NOT NULL," + "y INTEGER NOT NULL," + "path TEXT NOT NULL," + "content_type TEXT NOT NULL," + "status TEXT NOT NULL," + "http_status INTEGER NOT NULL DEFAULT 0," + "bytes INTEGER NOT NULL DEFAULT 0," + "fetched_at INTEGER NOT NULL DEFAULT " + "(CAST(strftime('%s','now') AS INTEGER))," + "last_error TEXT NOT NULL DEFAULT ''," + "PRIMARY KEY(source, z, x, y)" + ");"); +} + +struct DatabaseHandle +{ + sqlite3* db = nullptr; + + DatabaseHandle() + { + db = open_database(); + if (db != nullptr && !ensure_schema(db)) + { + sqlite3_close(db); + db = nullptr; + } + } + + ~DatabaseHandle() + { + if (db != nullptr) + { + sqlite3_close(db); + } + } + + DatabaseHandle(const DatabaseHandle&) = delete; + DatabaseHandle& operator=(const DatabaseHandle&) = delete; +}; + +bool bind_text(sqlite3_stmt* stmt, int index, std::string_view text) +{ + return sqlite3_bind_text(stmt, + index, + text.data(), + static_cast(text.size()), + SQLITE_TRANSIENT) == SQLITE_OK; +} + +std::string replace_all(std::string text, + std::string_view needle, + std::string_view value) +{ + std::size_t pos = 0; + while ((pos = text.find(needle, pos)) != std::string::npos) + { + text.replace(pos, needle.size(), value); + pos += value.size(); + } + return text; +} + +std::string build_url_from_template(std::string url_template, + const MapTileId& tile) +{ + url_template = replace_all(url_template, "{z}", std::to_string(tile.z)); + url_template = replace_all(url_template, "{x}", std::to_string(tile.x)); + url_template = replace_all(url_template, "{y}", std::to_string(tile.y)); + return url_template; +} + +std::string default_url_template(MapBaseSource source) +{ + switch (source) + { + case MapBaseSource::Terrain: + return "https://tile.opentopomap.org/{z}/{x}/{y}.png"; + case MapBaseSource::Satellite: + return "https://services.arcgisonline.com/ArcGIS/rest/services/" + "World_Imagery/MapServer/tile/{z}/{y}/{x}"; + case MapBaseSource::Osm: + default: + return "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; + } +} + +std::string url_template_for_source(MapBaseSource source) +{ + const char* env_name = nullptr; + switch (source) + { + case MapBaseSource::Terrain: + env_name = "TRAIL_MATE_TERRAIN_TILE_URL"; + break; + case MapBaseSource::Satellite: + env_name = "TRAIL_MATE_SATELLITE_TILE_URL"; + break; + case MapBaseSource::Osm: + default: + env_name = "TRAIL_MATE_OSM_TILE_URL"; + break; + } + + if (const char* value = std::getenv(env_name)) + { + if (value[0] != '\0') + { + return value; + } + } + return default_url_template(source); +} + +const char* content_type_for_source(MapBaseSource source) +{ + return source == MapBaseSource::Satellite ? "image/jpeg" : "image/png"; +} + +MapContourProfile major_contour(int interval_m) noexcept +{ + return MapContourProfile{MapContourKind::Major, interval_m}; +} + +MapContourProfile minor_contour(int interval_m) noexcept +{ + return MapContourProfile{MapContourKind::Minor, interval_m}; +} + +bool valid_tile(const MapTileId& tile) +{ + if (tile.z < 0 || tile.z > kMaxZoom) + { + return false; + } + + const int tiles = 1 << tile.z; + return tile.x >= 0 && tile.x < tiles && tile.y >= 0 && tile.y < tiles; +} + +std::uintmax_t file_size_or_zero(const std::filesystem::path& path) +{ + std::error_code ec; + const auto size = std::filesystem::file_size(path, ec); + return ec ? 0U : size; +} + +struct DownloadContext +{ + std::ofstream stream; +}; + +std::size_t write_file_callback(char* ptr, + std::size_t size, + std::size_t nmemb, + void* userdata) +{ + auto* ctx = static_cast(userdata); + const std::size_t bytes = size * nmemb; + if (ctx == nullptr || !ctx->stream.is_open()) + { + return 0; + } + + ctx->stream.write(ptr, static_cast(bytes)); + return ctx->stream.good() ? bytes : 0; +} + +MapTileResult record_tile_status(const MapTileId& tile, + const std::filesystem::path& relative_path, + MapTileStatus status, + long http_status, + std::uintmax_t bytes, + std::string_view error) +{ + DatabaseHandle handle; + if (handle.db != nullptr) + { + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "INSERT INTO map_tile_cache(" + "source, z, x, y, path, content_type, status, http_status, bytes, " + "fetched_at, last_error) " + "VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, " + "CAST(strftime('%s','now') AS INTEGER), ?10) " + "ON CONFLICT(source, z, x, y) DO UPDATE SET " + "path=excluded.path, " + "content_type=excluded.content_type, " + "status=excluded.status, " + "http_status=excluded.http_status, " + "bytes=excluded.bytes, " + "fetched_at=excluded.fetched_at, " + "last_error=excluded.last_error;"; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == + SQLITE_OK) + { + const std::string relative = relative_path.generic_string(); + const std::string status_text = + (status == MapTileStatus::Failed) ? "failed" : "cached"; + const bool ok = + bind_text(stmt, 1, map_base_source_key(tile.source)) && + sqlite3_bind_int(stmt, 2, tile.z) == SQLITE_OK && + sqlite3_bind_int(stmt, 3, tile.x) == SQLITE_OK && + sqlite3_bind_int(stmt, 4, tile.y) == SQLITE_OK && + bind_text(stmt, 5, relative) && + bind_text(stmt, 6, content_type_for_source(tile.source)) && + bind_text(stmt, 7, status_text) && + sqlite3_bind_int64(stmt, 8, http_status) == SQLITE_OK && + sqlite3_bind_int64(stmt, 9, + static_cast(bytes)) == + SQLITE_OK && + bind_text(stmt, 10, error); + if (ok) + { + (void)sqlite3_step(stmt); + } + } + sqlite3_finalize(stmt); + } + + MapTileResult result{}; + result.status = status; + result.tile = tile; + result.path = resolve_paths().sd_root / relative_path; + result.message = std::string(error); + result.http_status = http_status; + result.bytes = bytes; + return result; +} + +MapTileResult fail_result(const MapTileId& tile, + const std::filesystem::path& relative_path, + std::string_view message, + long http_status = 0) +{ + return record_tile_status(tile, + relative_path, + MapTileStatus::Failed, + http_status, + 0, + message); +} + +double clamp_lat(double lat) +{ + return std::clamp(lat, -kMaxMercatorLat, kMaxMercatorLat); +} + +} // namespace + +MapBaseSource sanitize_map_base_source(std::uint8_t source) noexcept +{ + switch (source) + { + case 1: + return MapBaseSource::Terrain; + case 2: + return MapBaseSource::Satellite; + case 0: + default: + return MapBaseSource::Osm; + } +} + +const char* map_base_source_key(MapBaseSource source) noexcept +{ + switch (source) + { + case MapBaseSource::Terrain: + return "terrain"; + case MapBaseSource::Satellite: + return "satellite"; + case MapBaseSource::Osm: + default: + return "osm"; + } +} + +const char* map_base_source_label(MapBaseSource source) noexcept +{ + switch (source) + { + case MapBaseSource::Terrain: + return "Terrain"; + case MapBaseSource::Satellite: + return "Satellite"; + case MapBaseSource::Osm: + default: + return "OSM"; + } +} + +const char* map_base_source_extension(MapBaseSource source) noexcept +{ + return source == MapBaseSource::Satellite ? "jpg" : "png"; +} + +const char* map_contour_kind_key(MapContourKind kind) noexcept +{ + switch (kind) + { + case MapContourKind::Minor: + return "minor"; + case MapContourKind::Major: + default: + return "major"; + } +} + +std::string map_contour_profile_key(const MapContourProfile& profile) +{ + return std::string(map_contour_kind_key(profile.kind)) + "-" + + std::to_string(std::max(1, profile.interval_m)); +} + +MapTileCache::MapTileCache() : root_(default_root()) +{ +} + +MapTileCache::MapTileCache(std::filesystem::path root) : root_(std::move(root)) +{ +} + +const std::filesystem::path& MapTileCache::root() const noexcept +{ + return root_; +} + +std::filesystem::path MapTileCache::relative_tile_path( + const MapTileId& tile) const +{ + MapTileId normalized = tile; + normalize_map_tile(normalized); + return std::filesystem::path("maps") / "base" / + map_base_source_key(normalized.source) / + std::to_string(normalized.z) / std::to_string(normalized.x) / + (std::to_string(normalized.y) + "." + + map_base_source_extension(normalized.source)); +} + +std::filesystem::path MapTileCache::tile_path(const MapTileId& tile) const +{ + MapTileId normalized = tile; + normalize_map_tile(normalized); + return root_ / map_base_source_key(normalized.source) / + std::to_string(normalized.z) / std::to_string(normalized.x) / + (std::to_string(normalized.y) + "." + + map_base_source_extension(normalized.source)); +} + +bool MapTileCache::tile_available(const MapTileId& tile) const +{ + return std::filesystem::exists(tile_path(tile)); +} + +MapTileCacheStats MapTileCache::stats() const +{ + MapTileCacheStats out{}; + out.root = root_; + out.database = sqlite_database_path(); + + DatabaseHandle handle; + if (handle.db == nullptr) + { + return out; + } + + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "SELECT " + "SUM(CASE WHEN status='cached' THEN 1 ELSE 0 END)," + "SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END)," + "COALESCE(SUM(CASE WHEN status='cached' THEN bytes ELSE 0 END), 0) " + "FROM map_tile_cache;"; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == SQLITE_OK && + sqlite3_step(stmt) == SQLITE_ROW) + { + out.cached_tiles = static_cast( + std::max(0, sqlite3_column_int64(stmt, 0))); + out.failed_tiles = static_cast( + std::max(0, sqlite3_column_int64(stmt, 1))); + out.total_bytes = static_cast( + std::max(0, sqlite3_column_int64(stmt, 2))); + } + sqlite3_finalize(stmt); + return out; +} + +MapTileResult MapTileCache::ensure_tile(const MapTileId& requested) const +{ + MapTileId tile = requested; + normalize_map_tile(tile); + const std::filesystem::path relative_path = relative_tile_path(tile); + const std::filesystem::path path = tile_path(tile); + + if (!valid_tile(tile)) + { + return fail_result(tile, relative_path, "Invalid tile coordinate."); + } + + if (std::filesystem::exists(path) && file_size_or_zero(path) > 0U) + { + return record_tile_status(tile, + relative_path, + MapTileStatus::Cached, + 0, + file_size_or_zero(path), + ""); + } + + if (!ensure_directory(path.parent_path())) + { + return fail_result(tile, relative_path, "Cannot create tile directory."); + } + + const std::string url = + build_url_from_template(url_template_for_source(tile.source), tile); + const std::filesystem::path temp_path = path.string() + ".tmp"; + + DownloadContext ctx{}; + ctx.stream.open(temp_path, std::ios::binary | std::ios::trunc); + if (!ctx.stream.is_open()) + { + return fail_result(tile, relative_path, "Cannot create temp tile file."); + } + + CURL* curl = curl_easy_init(); + if (curl == nullptr) + { + ctx.stream.close(); + std::error_code remove_ec; + std::filesystem::remove(temp_path, remove_ec); + append_map_diagnostic("tile", + std::string("libcurl init failed for ") + url); + return fail_result(tile, relative_path, "Cannot initialize libcurl."); + } + + char error_buffer[CURL_ERROR_SIZE] = {}; + apply_map_curl_resolver(curl); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_USERAGENT, kUserAgent); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, kConnectTimeoutSeconds); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, kTransferTimeoutSeconds); + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, error_buffer); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); + + const CURLcode rc = curl_easy_perform(curl); + long http_status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_status); + curl_easy_cleanup(curl); + ctx.stream.close(); + + if (rc != CURLE_OK) + { + std::error_code remove_ec; + std::filesystem::remove(temp_path, remove_ec); + std::ostringstream message; + message << "Download failed: " + << curl_error_message(rc, error_buffer) << " / url " << url; + if (http_status != 0) + { + message << " / HTTP " << http_status; + } + const std::string doh = map_curl_doh_url(); + if (!doh.empty()) + { + message << " / DoH " << doh; + } + append_map_diagnostic( + "tile", + std::string(map_base_source_key(tile.source)) + " z" + + std::to_string(tile.z) + "/" + std::to_string(tile.x) + + "/" + std::to_string(tile.y) + " failed: " + + message.str()); + return fail_result(tile, relative_path, message.str(), http_status); + } + + const std::uintmax_t bytes = file_size_or_zero(temp_path); + if (bytes == 0U) + { + std::error_code remove_ec; + std::filesystem::remove(temp_path, remove_ec); + append_map_diagnostic( + "tile", + std::string(map_base_source_key(tile.source)) + " z" + + std::to_string(tile.z) + "/" + std::to_string(tile.x) + + "/" + std::to_string(tile.y) + " empty response from " + url); + return fail_result(tile, relative_path, "Downloaded tile is empty.", + http_status); + } + + std::error_code ec; + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(temp_path, path, ec); + if (ec) + { + std::filesystem::remove(temp_path, ec); + append_map_diagnostic( + "tile", + std::string(map_base_source_key(tile.source)) + " z" + + std::to_string(tile.z) + "/" + std::to_string(tile.x) + + "/" + std::to_string(tile.y) + " commit failed for " + + path.string()); + return fail_result(tile, relative_path, "Cannot commit tile file.", + http_status); + } + + append_map_diagnostic( + "tile", + std::string(map_base_source_key(tile.source)) + " z" + + std::to_string(tile.z) + "/" + std::to_string(tile.x) + "/" + + std::to_string(tile.y) + " cached HTTP " + + std::to_string(http_status) + " " + std::to_string(bytes) + + " bytes from " + url); + return record_tile_status(tile, + relative_path, + MapTileStatus::Downloaded, + http_status, + bytes, + ""); +} + +MapContourTileStore::MapContourTileStore() : root_(default_contour_root()) +{ +} + +MapContourTileStore::MapContourTileStore(std::filesystem::path root) + : root_(std::move(root)) +{ +} + +const std::filesystem::path& MapContourTileStore::root() const noexcept +{ + return root_; +} + +std::filesystem::path MapContourTileStore::relative_tile_path( + const MapContourTileId& tile) const +{ + MapContourTileId normalized = tile; + normalize_map_contour_tile(normalized); + return std::filesystem::path("maps") / "contour" / + map_contour_profile_key(normalized.profile) / + std::to_string(normalized.z) / std::to_string(normalized.x) / + (std::to_string(normalized.y) + ".png"); +} + +std::filesystem::path MapContourTileStore::tile_path( + const MapContourTileId& tile) const +{ + MapContourTileId normalized = tile; + normalize_map_contour_tile(normalized); + return root_ / map_contour_profile_key(normalized.profile) / + std::to_string(normalized.z) / std::to_string(normalized.x) / + (std::to_string(normalized.y) + ".png"); +} + +std::filesystem::path MapContourTileStore::existing_tile_path( + const MapContourTileId& tile) const +{ + const auto primary = tile_path(tile); + if (std::filesystem::exists(primary)) + { + return primary; + } + + MapContourTileId normalized = tile; + normalize_map_contour_tile(normalized); + const auto center_layout = + legacy_center_contour_root() / + map_contour_profile_key(normalized.profile) / + std::to_string(normalized.z) / std::to_string(normalized.x) / + (std::to_string(normalized.y) + ".png"); + return std::filesystem::exists(center_layout) ? center_layout : primary; +} + +bool MapContourTileStore::tile_available( + const MapContourTileId& tile) const +{ + return std::filesystem::exists(existing_tile_path(tile)); +} + +void normalize_map_tile(MapTileId& tile) noexcept +{ + tile.source = sanitize_map_base_source(static_cast(tile.source)); + tile.z = std::clamp(tile.z, 0, kMaxZoom); + const int tiles = 1 << tile.z; + if (tiles <= 0) + { + tile.x = 0; + tile.y = 0; + return; + } + + tile.x %= tiles; + if (tile.x < 0) + { + tile.x += tiles; + } + tile.y = std::clamp(tile.y, 0, tiles - 1); +} + +void normalize_map_contour_tile(MapContourTileId& tile) noexcept +{ + tile.profile.interval_m = std::clamp(tile.profile.interval_m, 1, 10000); + tile.z = std::clamp(tile.z, 0, kMaxZoom); + const int tiles = 1 << tile.z; + if (tiles <= 0) + { + tile.x = 0; + tile.y = 0; + return; + } + + tile.x %= tiles; + if (tile.x < 0) + { + tile.x += tiles; + } + tile.y = std::clamp(tile.y, 0, tiles - 1); +} + +std::vector map_tiles_around(double lat, + double lon, + int zoom, + MapBaseSource source, + int radius_x, + int radius_y) +{ + zoom = std::clamp(zoom, 0, kMaxZoom); + radius_x = std::clamp(radius_x, 0, 5); + radius_y = std::clamp(radius_y, 0, 5); + + const double tiles = static_cast(1U << zoom); + const double clamped_lat = clamp_lat(lat); + const double lat_rad = clamped_lat * kPi / 180.0; + int center_x = static_cast( + std::floor((lon + 180.0) / 360.0 * tiles)); + int center_y = static_cast(std::floor( + (1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / kPi) / + 2.0 * tiles)); + + MapTileId center{source, zoom, center_x, center_y}; + normalize_map_tile(center); + + std::vector out; + out.reserve(static_cast((radius_x * 2 + 1) * + (radius_y * 2 + 1))); + for (int dy = -radius_y; dy <= radius_y; ++dy) + { + for (int dx = -radius_x; dx <= radius_x; ++dx) + { + MapTileId tile{source, zoom, center.x + dx, center.y + dy}; + normalize_map_tile(tile); + out.push_back(tile); + } + } + return out; +} + +std::vector contour_profiles_for_zoom( + int zoom, + bool allow_ultra_fine) +{ + zoom = std::clamp(zoom, 0, kMaxZoom); + std::vector out; + + if (zoom <= 7) + { + return out; + } + if (zoom == 8) + { + out.push_back(major_contour(500)); + return out; + } + if (zoom == 9) + { + out.push_back(major_contour(200)); + return out; + } + if (zoom == 10) + { + out.push_back(major_contour(500)); + out.push_back(minor_contour(100)); + return out; + } + if (zoom == 11) + { + out.push_back(major_contour(200)); + out.push_back(minor_contour(50)); + return out; + } + if (zoom == 12) + { + out.push_back(major_contour(100)); + out.push_back(minor_contour(50)); + return out; + } + if (zoom == 13 || zoom == 14) + { + out.push_back(major_contour(100)); + out.push_back(minor_contour(20)); + return out; + } + if (zoom == 15 || zoom == 16) + { + out.push_back(major_contour(50)); + out.push_back(minor_contour(10)); + return out; + } + + out.push_back(major_contour(25)); + if (allow_ultra_fine) + { + out.push_back(minor_contour(5)); + } + return out; +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/linux/runtime_packet_log.cpp b/platform/linux/common/src/platform/linux/runtime_packet_log.cpp new file mode 100644 index 00000000..ac7dc205 --- /dev/null +++ b/platform/linux/common/src/platform/linux/runtime_packet_log.cpp @@ -0,0 +1,311 @@ +#include "platform/linux/runtime_packet_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform/linux/runtime_paths.h" + +namespace platform::linux_runtime +{ +namespace +{ + +constexpr std::size_t kMaxEntriesPerSource = 240; +constexpr std::uintmax_t kMaxPacketLogFileBytes = 2U * 1024U * 1024U; + +std::mutex s_mutex; +std::deque s_gps_entries; +std::deque s_lora_entries; +std::deque s_mqtt_entries; + +std::deque& entries_for(PacketLogSource source) +{ + switch (source) + { + case PacketLogSource::Lora: + return s_lora_entries; + case PacketLogSource::Mqtt: + return s_mqtt_entries; + case PacketLogSource::Gps: + default: + return s_gps_entries; + } +} + +const char* packet_log_file_name(PacketLogSource source) +{ + switch (source) + { + case PacketLogSource::Lora: + return "lora.log"; + case PacketLogSource::Mqtt: + return "mqtt.log"; + case PacketLogSource::Gps: + default: + return "gps.log"; + } +} + +const char* direction_token(PacketLogDirection direction) +{ + switch (direction) + { + case PacketLogDirection::Tx: + return "TX"; + case PacketLogDirection::System: + return "SYS"; + case PacketLogDirection::Rx: + default: + return "RX"; + } +} + +const char* segment_kind_token(PacketLogSegmentKind kind) +{ + switch (kind) + { + case PacketLogSegmentKind::Header: + return "header"; + case PacketLogSegmentKind::Body: + return "body"; + case PacketLogSegmentKind::Checksum: + return "checksum"; + case PacketLogSegmentKind::Error: + return "error"; + case PacketLogSegmentKind::Meta: + default: + return "meta"; + } +} + +std::uint64_t now_ms() +{ + using clock = std::chrono::system_clock; + return static_cast( + std::chrono::duration_cast( + clock::now().time_since_epoch()) + .count()); +} + +std::string timestamp_utc(std::uint64_t timestamp_ms) +{ + const std::time_t seconds = + static_cast(timestamp_ms / 1000ULL); + std::tm utc{}; +#if defined(_WIN32) + gmtime_s(&utc, &seconds); +#else + gmtime_r(&seconds, &utc); +#endif + char buffer[40] = {}; + const auto millis = static_cast(timestamp_ms % 1000ULL); + std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &utc); + char out[48] = {}; + std::snprintf(out, sizeof(out), "%s.%03uZ", buffer, millis); + return out; +} + +void write_escaped(std::ostream& out, std::string_view text) +{ + out << '"'; + for (char ch : text) + { + switch (ch) + { + case '\\': + out << "\\\\"; + break; + case '"': + out << "\\\""; + break; + case '\n': + out << "\\n"; + break; + case '\r': + out << "\\r"; + break; + case '\t': + out << "\\t"; + break; + default: + out << ch; + break; + } + } + out << '"'; +} + +void rotate_if_needed(const std::filesystem::path& path) +{ + std::error_code ec; + const auto size = std::filesystem::file_size(path, ec); + if (ec || size < kMaxPacketLogFileBytes) + { + return; + } + + const auto rotated = path.string() + ".1"; + std::filesystem::remove(rotated, ec); + ec.clear(); + std::filesystem::rename(path, rotated, ec); +} + +void append_packet_log_file(const PacketLogEntry& entry) +{ + const auto path = + resolve_paths().settings_root / "logs" / packet_log_file_name(entry.source); + if (!ensure_directory(path.parent_path())) + { + return; + } + rotate_if_needed(path); + + std::ofstream out(path, std::ios::app); + if (!out.is_open()) + { + return; + } + + out << timestamp_utc(entry.timestamp_ms) + << " direction=" << direction_token(entry.direction) + << " title="; + write_escaped(out, entry.title); + out << " summary="; + write_escaped(out, entry.summary); + if (!entry.raw_hex.empty()) + { + out << " raw="; + write_escaped(out, entry.raw_hex); + } + for (const auto& segment : entry.segments) + { + out << " segment." << segment_kind_token(segment.kind) << '.' + << (segment.label.empty() ? "value" : segment.label) << '='; + write_escaped(out, segment.text); + } + out << '\n'; +} + +} // namespace + +void append_packet_log(PacketLogEntry entry) +{ + std::lock_guard lock(s_mutex); + if (entry.timestamp_ms == 0) + { + entry.timestamp_ms = now_ms(); + } + append_packet_log_file(entry); + auto& entries = entries_for(entry.source); + entries.push_back(std::move(entry)); + while (entries.size() > kMaxEntriesPerSource) + { + entries.pop_front(); + } +} + +std::vector recent_packet_logs(PacketLogSource source, + std::size_t max_entries) +{ + std::lock_guard lock(s_mutex); + const auto& entries = entries_for(source); + const std::size_t count = std::min(max_entries, entries.size()); + std::vector out{}; + out.reserve(count); + const auto first = entries.end() - static_cast(count); + for (auto it = first; it != entries.end(); ++it) + { + out.push_back(*it); + } + std::reverse(out.begin(), out.end()); + return out; +} + +void clear_packet_logs(PacketLogSource source) +{ + std::lock_guard lock(s_mutex); + entries_for(source).clear(); +} + +const char* packet_log_source_label(PacketLogSource source) noexcept +{ + switch (source) + { + case PacketLogSource::Lora: + return "LoRa"; + case PacketLogSource::Mqtt: + return "MQTT"; + case PacketLogSource::Gps: + default: + return "GPS"; + } +} + +const char* packet_log_direction_label(PacketLogDirection direction) noexcept +{ + switch (direction) + { + case PacketLogDirection::Tx: + return "TX"; + case PacketLogDirection::System: + return "SYS"; + case PacketLogDirection::Rx: + default: + return "RX"; + } +} + +const char* packet_log_segment_class(PacketLogSegmentKind kind) noexcept +{ + switch (kind) + { + case PacketLogSegmentKind::Header: + return "log-segment-header"; + case PacketLogSegmentKind::Body: + return "log-segment-body"; + case PacketLogSegmentKind::Checksum: + return "log-segment-checksum"; + case PacketLogSegmentKind::Error: + return "log-segment-error"; + case PacketLogSegmentKind::Meta: + default: + return "log-segment-meta"; + } +} + +std::string hex_bytes(const std::uint8_t* data, std::size_t size) +{ + if (data == nullptr || size == 0) + { + return {}; + } + + std::string out{}; + out.reserve(size * 3); + char byte[4] = {}; + for (std::size_t index = 0; index < size; ++index) + { + if (index != 0) + { + out += ' '; + } + std::snprintf(byte, sizeof(byte), "%02X", + static_cast(data[index])); + out += byte; + } + return out; +} + +std::string hex_bytes(const char* data, std::size_t size) +{ + return hex_bytes(reinterpret_cast(data), size); +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/linux/runtime_paths.cpp b/platform/linux/common/src/platform/linux/runtime_paths.cpp index 21536103..bc42820c 100644 --- a/platform/linux/common/src/platform/linux/runtime_paths.cpp +++ b/platform/linux/common/src/platform/linux/runtime_paths.cpp @@ -119,6 +119,12 @@ std::filesystem::path settings_file(const char* ns) (sanitize_component(ns) + ".kv"); } +std::filesystem::path sqlite_database_path() +{ + const RuntimePaths paths = resolve_paths(); + return paths.settings_root / "trailmate.sqlite3"; +} + std::filesystem::path sd_child(std::string_view relative) { const RuntimePaths paths = resolve_paths(); diff --git a/platform/linux/common/src/platform/linux/sx126x_radio.cpp b/platform/linux/common/src/platform/linux/sx126x_radio.cpp new file mode 100644 index 00000000..625a1c68 --- /dev/null +++ b/platform/linux/common/src/platform/linux/sx126x_radio.cpp @@ -0,0 +1,1367 @@ +#include "platform/linux/sx126x_radio.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#include +#include +#endif + +namespace platform::linux_runtime +{ +namespace +{ + +constexpr std::uint8_t kCmdSetStandby = 0x80; +constexpr std::uint8_t kCmdGetStatus = 0xC0; +constexpr std::uint8_t kCmdSetRx = 0x82; +constexpr std::uint8_t kCmdSetTx = 0x83; +constexpr std::uint8_t kCmdSetPacketType = 0x8A; +constexpr std::uint8_t kCmdSetRfFrequency = 0x86; +constexpr std::uint8_t kCmdSetTxParams = 0x8E; +constexpr std::uint8_t kCmdSetModulationParams = 0x8B; +constexpr std::uint8_t kCmdSetPacketParams = 0x8C; +constexpr std::uint8_t kCmdSetBufferBaseAddress = 0x8F; +constexpr std::uint8_t kCmdSetRegulatorMode = 0x96; +constexpr std::uint8_t kCmdSetPaConfig = 0x95; +constexpr std::uint8_t kCmdSetDioIrqParams = 0x08; +constexpr std::uint8_t kCmdGetIrqStatus = 0x12; +constexpr std::uint8_t kCmdClearIrqStatus = 0x02; +constexpr std::uint8_t kCmdSetDio2AsRfSwitchCtrl = 0x9D; +constexpr std::uint8_t kCmdSetDio3AsTcxoCtrl = 0x97; +constexpr std::uint8_t kCmdGetRssiInst = 0x15; +constexpr std::uint8_t kCmdGetRxBufferStatus = 0x13; +constexpr std::uint8_t kCmdGetPacketStatus = 0x14; +constexpr std::uint8_t kCmdReadBuffer = 0x1E; +constexpr std::uint8_t kCmdWriteBuffer = 0x0E; +constexpr std::uint8_t kCmdCalibrateImage = 0x98; +constexpr std::uint8_t kCmdSetRxTxFallbackMode = 0x93; +constexpr std::uint8_t kCmdCalibrate = 0x89; +constexpr std::uint8_t kCmdReadRegister = 0x1D; +constexpr std::uint8_t kCmdWriteRegister = 0x0D; + +constexpr std::uint8_t kPacketTypeLoRa = 0x01; +constexpr std::uint8_t kStandbyRc = 0x00; +constexpr std::uint8_t kRegulatorDcDc = 0x01; +constexpr std::uint8_t kFallbackStandbyRc = 0x20; +constexpr std::uint8_t kPaRamp200u = 0x04; +constexpr std::uint8_t kPaConfigDeviceSelSx1262 = 0x00; +constexpr std::uint8_t kPaConfigPaLut = 0x01; +constexpr std::uint8_t kLoRaHeaderExplicit = 0x00; +constexpr std::uint8_t kLoRaCrcOff = 0x00; +constexpr std::uint8_t kLoRaCrcOn = 0x01; +constexpr std::uint8_t kLoRaIqStandard = 0x00; +constexpr std::uint32_t kRxTimeoutInf = 0xFFFFFF; +constexpr std::uint16_t kIrqTxDone = 0x0001; +constexpr std::uint16_t kIrqRxDone = 0x0002; +constexpr std::uint16_t kIrqHeaderErr = 0x0020; +constexpr std::uint16_t kIrqCrcErr = 0x0040; +constexpr std::uint16_t kIrqTimeout = 0x0200; +constexpr std::uint16_t kIrqAll = 0x43FF; +constexpr std::uint16_t kRegOcpConfiguration = 0x08E7; +constexpr std::uint16_t kRegLoraSyncWordMsb = 0x0740; +constexpr float kFrequencyStepHz = 0.9536743164f; + +int env_int_or_default(const char* name, int fallback) +{ + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + { + return fallback; + } + char* end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (end == value || (end != nullptr && *end != '\0')) + { + return fallback; + } + return static_cast(parsed); +} + +float env_float_or_default(const char* name, float fallback) +{ + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + { + return fallback; + } + char* end = nullptr; + const float parsed = std::strtof(value, &end); + if (end == value || (end != nullptr && *end != '\0') || + !std::isfinite(parsed)) + { + return fallback; + } + return parsed; +} + +std::string env_string_or_default(const char* name, const char* fallback) +{ + const char* value = std::getenv(name); + if (value != nullptr && value[0] != '\0') + { + return value; + } + return fallback; +} + +bool env_flag_or_default(const char* name, bool fallback) +{ + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + { + return fallback; + } + return std::strcmp(value, "1") == 0 || std::strcmp(value, "true") == 0 || + std::strcmp(value, "TRUE") == 0 || std::strcmp(value, "yes") == 0 || + std::strcmp(value, "YES") == 0; +} + +std::uint8_t map_lora_bw(float bw_khz) +{ + const float half = bw_khz / 2.0f; + const int bw_div2 = static_cast(half + 0.01f); + switch (bw_div2) + { + case 3: + return 0x00; + case 5: + return 0x08; + case 7: + return 0x01; + case 10: + return 0x09; + case 15: + return 0x02; + case 20: + return 0x0A; + case 31: + return 0x03; + case 62: + return 0x04; + case 125: + return 0x05; + case 250: + return 0x06; + default: + return 0x05; + } +} + +std::uint8_t map_lora_cr(std::uint8_t cr) +{ + if (cr < 5) return 0x01; + if (cr > 8) return 0x04; + return static_cast(cr - 4); +} + +std::uint8_t calc_ldro(std::uint8_t sf, float bw_khz) +{ + const float symbol_ms = static_cast(1UL << sf) / bw_khz; + return symbol_ms >= 16.0f ? 0x01 : 0x00; +} + +std::uint32_t rf_frequency_raw(float freq_mhz) +{ + return static_cast( + (static_cast(freq_mhz) * 1000000.0) / + static_cast(kFrequencyStepHz)); +} + +std::uint8_t ocp_for_60ma() +{ + return static_cast(60.0f / 2.5f); +} + +void sleep_ms(int ms) +{ + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); +} + +void close_fd(int& fd) +{ +#if defined(__linux__) + if (fd >= 0) + { + close(fd); + fd = -1; + } +#else + fd = -1; +#endif +} + +std::string errno_suffix() +{ +#if defined(__linux__) + return std::string(" errno=") + std::to_string(errno) + " (" + + std::strerror(errno) + ")"; +#else + return {}; +#endif +} + +std::string hex_bytes(const std::uint8_t* data, std::size_t size) +{ + if (data == nullptr || size == 0) + { + return {}; + } + + static constexpr char kHex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(size * 3U); + for (std::size_t i = 0; i < size; ++i) + { + if (i != 0) + { + out.push_back(' '); + } + out.push_back(kHex[(data[i] >> 4) & 0x0F]); + out.push_back(kHex[data[i] & 0x0F]); + } + return out; +} + +const char* bool_name(bool value) +{ + return value ? "true" : "false"; +} + +std::string describe_radio_config(const Sx126xRadioConfig& config) +{ + char buffer[384] = {}; + std::snprintf(buffer, + sizeof(buffer), + "spi=%s gpiochip=%s power=%d reset=%d busy=%d irq=%d hz=%lu dio2_rf=%s dio3_tcxo_1v8=%s", + config.spi_device.c_str(), + config.gpiochip.c_str(), + config.power_gpio, + config.reset_gpio, + config.busy_gpio, + config.irq_gpio, + static_cast(config.spi_speed_hz), + bool_name(config.dio2_as_rf_switch), + bool_name(config.dio3_tcxo_1v8)); + return buffer; +} + +#if defined(__linux__) +bool request_gpio_line(int chip_fd, + unsigned offset, + bool output, + int initial_value, + const char* label, + int* out_fd) +{ + if (out_fd == nullptr) + { + return false; + } + *out_fd = -1; + + gpiohandle_request req{}; + req.lineoffsets[0] = offset; + req.lines = 1; + req.flags = output ? GPIOHANDLE_REQUEST_OUTPUT + : GPIOHANDLE_REQUEST_INPUT; + req.default_values[0] = initial_value ? 1 : 0; + std::snprintf(req.consumer_label, + sizeof(req.consumer_label), + "%s", + label == nullptr ? "trailmate" : label); + + if (ioctl(chip_fd, GPIO_GET_LINEHANDLE_IOCTL, &req) != 0) + { + return false; + } + *out_fd = req.fd; + return true; +} + +bool set_gpio_value(int fd, int value) +{ + if (fd < 0) + { + return false; + } + gpiohandle_data data{}; + data.values[0] = value ? 1 : 0; + return ioctl(fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &data) == 0; +} + +int get_gpio_value(int fd) +{ + if (fd < 0) + { + return 0; + } + gpiohandle_data data{}; + if (ioctl(fd, GPIOHANDLE_GET_LINE_VALUES_IOCTL, &data) != 0) + { + return 0; + } + return data.values[0] ? 1 : 0; +} +#endif + +} // namespace + +Sx126xRadio& Sx126xRadio::instance() +{ + static Sx126xRadio radio; + return radio; +} + +Sx126xRadio::~Sx126xRadio() +{ + std::lock_guard lock(mutex_); + closeLocked(); +} + +bool Sx126xRadio::hardwareCandidatePresent() +{ + const std::filesystem::path spi = + env_string_or_default("TRAIL_MATE_LORA_SPI", "/dev/spidev1.0"); + std::error_code ec; + return std::filesystem::exists(spi, ec) && !ec && + !env_flag_or_default("TRAIL_MATE_LORA_DISABLE", false); +} + +Sx126xRadioConfig Sx126xRadio::defaultConfigFromEnvironment() +{ + Sx126xRadioConfig config{}; + config.spi_device = + env_string_or_default("TRAIL_MATE_LORA_SPI", config.spi_device.c_str()); + config.gpiochip = + env_string_or_default("TRAIL_MATE_LORA_GPIOCHIP", config.gpiochip.c_str()); + config.power_gpio = + env_int_or_default("TRAIL_MATE_LORA_POWER_GPIO", config.power_gpio); + config.reset_gpio = + env_int_or_default("TRAIL_MATE_LORA_RESET_GPIO", config.reset_gpio); + config.busy_gpio = + env_int_or_default("TRAIL_MATE_LORA_BUSY_GPIO", config.busy_gpio); + config.irq_gpio = + env_int_or_default("TRAIL_MATE_LORA_IRQ_GPIO", config.irq_gpio); + config.spi_speed_hz = static_cast( + std::max(100000, env_int_or_default("TRAIL_MATE_LORA_SPI_HZ", + static_cast(config.spi_speed_hz)))); + config.dio2_as_rf_switch = + env_flag_or_default("TRAIL_MATE_LORA_DIO2_RF_SWITCH", + config.dio2_as_rf_switch); + config.dio3_tcxo_1v8 = + env_flag_or_default("TRAIL_MATE_LORA_DIO3_TCXO_1V8", + config.dio3_tcxo_1v8); + return config; +} + +Sx126xLoRaConfig Sx126xRadio::defaultLoRaConfigFromEnvironment() +{ + Sx126xLoRaConfig config{}; + config.freq_mhz = + env_float_or_default("TRAIL_MATE_LORA_FREQ_MHZ", config.freq_mhz); + config.bw_khz = + env_float_or_default("TRAIL_MATE_LORA_BW_KHZ", config.bw_khz); + config.sf = static_cast( + std::clamp(env_int_or_default("TRAIL_MATE_LORA_SF", config.sf), 5, 12)); + config.cr = static_cast( + std::clamp(env_int_or_default("TRAIL_MATE_LORA_CR", config.cr), 5, 8)); + config.tx_power_dbm = static_cast( + std::clamp(env_int_or_default("TRAIL_MATE_LORA_TX_DBM", + config.tx_power_dbm), + -9, + 22)); + config.preamble_len = static_cast( + std::clamp(env_int_or_default("TRAIL_MATE_LORA_PREAMBLE", + config.preamble_len), + 6, + 65535)); + config.sync_word = static_cast( + std::clamp(env_int_or_default("TRAIL_MATE_LORA_SYNC_WORD", + config.sync_word), + 0, + 255)); + return config; +} + +bool Sx126xRadio::acquire(const Sx126xRadioConfig& config) +{ + std::lock_guard lock(mutex_); + if (!initLocked(config)) + { + return false; + } + ++users_; + return true; +} + +void Sx126xRadio::release() +{ + std::lock_guard lock(mutex_); + if (users_ > 0) + { + --users_; + } + if (users_ == 0 && online_) + { + const std::uint8_t mode = kStandbyRc; + (void)writeCommandLocked(kCmdSetStandby, &mode, 1, true); + } +} + +bool Sx126xRadio::configureLoRa(const Sx126xLoRaConfig& config) +{ + std::lock_guard lock(mutex_); + const bool ok = initLocked(config_) && configureLoRaLocked(config) && + setDioIrqParamsLocked( + kIrqRxDone | kIrqTimeout | kIrqCrcErr | kIrqHeaderErr, + kIrqRxDone) && + clearIrqLocked(kIrqAll) && + setBufferBaseLocked(0x00, 0x00) && setRxLocked(kRxTimeoutInf); + if (ok) + { + lora_config_ = config; + } + return ok; +} + +bool Sx126xRadio::startReceive() +{ + std::lock_guard lock(mutex_); + if (!online_) + { + return false; + } + return setDioIrqParamsLocked(kIrqRxDone | kIrqTimeout | kIrqCrcErr, + kIrqRxDone) && + clearIrqLocked(kIrqAll) && setBufferBaseLocked(0x00, 0x00) && + setRxLocked(kRxTimeoutInf); +} + +bool Sx126xRadio::transmit(const std::uint8_t* data, std::size_t size) +{ + if (data == nullptr || size == 0 || size > 220) + { + return false; + } + + std::lock_guard lock(mutex_); + if (!online_) + { + return false; + } + + bool ok = setBufferBaseLocked(0x00, 0x00); + const std::uint8_t packet[6] = { + static_cast((lora_config_.preamble_len >> 8) & 0xFF), + static_cast(lora_config_.preamble_len & 0xFF), + kLoRaHeaderExplicit, + static_cast(size), + lora_config_.crc_len ? kLoRaCrcOn : kLoRaCrcOff, + kLoRaIqStandard, + }; + ok = ok && writeCommandLocked(kCmdSetPacketParams, packet, sizeof(packet), true); + + if (ok) + { + std::array tx{}; + tx[0] = kCmdWriteBuffer; + tx[1] = 0x00; + std::memcpy(tx.data() + 2, data, size); + ok = transferLocked(tx.data(), nullptr, size + 2); + } + + ok = ok && setDioIrqParamsLocked(kIrqTxDone | kIrqTimeout, kIrqTxDone) && + clearIrqLocked(kIrqAll) && setTxLocked(0x000000); + if (!ok) + { + (void)setRxLocked(kRxTimeoutInf); + return false; + } + + const auto start = std::chrono::steady_clock::now(); + while (true) + { + const std::uint32_t irq = getIrqFlagsLocked(); + if ((irq & kIrqTxDone) != 0) + { + (void)clearIrqLocked(kIrqAll); + (void)setRxLocked(kRxTimeoutInf); + ++tx_packets_; + return true; + } + if ((irq & kIrqTimeout) != 0) + { + (void)clearIrqLocked(kIrqAll); + (void)setRxLocked(kRxTimeoutInf); + setErrorLocked("LoRa TX timeout"); + return false; + } + if (std::chrono::steady_clock::now() - start > + std::chrono::seconds(4)) + { + (void)setRxLocked(kRxTimeoutInf); + setErrorLocked("LoRa TX wait timed out"); + return false; + } + sleep_ms(10); + } +} + +bool Sx126xRadio::pollReceive(Sx126xPacket* out) +{ + if (out == nullptr) + { + return false; + } + + std::lock_guard lock(mutex_); + if (!online_) + { + return false; + } + + const std::uint32_t irq = getIrqFlagsLocked(); + updateLastIrqLocked(irq); + if ((irq & kIrqRxDone) == 0) + { + if ((irq & (kIrqTimeout | kIrqCrcErr | kIrqHeaderErr)) != 0) + { + if ((irq & kIrqCrcErr) != 0) + { + ++rx_crc_errors_; + setErrorLocked("LoRa RX CRC error"); + } + if ((irq & kIrqHeaderErr) != 0) + { + ++rx_header_errors_; + setErrorLocked("LoRa RX header error"); + } + if ((irq & kIrqTimeout) != 0) + { + ++rx_timeouts_; + setErrorLocked("LoRa RX timeout"); + } + (void)clearIrqLocked(kIrqAll); + (void)setRxLocked(kRxTimeoutInf); + } + return false; + } + + std::uint8_t offset = 0; + const int packet_len = getPacketLengthLocked(&offset); + if (packet_len <= 0 || + packet_len > static_cast(out->data.size())) + { + ++rx_invalid_lengths_; + setErrorLocked("LoRa RX invalid packet length"); + (void)clearIrqLocked(kIrqAll); + (void)setRxLocked(kRxTimeoutInf); + return false; + } + + if (readPacketLocked(offset, out->data.data(), + static_cast(packet_len)) != 0) + { + ++rx_read_errors_; + setErrorLocked("LoRa RX buffer read failed"); + (void)clearIrqLocked(kIrqAll); + (void)setRxLocked(kRxTimeoutInf); + return false; + } + + out->size = static_cast(packet_len); + (void)readPacketStatusLocked(&out->rssi_dbm, &out->snr_db); + out->freq_hz = + static_cast(lora_config_.freq_mhz * 1000000.0f + 0.5f); + out->bw_hz = + static_cast(lora_config_.bw_khz * 1000.0f + 0.5f); + out->sf = lora_config_.sf; + out->cr = lora_config_.cr; + + (void)clearIrqLocked(kIrqAll); + (void)setRxLocked(kRxTimeoutInf); + ++rx_packets_; + return true; +} + +float Sx126xRadio::readRssi() +{ + std::lock_guard lock(mutex_); + if (!online_) + { + return NAN; + } + std::uint8_t raw = 0; + const bool ok = readCommandLocked(kCmdGetRssiInst, nullptr, 0, &raw, 1, true); + return ok ? (static_cast(raw) / -2.0f) : NAN; +} + +bool Sx126xRadio::isOnline() const +{ + std::lock_guard lock(mutex_); + return online_; +} + +const char* Sx126xRadio::lastError() const +{ + std::lock_guard lock(mutex_); + return last_error_; +} + +Sx126xLoRaConfig Sx126xRadio::appliedLoRaConfig() const +{ + std::lock_guard lock(mutex_); + return lora_config_; +} + +Sx126xRadioStats Sx126xRadio::stats() const +{ + std::lock_guard lock(mutex_); + return Sx126xRadioStats{ + .online = online_, + .rx_packets = rx_packets_, + .tx_packets = tx_packets_, + .rx_crc_errors = rx_crc_errors_, + .rx_header_errors = rx_header_errors_, + .rx_timeouts = rx_timeouts_, + .rx_invalid_lengths = rx_invalid_lengths_, + .rx_read_errors = rx_read_errors_, + .last_irq_flags = last_irq_flags_, + .lora_config = lora_config_, + }; +} + +bool Sx126xRadio::initLocked(const Sx126xRadioConfig& config) +{ +#if !defined(__linux__) + (void)config; + setErrorLocked("SX126x Linux driver is unavailable on this OS"); + return false; +#else + if (initialized_) + { + return online_; + } + config_ = config; + setErrorLocked(""); + if (!openGpioLocked() || !openSpiLocked()) + { + closeLocked(); + return false; + } + + online_ = prepareAio2Locked() && probeLocked(); + if (!online_) + { + if (last_error_[0] == '\0') + { + setErrorStringLocked("SX126x probe failed: " + + describe_radio_config(config_)); + } + closeLocked(); + return false; + } + initialized_ = true; + + const std::uint8_t calibrate = 0x7F; + (void)writeCommandLocked(kCmdCalibrate, &calibrate, 1, true); + (void)setPacketTypeLocked(kPacketTypeLoRa); + (void)setBufferBaseLocked(0x00, 0x00); + const std::uint8_t regulator = kRegulatorDcDc; + (void)writeCommandLocked(kCmdSetRegulatorMode, ®ulator, 1, true); + if (config_.dio2_as_rf_switch) + { + const std::uint8_t dio2 = 0x01; + (void)writeCommandLocked(kCmdSetDio2AsRfSwitchCtrl, &dio2, 1, true); + } + if (config_.dio3_tcxo_1v8) + { + const std::uint8_t tcxo[4] = {0x02, 0x00, 0x01, 0x40}; + (void)writeCommandLocked(kCmdSetDio3AsTcxoCtrl, tcxo, sizeof(tcxo), true); + sleep_ms(5); + } + const std::uint8_t fallback = kFallbackStandbyRc; + (void)writeCommandLocked(kCmdSetRxTxFallbackMode, &fallback, 1, true); + (void)clearIrqLocked(kIrqAll); + const std::uint8_t ocp = ocp_for_60ma(); + (void)writeRegisterLocked(kRegOcpConfiguration, &ocp, 1); + return true; +#endif +} + +bool Sx126xRadio::openSpiLocked() +{ +#if !defined(__linux__) + return false; +#else + spi_fd_ = open(config_.spi_device.c_str(), O_RDWR | O_CLOEXEC); + if (spi_fd_ < 0) + { + setErrorStringLocked("open spidev failed: " + config_.spi_device + + errno_suffix()); + return false; + } + + std::uint8_t mode = SPI_MODE_0; + std::uint8_t bits = 8; + if (ioctl(spi_fd_, SPI_IOC_WR_MODE, &mode) != 0 || + ioctl(spi_fd_, SPI_IOC_WR_BITS_PER_WORD, &bits) != 0 || + ioctl(spi_fd_, SPI_IOC_WR_MAX_SPEED_HZ, &config_.spi_speed_hz) != 0) + { + setErrorStringLocked("configure spidev failed: " + + describe_radio_config(config_) + errno_suffix()); + return false; + } + return true; +#endif +} + +bool Sx126xRadio::openGpioLocked() +{ +#if !defined(__linux__) + return false; +#else + chip_fd_ = open(config_.gpiochip.c_str(), O_RDONLY | O_CLOEXEC); + if (chip_fd_ < 0) + { + setErrorStringLocked("open gpiochip failed: " + config_.gpiochip + + errno_suffix()); + return false; + } + + if (config_.power_gpio >= 0 && + !request_gpio_line(chip_fd_, + static_cast(config_.power_gpio), + true, + 1, + "trailmate-lora-power", + &power_fd_)) + { + setErrorStringLocked("request LoRa power GPIO failed: gpio=" + + std::to_string(config_.power_gpio) + + errno_suffix()); + return false; + } + if (config_.reset_gpio >= 0 && + !request_gpio_line(chip_fd_, + static_cast(config_.reset_gpio), + true, + 1, + "trailmate-lora-reset", + &reset_fd_)) + { + setErrorStringLocked("request LoRa reset GPIO failed: gpio=" + + std::to_string(config_.reset_gpio) + + errno_suffix()); + return false; + } + if (config_.busy_gpio >= 0 && + !request_gpio_line(chip_fd_, + static_cast(config_.busy_gpio), + false, + 0, + "trailmate-lora-busy", + &busy_fd_)) + { + setErrorStringLocked("request LoRa busy GPIO failed: gpio=" + + std::to_string(config_.busy_gpio) + + errno_suffix()); + return false; + } + if (config_.irq_gpio >= 0 && + !request_gpio_line(chip_fd_, + static_cast(config_.irq_gpio), + false, + 0, + "trailmate-lora-irq", + &irq_fd_)) + { + setErrorStringLocked("request LoRa IRQ GPIO failed: gpio=" + + std::to_string(config_.irq_gpio) + + errno_suffix()); + return false; + } + return true; +#endif +} + +void Sx126xRadio::closeLocked() +{ + close_fd(power_fd_); + close_fd(reset_fd_); + close_fd(busy_fd_); + close_fd(irq_fd_); + close_fd(chip_fd_); + close_fd(spi_fd_); + initialized_ = false; + online_ = false; + users_ = 0; +} + +void Sx126xRadio::updateLastIrqLocked(std::uint32_t irq) +{ + last_irq_flags_ = irq; +} + +void Sx126xRadio::waitReadyLocked() const +{ +#if defined(__linux__) + if (busy_fd_ >= 0) + { + const auto start = std::chrono::steady_clock::now(); + while (get_gpio_value(busy_fd_) != 0) + { + if (std::chrono::steady_clock::now() - start > + std::chrono::milliseconds(80)) + { + break; + } + sleep_ms(1); + } + return; + } +#endif + std::this_thread::sleep_for(std::chrono::microseconds(250)); +} + +bool Sx126xRadio::transferLocked(const std::uint8_t* tx, + std::uint8_t* rx, + std::size_t size) +{ +#if !defined(__linux__) + (void)tx; + (void)rx; + (void)size; + return false; +#else + if (spi_fd_ < 0 || tx == nullptr || size == 0 || size > 260) + { + setErrorLocked("invalid SPI transfer"); + return false; + } + spi_ioc_transfer transfer{}; + transfer.tx_buf = reinterpret_cast(tx); + transfer.rx_buf = reinterpret_cast(rx); + transfer.len = static_cast<__u32>(size); + transfer.speed_hz = config_.spi_speed_hz; + transfer.bits_per_word = 8; + transfer.cs_change = 0; + if (ioctl(spi_fd_, SPI_IOC_MESSAGE(1), &transfer) < 1) + { + setErrorStringLocked("SPI transfer failed: bytes=" + + std::to_string(size) + " " + + describe_radio_config(config_) + + errno_suffix()); + return false; + } + return true; +#endif +} + +bool Sx126xRadio::writeCommandLocked(std::uint8_t cmd, + const std::uint8_t* data, + std::size_t size, + bool wait) +{ + waitReadyLocked(); + std::array tx{}; + const std::size_t total = 1 + size; + if (total > tx.size()) + { + setErrorLocked("SX126x command too large"); + return false; + } + tx[0] = cmd; + if (data != nullptr && size > 0) + { + std::memcpy(tx.data() + 1, data, size); + } + const bool ok = transferLocked(tx.data(), nullptr, total); + if (wait) + { + waitReadyLocked(); + } + return ok; +} + +bool Sx126xRadio::readCommandLocked(std::uint8_t cmd, + const std::uint8_t* prefix, + std::size_t prefix_size, + std::uint8_t* data, + std::size_t size, + bool wait) +{ + waitReadyLocked(); + std::array tx{}; + std::array rx{}; + const std::size_t total = 1 + prefix_size + 1 + size; + if (total > tx.size()) + { + setErrorLocked("SX126x read command too large"); + return false; + } + tx[0] = cmd; + if (prefix != nullptr && prefix_size > 0) + { + std::memcpy(tx.data() + 1, prefix, prefix_size); + } + const bool ok = transferLocked(tx.data(), rx.data(), total); + if (ok && data != nullptr && size > 0) + { + std::memcpy(data, rx.data() + 1 + prefix_size + 1, size); + } + if (wait) + { + waitReadyLocked(); + } + return ok; +} + +bool Sx126xRadio::writeRegisterLocked(std::uint16_t addr, + const std::uint8_t* data, + std::size_t size) +{ + const std::uint8_t prefix[2] = { + static_cast((addr >> 8) & 0xFF), + static_cast(addr & 0xFF), + }; + std::array tx{}; + const std::size_t total = 1 + sizeof(prefix) + size; + if (total > tx.size()) + { + setErrorLocked("SX126x register write too large"); + return false; + } + tx[0] = kCmdWriteRegister; + std::memcpy(tx.data() + 1, prefix, sizeof(prefix)); + if (data != nullptr && size > 0) + { + std::memcpy(tx.data() + 1 + sizeof(prefix), data, size); + } + waitReadyLocked(); + const bool ok = transferLocked(tx.data(), nullptr, total); + waitReadyLocked(); + return ok; +} + +bool Sx126xRadio::readRegisterLocked(std::uint16_t addr, + std::uint8_t* data, + std::size_t size) +{ + const std::uint8_t prefix[2] = { + static_cast((addr >> 8) & 0xFF), + static_cast(addr & 0xFF), + }; + return readCommandLocked(kCmdReadRegister, prefix, sizeof(prefix), data, size, true); +} + +bool Sx126xRadio::prepareAio2Locked() +{ +#if !defined(__linux__) + return false; +#else + if (power_fd_ >= 0) + { + if (!set_gpio_value(power_fd_, 1)) + { + setErrorStringLocked("set LoRa power GPIO high failed: gpio=" + + std::to_string(config_.power_gpio) + + errno_suffix()); + return false; + } + sleep_ms(100); + } + + if (reset_fd_ >= 0) + { + if (!set_gpio_value(reset_fd_, 0)) + { + setErrorStringLocked("assert LoRa reset failed: gpio=" + + std::to_string(config_.reset_gpio) + + errno_suffix()); + return false; + } + sleep_ms(10); + if (!set_gpio_value(reset_fd_, 1)) + { + setErrorStringLocked("release LoRa reset failed: gpio=" + + std::to_string(config_.reset_gpio) + + errno_suffix()); + return false; + } + sleep_ms(30); + } + + waitReadyLocked(); + + if (config_.dio3_tcxo_1v8) + { + const std::uint8_t tcxo[4] = {0x02, 0x00, 0x01, 0x40}; + if (!writeCommandLocked(kCmdSetDio3AsTcxoCtrl, tcxo, sizeof(tcxo), true)) + { + setErrorStringLocked("AIO2 SX1262 TCXO enable failed: " + + describe_radio_config(config_)); + return false; + } + sleep_ms(20); + } + + if (config_.dio2_as_rf_switch) + { + const std::uint8_t dio2 = 0x01; + if (!writeCommandLocked(kCmdSetDio2AsRfSwitchCtrl, &dio2, 1, true)) + { + setErrorStringLocked("AIO2 SX1262 DIO2 RF switch enable failed: " + + describe_radio_config(config_)); + return false; + } + } + + const std::uint8_t standby = kStandbyRc; + if (!writeCommandLocked(kCmdSetStandby, &standby, 1, true)) + { + setErrorStringLocked("SX1262 standby command failed after AIO2 power/reset: " + + describe_radio_config(config_)); + return false; + } + sleep_ms(2); + return true; +#endif +} + +bool Sx126xRadio::readStatusLocked(std::uint8_t* out_status) +{ + if (out_status == nullptr) + { + setErrorLocked("invalid SX1262 status read"); + return false; + } + waitReadyLocked(); + const std::uint8_t tx[2] = {kCmdGetStatus, 0x00}; + std::uint8_t rx[2] = {}; + if (!transferLocked(tx, rx, sizeof(tx))) + { + return false; + } + *out_status = rx[1]; + waitReadyLocked(); + return true; +} + +bool Sx126xRadio::probeLocked() +{ + std::uint8_t status = 0; + const bool have_status = readStatusLocked(&status); + + std::uint8_t original_ocp = 0; + if (!readRegisterLocked(kRegOcpConfiguration, &original_ocp, 1)) + { + setErrorStringLocked("SX1262 probe failed: OCP register read failed; status=" + + (have_status ? hex_bytes(&status, 1) + : std::string("unavailable")) + + " " + describe_radio_config(config_)); + return false; + } + + const std::uint8_t probe_ocp = + static_cast(original_ocp ^ 0x01U); + if (!writeRegisterLocked(kRegOcpConfiguration, &probe_ocp, 1)) + { + setErrorStringLocked("SX1262 probe failed: OCP test write failed; status=" + + (have_status ? hex_bytes(&status, 1) + : std::string("unavailable")) + + " ocp=" + hex_bytes(&original_ocp, 1) + + " " + describe_radio_config(config_)); + return false; + } + + std::uint8_t readback_ocp = 0; + const bool readback_ok = + readRegisterLocked(kRegOcpConfiguration, &readback_ocp, 1); + (void)writeRegisterLocked(kRegOcpConfiguration, &original_ocp, 1); + + if (!readback_ok || readback_ocp != probe_ocp) + { + setErrorStringLocked("SX1262 probe failed: OCP readback mismatch; status=" + + (have_status ? hex_bytes(&status, 1) + : std::string("unavailable")) + + " original=" + hex_bytes(&original_ocp, 1) + + " wrote=" + hex_bytes(&probe_ocp, 1) + + " read=" + hex_bytes(&readback_ocp, 1) + + " busy=" + + std::to_string(busy_fd_ >= 0 + ? get_gpio_value(busy_fd_) + : -1) + + " irq=" + + std::to_string(irq_fd_ >= 0 + ? get_gpio_value(irq_fd_) + : -1) + + " " + describe_radio_config(config_)); + return false; + } + + return true; +} + +bool Sx126xRadio::setPacketTypeLocked(std::uint8_t packet_type) +{ + if (packet_type_ == packet_type) + { + return true; + } + if (!writeCommandLocked(kCmdSetPacketType, &packet_type, 1, true)) + { + return false; + } + packet_type_ = packet_type; + return true; +} + +bool Sx126xRadio::setRfFrequencyLocked(float freq_mhz) +{ + if (std::fabs(freq_mhz_ - freq_mhz) >= 20.0f) + { + std::uint8_t cal[2] = {0xE1, 0xE9}; + if (freq_mhz < 779.0f) + { + cal[0] = 0xC1; + cal[1] = 0xC5; + } + else if (freq_mhz < 902.0f) + { + cal[0] = 0xD7; + cal[1] = 0xDB; + } + (void)writeCommandLocked(kCmdCalibrateImage, cal, sizeof(cal), true); + } + + const std::uint32_t raw = rf_frequency_raw(freq_mhz); + const std::uint8_t data[4] = { + static_cast((raw >> 24) & 0xFF), + static_cast((raw >> 16) & 0xFF), + static_cast((raw >> 8) & 0xFF), + static_cast(raw & 0xFF), + }; + if (!writeCommandLocked(kCmdSetRfFrequency, data, sizeof(data), true)) + { + return false; + } + freq_mhz_ = freq_mhz; + return true; +} + +bool Sx126xRadio::setTxPowerLocked(std::int8_t tx_power) +{ + const std::int8_t clipped = std::clamp(tx_power, -9, 22); + std::uint8_t ocp = 0; + (void)readRegisterLocked(kRegOcpConfiguration, &ocp, 1); + const std::uint8_t pa_config[4] = { + 0x04, + 0x07, + kPaConfigDeviceSelSx1262, + kPaConfigPaLut, + }; + if (!writeCommandLocked(kCmdSetPaConfig, pa_config, sizeof(pa_config), true)) + { + return false; + } + const std::uint8_t tx_params[2] = { + static_cast(clipped), + kPaRamp200u, + }; + const bool ok = writeCommandLocked(kCmdSetTxParams, tx_params, sizeof(tx_params), true); + (void)writeRegisterLocked(kRegOcpConfiguration, &ocp, 1); + return ok; +} + +bool Sx126xRadio::setDioIrqParamsLocked(std::uint16_t irq_mask, + std::uint16_t dio1_mask) +{ + const std::uint8_t data[8] = { + static_cast((irq_mask >> 8) & 0xFF), + static_cast(irq_mask & 0xFF), + static_cast((dio1_mask >> 8) & 0xFF), + static_cast(dio1_mask & 0xFF), + 0x00, + 0x00, + 0x00, + 0x00, + }; + return writeCommandLocked(kCmdSetDioIrqParams, data, sizeof(data), true); +} + +bool Sx126xRadio::clearIrqLocked(std::uint16_t flags) +{ + const std::uint8_t data[2] = { + static_cast((flags >> 8) & 0xFF), + static_cast(flags & 0xFF), + }; + return writeCommandLocked(kCmdClearIrqStatus, data, sizeof(data), true); +} + +bool Sx126xRadio::setBufferBaseLocked(std::uint8_t tx_base, + std::uint8_t rx_base) +{ + const std::uint8_t data[2] = {tx_base, rx_base}; + return writeCommandLocked(kCmdSetBufferBaseAddress, data, sizeof(data), true); +} + +bool Sx126xRadio::setRxLocked(std::uint32_t timeout_raw) +{ + const std::uint8_t data[3] = { + static_cast((timeout_raw >> 16) & 0xFF), + static_cast((timeout_raw >> 8) & 0xFF), + static_cast(timeout_raw & 0xFF), + }; + return writeCommandLocked(kCmdSetRx, data, sizeof(data), true); +} + +bool Sx126xRadio::setTxLocked(std::uint32_t timeout_raw) +{ + const std::uint8_t data[3] = { + static_cast((timeout_raw >> 16) & 0xFF), + static_cast((timeout_raw >> 8) & 0xFF), + static_cast(timeout_raw & 0xFF), + }; + return writeCommandLocked(kCmdSetTx, data, sizeof(data), true); +} + +bool Sx126xRadio::configureLoRaLocked(const Sx126xLoRaConfig& config) +{ + if (!setPacketTypeLocked(kPacketTypeLoRa)) + { + return false; + } + const std::uint8_t standby_mode = kStandbyRc; + if (!writeCommandLocked(kCmdSetStandby, &standby_mode, 1, true)) + { + return false; + } + if (!setRfFrequencyLocked(config.freq_mhz) || + !setTxPowerLocked(config.tx_power_dbm)) + { + return false; + } + + const std::uint8_t mod[4] = { + config.sf, + map_lora_bw(config.bw_khz), + map_lora_cr(config.cr), + calc_ldro(config.sf, config.bw_khz), + }; + if (!writeCommandLocked(kCmdSetModulationParams, mod, sizeof(mod), true)) + { + return false; + } + + const std::uint8_t packet[6] = { + static_cast((config.preamble_len >> 8) & 0xFF), + static_cast(config.preamble_len & 0xFF), + kLoRaHeaderExplicit, + 0xFF, + config.crc_len ? kLoRaCrcOn : kLoRaCrcOff, + kLoRaIqStandard, + }; + if (!writeCommandLocked(kCmdSetPacketParams, packet, sizeof(packet), true)) + { + return false; + } + + const std::uint8_t sync[2] = { + static_cast((config.sync_word & 0xF0) | 0x04), + static_cast(((config.sync_word & 0x0F) << 4) | 0x04), + }; + return writeRegisterLocked(kRegLoraSyncWordMsb, sync, sizeof(sync)); +} + +std::uint32_t Sx126xRadio::getIrqFlagsLocked() +{ + std::uint8_t irq[2] = {}; + if (!readCommandLocked(kCmdGetIrqStatus, nullptr, 0, irq, sizeof(irq), true)) + { + return 0; + } + return (static_cast(irq[0]) << 8) | irq[1]; +} + +int Sx126xRadio::getPacketLengthLocked(std::uint8_t* out_offset) +{ + std::uint8_t status[2] = {}; + if (!readCommandLocked(kCmdGetRxBufferStatus, + nullptr, + 0, + status, + sizeof(status), + true)) + { + return -1; + } + if (out_offset != nullptr) + { + *out_offset = status[1]; + } + return static_cast(status[0]); +} + +int Sx126xRadio::readPacketLocked(std::uint8_t offset, + std::uint8_t* buffer, + std::size_t size) +{ + if (buffer == nullptr || size == 0) + { + return -1; + } + return readCommandLocked(kCmdReadBuffer, &offset, 1, buffer, size, true) ? 0 + : -1; +} + +bool Sx126xRadio::readPacketStatusLocked(float* out_rssi_dbm, + float* out_snr_db) +{ + std::uint8_t status[3] = {}; + if (!readCommandLocked(kCmdGetPacketStatus, + nullptr, + 0, + status, + sizeof(status), + true)) + { + return false; + } + if (out_rssi_dbm != nullptr) + { + *out_rssi_dbm = static_cast(status[0]) / -2.0f; + } + if (out_snr_db != nullptr) + { + *out_snr_db = static_cast(static_cast(status[1])) / 4.0f; + } + return true; +} + +void Sx126xRadio::setErrorLocked(const char* error) +{ + std::snprintf(last_error_, sizeof(last_error_), "%s", + error == nullptr ? "" : error); +} + +void Sx126xRadio::setErrorStringLocked(const std::string& error) +{ + setErrorLocked(error.c_str()); +} + +} // namespace platform::linux_runtime diff --git a/platform/linux/common/src/platform/ui/gps_runtime.cpp b/platform/linux/common/src/platform/ui/gps_runtime.cpp index deb7fdb9..d90a829f 100644 --- a/platform/linux/common/src/platform/ui/gps_runtime.cpp +++ b/platform/linux/common/src/platform/ui/gps_runtime.cpp @@ -7,10 +7,12 @@ #include #include #include +#include #include #include #include #include +#include #if defined(__linux__) #include @@ -19,6 +21,8 @@ #include #endif +#include "platform/linux/runtime_packet_log.h" + namespace platform::ui::gps { namespace @@ -45,6 +49,7 @@ constexpr const char* kGpsFixEnv = "TRAIL_MATE_GPS_FIX"; constexpr const char* kGpsDeviceEnv = "TRAIL_MATE_GPS_DEVICE"; constexpr const char* kGpsBaudEnv = "TRAIL_MATE_GPS_BAUD"; constexpr const char* kGpsNmeaFileEnv = "TRAIL_MATE_GPS_NMEA_FILE"; +constexpr const char* kGpsAutoSerialEnv = "TRAIL_MATE_GPS_AUTO_SERIAL"; constexpr double kDefaultLat = 25.0389; constexpr double kDefaultLng = 102.7183; @@ -161,6 +166,27 @@ int env_int_or_default(const char* name, int fallback) return static_cast(parsed); } +bool env_configured(const char* name) +{ + const char* value = std::getenv(name); + return value != nullptr && value[0] != '\0'; +} + +bool auto_serial_probe_enabled() +{ + if (env_configured(kGpsAutoSerialEnv)) + { + return env_flag_or_default(kGpsAutoSerialEnv, false); + } + +#if defined(__linux__) + std::error_code ec; + return std::filesystem::exists("/dev/spidev1.0", ec) && !ec; +#else + return false; +#endif +} + ::gps::GnssFix env_fix_or_default() { switch (env_int_or_default(kGpsFixEnv, static_cast(::gps::GnssFix::FIX3D))) @@ -379,6 +405,73 @@ bool verify_checksum(const char* line) return end != text && checksum == static_cast(expected & 0xFF); } +std::string sentence_checksum_text(const char* line) +{ + if (!line) + { + return {}; + } + const char* star = std::strchr(line, '*'); + if (!star || !star[1] || !star[2]) + { + return {}; + } + return std::string(star + 1, 2); +} + +void append_nmea_log_locked(const char* sentence, bool checksum_ok) +{ + if (!sentence || sentence[0] != '$') + { + return; + } + + const char* star = std::strchr(sentence, '*'); + const char* comma = std::strchr(sentence, ','); + const char* header_end = comma != nullptr ? comma : (star != nullptr ? star : sentence + std::strlen(sentence)); + std::string header(sentence, static_cast(header_end - sentence)); + std::string body{}; + if (comma != nullptr) + { + const char* body_end = star != nullptr ? star : sentence + std::strlen(sentence); + body.assign(comma + 1, static_cast(body_end - comma - 1)); + } + + ::platform::linux_runtime::PacketLogEntry entry{}; + entry.source = ::platform::linux_runtime::PacketLogSource::Gps; + entry.direction = ::platform::linux_runtime::PacketLogDirection::Rx; + entry.title = header; + entry.summary = checksum_ok ? "NMEA sentence parsed" + : "NMEA checksum failed"; + entry.raw_hex = + ::platform::linux_runtime::hex_bytes(sentence, std::strlen(sentence)); + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Header, + .label = "head", + .text = header, + }); + if (!body.empty()) + { + entry.segments.push_back({ + .kind = ::platform::linux_runtime::PacketLogSegmentKind::Body, + .label = "body", + .text = body, + }); + } + const std::string checksum = sentence_checksum_text(sentence); + if (!checksum.empty()) + { + entry.segments.push_back({ + .kind = checksum_ok + ? ::platform::linux_runtime::PacketLogSegmentKind::Checksum + : ::platform::linux_runtime::PacketLogSegmentKind::Error, + .label = "sum", + .text = checksum, + }); + } + ::platform::linux_runtime::append_packet_log(std::move(entry)); +} + std::size_t split_fields(char* sentence, std::array& fields) { fields.fill(nullptr); @@ -610,13 +703,20 @@ void parse_gsv_locked(const char* talker, const std::array& f void parse_sentence_locked(const char* sentence, uint32_t ts) { - if (!sentence || sentence[0] != '$' || !verify_checksum(sentence)) + const bool checksum_ok = verify_checksum(sentence); + if (sentence && sentence[0] == '$') + { + append_nmea_log_locked(sentence, checksum_ok); + } + if (!sentence || sentence[0] != '$' || !checksum_ok) { return; } char working[kLineBufferSize] = {}; - std::strncpy(working, sentence + 1, sizeof(working) - 1); + const std::size_t copy_len = + std::min(std::strlen(sentence + 1), sizeof(working) - 1); + std::memcpy(working, sentence + 1, copy_len); char* star = std::strchr(working, '*'); if (star) { @@ -712,6 +812,31 @@ std::string requested_source_path(bool* out_is_serial) return std::string(file); } +#if defined(__linux__) + if (!auto_serial_probe_enabled()) + { + return {}; + } + + std::error_code ec; + if (std::filesystem::exists("/dev/ttyS0", ec) && !ec) + { + if (out_is_serial) + { + *out_is_serial = true; + } + return "/dev/ttyS0"; + } + if (std::filesystem::exists("/dev/serial0", ec) && !ec) + { + if (out_is_serial) + { + *out_is_serial = true; + } + return "/dev/serial0"; + } +#endif + return {}; } @@ -761,7 +886,12 @@ bool open_serial_locked(const std::string& path) } cfmakeraw(&tio); - const speed_t baud = baud_to_termios(env_int_or_default(kGpsBaudEnv, 38400)); + const int default_baud = + (!env_configured(kGpsBaudEnv) && + (path == "/dev/ttyS0" || path == "/dev/serial0")) + ? 9600 + : 38400; + const speed_t baud = baud_to_termios(env_int_or_default(kGpsBaudEnv, default_baud)); cfsetispeed(&tio, baud); cfsetospeed(&tio, baud); tio.c_cflag |= (CLOCAL | CREAD); @@ -945,12 +1075,19 @@ GpsState get_data() } std::lock_guard lock(s_mutex); + bool requested_serial = false; + const bool source_requested = + !requested_source_path(&requested_serial).empty(); poll_external_source_locked(); if (external_source_active_locked()) { return make_external_state_locked(); } + if (source_requested) + { + return {}; + } if (s_runtime.last_motion_ms == 0) { @@ -975,6 +1112,9 @@ bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count } std::lock_guard lock(s_mutex); + bool requested_serial = false; + const bool source_requested = + !requested_source_path(&requested_serial).empty(); poll_external_source_locked(); if (external_source_active_locked()) @@ -1013,6 +1153,18 @@ bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count } return true; } + if (source_requested) + { + if (out_count) + { + *out_count = 0; + } + if (status) + { + *status = GnssStatus{}; + } + return false; + } const auto satellites = default_satellites(); const std::size_t count = std::min(max, satellites.size()); @@ -1035,6 +1187,81 @@ bool get_gnss_snapshot(GnssSatInfo* out, std::size_t max, std::size_t* out_count return true; } +GpsDiagnosticsSnapshot diagnostics() +{ + GpsDiagnosticsSnapshot snapshot{}; + snapshot.supported = runtime_active(); + snapshot.enabled = is_enabled(); + snapshot.powered = is_powered(); + snapshot.collection_interval_ms = s_collection_interval_ms; + snapshot.poll_interval_ms = 1000; + + if (!snapshot.supported) + { + snapshot.code = ::gps::GpsDiagnosticCode::Disabled; + return snapshot; + } + if (!snapshot.enabled) + { + snapshot.code = ::gps::GpsDiagnosticCode::NotEnabled; + return snapshot; + } + if (!snapshot.powered) + { + snapshot.code = ::gps::GpsDiagnosticCode::PowerOff; + return snapshot; + } + + std::lock_guard lock(s_mutex); + bool requested_serial = false; + const bool source_requested = + !requested_source_path(&requested_serial).empty(); + poll_external_source_locked(); + + if (external_source_active_locked()) + { + snapshot.ready = true; + snapshot.has_fix = s_runtime.data.valid; + snapshot.satellites = s_runtime.data.satellites; + snapshot.sats_in_view = s_runtime.status.sats_in_view; + snapshot.sats_in_use = s_runtime.status.sats_in_use; + snapshot.last_rx_age_ms = s_runtime.last_rx_ms ? (now_ms() - s_runtime.last_rx_ms) : 0xFFFFFFFFUL; + } + else if (!source_requested) + { + const auto state = make_default_state(); + const auto status = make_default_status(); + snapshot.ready = true; + snapshot.has_fix = state.valid; + snapshot.satellites = state.satellites; + snapshot.sats_in_view = status.sats_in_view; + snapshot.sats_in_use = status.sats_in_use; + snapshot.last_rx_age_ms = 0; + } + else + { + snapshot.ready = false; + } + + if (!snapshot.ready) + { + snapshot.code = ::gps::GpsDiagnosticCode::TransportNotReady; + } + else if (snapshot.last_rx_age_ms != 0xFFFFFFFFUL && snapshot.last_rx_age_ms > kExternalSourceStaleMs) + { + snapshot.code = ::gps::GpsDiagnosticCode::TrafficStalled; + } + else if (!snapshot.has_fix) + { + snapshot.code = ::gps::GpsDiagnosticCode::NoFix; + } + else + { + snapshot.code = ::gps::GpsDiagnosticCode::OK; + } + return snapshot; +} + uint32_t last_motion_ms() { if (!runtime_active()) @@ -1098,6 +1325,11 @@ void set_external_nmea_config(uint8_t output_hz, uint8_t sentence_mask) s_external_nmea_sentence_mask = sentence_mask; } +void set_receiver_init_config(const GpsReceiverInitConfig& config) +{ + (void)config; +} + void set_motion_idle_timeout(uint32_t timeout_ms) { s_motion_idle_timeout_ms = timeout_ms; diff --git a/platform/linux/common/src/platform/ui/lora_runtime.cpp b/platform/linux/common/src/platform/ui/lora_runtime.cpp index 7b60c24c..33f49d99 100644 --- a/platform/linux/common/src/platform/ui/lora_runtime.cpp +++ b/platform/linux/common/src/platform/ui/lora_runtime.cpp @@ -6,10 +6,14 @@ #include #include #include +#include #include #include +#include #include "platform/linux/capability_status.h" +#include "platform/linux/runtime_mode.h" +#include "platform/linux/sx126x_radio.h" namespace platform::ui::lora { @@ -27,6 +31,7 @@ struct SpectralPeak constexpr const char* kNoiseFloorEnv = "TRAIL_MATE_LORA_NOISE_FLOOR_DBM"; constexpr const char* kPrimaryPeakEnv = "TRAIL_MATE_LORA_PRIMARY_PEAK_MHZ"; constexpr const char* kSecondaryPeakEnv = "TRAIL_MATE_LORA_SECONDARY_PEAK_MHZ"; +constexpr const char* kSimulatedLoraEnv = "TRAIL_MATE_LORA_SIMULATED"; constexpr std::array kDefaultPeaks{{ {433.175f, -69.0f}, @@ -40,6 +45,32 @@ bool s_configured = false; Clock::time_point s_started_at = Clock::now(); float s_freq_mhz = 0.0f; ReceiveConfig s_config{}; +bool s_real_driver = false; +std::string s_capability_message{}; + +bool env_flag_or_default(const char* name, bool fallback) +{ + const char* value = std::getenv(name); + if (!value || value[0] == '\0') + { + return fallback; + } + + return std::strcmp(value, "1") == 0 || std::strcmp(value, "true") == 0 || + std::strcmp(value, "TRUE") == 0 || std::strcmp(value, "yes") == 0 || + std::strcmp(value, "YES") == 0; +} + +bool simulated_lora_enabled() +{ + if (const char* value = std::getenv(kSimulatedLoraEnv); + value != nullptr && value[0] != '\0') + { + return env_flag_or_default(kSimulatedLoraEnv, false); + } + return ::platform::linux_runtime::resolve_runtime_mode() == + ::platform::linux_runtime::LinuxRuntimeMode::SimulatorDemo; +} float env_float_or_default(const char* name, float fallback) { @@ -104,21 +135,51 @@ float current_rssi_locked() bool is_supported() { - return true; + return ::platform::linux_runtime::Sx126xRadio::hardwareCandidatePresent() || + simulated_lora_enabled(); } CapabilityStatus capability_status() { - return {CapabilityState::Simulated, - "Synthetic RSSI generator. No real LoRa radio driver."}; + std::lock_guard lock(s_mutex); + auto& radio = ::platform::linux_runtime::Sx126xRadio::instance(); + if (radio.isOnline()) + { + return {CapabilityState::Available, + "SX1262 is bound through Linux spidev/gpiochip."}; + } + if (::platform::linux_runtime::Sx126xRadio::hardwareCandidatePresent()) + { + const char* last_error = radio.lastError(); + s_capability_message = "SX1262 endpoint present; driver not online"; + if (last_error != nullptr && last_error[0] != '\0') + { + s_capability_message += ": "; + s_capability_message += last_error; + } + else + { + s_capability_message += "."; + } + return {CapabilityState::Degraded, s_capability_message.c_str()}; + } + if (simulated_lora_enabled()) + { + return {CapabilityState::Simulated, + "Synthetic RSSI generator. No real LoRa radio driver."}; + } + return {CapabilityState::Unsupported, + "No LoRa SPI endpoint is present."}; } bool acquire() { std::lock_guard lock(s_mutex); - s_acquired = true; + auto& radio = ::platform::linux_runtime::Sx126xRadio::instance(); + s_real_driver = radio.acquire(); + s_acquired = s_real_driver || simulated_lora_enabled(); s_started_at = Clock::now(); - return true; + return s_acquired; } bool is_online() @@ -137,12 +198,32 @@ bool configure_receive(float freq_mhz, const ReceiveConfig& config) s_freq_mhz = freq_mhz; s_config = config; s_configured = true; - return true; + if (s_real_driver) + { + auto lora_config = + ::platform::linux_runtime::Sx126xRadio:: + defaultLoRaConfigFromEnvironment(); + lora_config.freq_mhz = freq_mhz; + lora_config.bw_khz = config.bw_khz; + lora_config.sf = config.sf; + lora_config.cr = config.cr; + lora_config.tx_power_dbm = config.tx_power; + lora_config.preamble_len = config.preamble_len; + lora_config.sync_word = config.sync_word; + lora_config.crc_len = config.crc_len; + return ::platform::linux_runtime::Sx126xRadio::instance() + .configureLoRa(lora_config); + } + return simulated_lora_enabled(); } float read_instant_rssi() { std::lock_guard lock(s_mutex); + if (s_real_driver) + { + return ::platform::linux_runtime::Sx126xRadio::instance().readRssi(); + } return current_rssi_locked(); } @@ -151,6 +232,11 @@ void release() std::lock_guard lock(s_mutex); s_acquired = false; s_configured = false; + if (s_real_driver) + { + ::platform::linux_runtime::Sx126xRadio::instance().release(); + } + s_real_driver = false; s_freq_mhz = 0.0f; } diff --git a/platform/linux/common/src/platform/ui/settings_store.cpp b/platform/linux/common/src/platform/ui/settings_store.cpp index ae2d3f98..8de36252 100644 --- a/platform/linux/common/src/platform/ui/settings_store.cpp +++ b/platform/linux/common/src/platform/ui/settings_store.cpp @@ -1,17 +1,14 @@ #include "platform/ui/settings_store.h" -#include #include -#include #include #include -#include #include -#include #include -#include #include +#include + #include "platform/linux/runtime_paths.h" namespace platform::ui::settings_store @@ -28,174 +25,240 @@ enum class ValueKind : char Blob = 'x', }; -struct StoredValue -{ - ValueKind kind = ValueKind::Int; - std::string payload{}; -}; - -using NamespaceMap = std::unordered_map; - std::mutex s_store_mutex; -std::filesystem::path namespace_path(const char* ns) -{ - return ::platform::linux_runtime::settings_file(ns); -} - bool ensure_parent_directory(const std::filesystem::path& file_path) { return ::platform::linux_runtime::ensure_directory(file_path.parent_path()); } -std::string hex_encode(const uint8_t* data, std::size_t len) +sqlite3* open_database() { - static constexpr char kHex[] = "0123456789ABCDEF"; - - std::string out; - out.reserve(len * 2U); - for (std::size_t index = 0; index < len; ++index) + const std::filesystem::path db_path = + ::platform::linux_runtime::sqlite_database_path(); + if (!ensure_parent_directory(db_path)) { - const uint8_t value = data[index]; - out.push_back(kHex[(value >> 4) & 0x0F]); - out.push_back(kHex[value & 0x0F]); + return nullptr; } - return out; + + sqlite3* db = nullptr; + const int rc = sqlite3_open_v2(db_path.string().c_str(), + &db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | + SQLITE_OPEN_FULLMUTEX, + nullptr); + if (rc != SQLITE_OK) + { + if (db != nullptr) + { + sqlite3_close(db); + } + return nullptr; + } + + sqlite3_busy_timeout(db, 5000); + return db; } -bool decode_hex_nibble(char ch, uint8_t* out) +bool exec_sql(sqlite3* db, const char* sql) { - if (!out) - { - return false; - } - if (ch >= '0' && ch <= '9') - { - *out = static_cast(ch - '0'); - return true; - } - if (ch >= 'A' && ch <= 'F') - { - *out = static_cast(10 + (ch - 'A')); - return true; - } - if (ch >= 'a' && ch <= 'f') - { - *out = static_cast(10 + (ch - 'a')); - return true; - } - return false; -} - -bool hex_decode(const std::string& hex, std::vector* out) -{ - if (!out || (hex.size() % 2U) != 0U) + if (db == nullptr || sql == nullptr) { return false; } - out->clear(); - out->reserve(hex.size() / 2U); - for (std::size_t index = 0; index < hex.size(); index += 2U) + char* error = nullptr; + const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &error); + if (error != nullptr) { - uint8_t high = 0; - uint8_t low = 0; - if (!decode_hex_nibble(hex[index], &high) || !decode_hex_nibble(hex[index + 1U], &low)) - { - out->clear(); - return false; - } - out->push_back(static_cast((high << 4) | low)); + sqlite3_free(error); } - return true; + return rc == SQLITE_OK; } -NamespaceMap load_namespace_map(const char* ns) +bool ensure_schema(sqlite3* db) { - NamespaceMap map; - - const std::filesystem::path path = namespace_path(ns); - std::ifstream stream(path, std::ios::binary); - if (!stream.is_open()) - { - return map; - } - - std::string line; - while (std::getline(stream, line)) - { - const std::size_t first = line.find('|'); - const std::size_t second = (first == std::string::npos) ? std::string::npos : line.find('|', first + 1U); - if (first == std::string::npos || second == std::string::npos || first == 0U || second <= first + 1U) - { - continue; - } - - StoredValue value{}; - value.kind = static_cast(line[first + 1U]); - value.payload = line.substr(second + 1U); - map[line.substr(0, first)] = std::move(value); - } - - return map; + return exec_sql(db, "PRAGMA busy_timeout=5000;") && + exec_sql(db, "PRAGMA journal_mode=WAL;") && + exec_sql(db, + "CREATE TABLE IF NOT EXISTS settings (" + "namespace TEXT NOT NULL," + "key TEXT NOT NULL," + "kind TEXT NOT NULL," + "payload BLOB NOT NULL," + "updated_at INTEGER NOT NULL DEFAULT " + "(CAST(strftime('%s','now') AS INTEGER))," + "PRIMARY KEY(namespace, key)" + ");"); } -bool save_namespace_map(const char* ns, const NamespaceMap& map) +struct DatabaseHandle { - const std::filesystem::path path = namespace_path(ns); - if (!ensure_parent_directory(path)) + sqlite3* db = nullptr; + + DatabaseHandle() + { + db = open_database(); + if (db != nullptr && !ensure_schema(db)) + { + sqlite3_close(db); + db = nullptr; + } + } + + ~DatabaseHandle() + { + if (db != nullptr) + { + sqlite3_close(db); + } + } + + DatabaseHandle(const DatabaseHandle&) = delete; + DatabaseHandle& operator=(const DatabaseHandle&) = delete; + + explicit operator bool() const noexcept + { + return db != nullptr; + } +}; + +bool bind_text(sqlite3_stmt* stmt, int index, const char* text) +{ + return sqlite3_bind_text(stmt, index, text ? text : "", -1, + SQLITE_TRANSIENT) == SQLITE_OK; +} + +bool upsert_value(const char* ns, + const char* key, + ValueKind kind, + const void* payload, + std::size_t payload_len) +{ + if (!ns || !key || key[0] == '\0' || + (payload_len > 0U && payload == nullptr)) { return false; } - if (map.empty()) + DatabaseHandle handle; + if (!handle) { - std::error_code ec; - std::filesystem::remove(path, ec); - return true; + return false; } - const std::filesystem::path temp_path = path.string() + ".tmp"; + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "INSERT INTO settings(namespace, key, kind, payload, updated_at) " + "VALUES(?1, ?2, ?3, ?4, CAST(strftime('%s','now') AS INTEGER)) " + "ON CONFLICT(namespace, key) DO UPDATE SET " + "kind=excluded.kind, " + "payload=excluded.payload, " + "updated_at=excluded.updated_at;"; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) != SQLITE_OK) { - std::ofstream stream(temp_path, std::ios::binary | std::ios::trunc); - if (!stream.is_open()) - { - return false; - } + return false; + } - std::vector keys; - keys.reserve(map.size()); - for (const auto& entry : map) - { - keys.push_back(entry.first); - } - std::sort(keys.begin(), keys.end()); + const char kind_text[2] = {static_cast(kind), '\0'}; + bool ok = bind_text(stmt, 1, ns) && bind_text(stmt, 2, key) && + bind_text(stmt, 3, kind_text) && + sqlite3_bind_blob(stmt, + 4, + payload, + static_cast(payload_len), + SQLITE_TRANSIENT) == SQLITE_OK; + ok = ok && sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + return ok; +} - for (const auto& key : keys) +bool remove_key(sqlite3* db, const char* ns, const char* key) +{ + if (db == nullptr || !ns || !key || key[0] == '\0') + { + return false; + } + + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "DELETE FROM settings WHERE namespace=?1 AND key=?2;"; + if (sqlite3_prepare_v2(db, kSql, -1, &stmt, nullptr) != SQLITE_OK) + { + return false; + } + + const bool ok = bind_text(stmt, 1, ns) && bind_text(stmt, 2, key) && + sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + return ok; +} + +bool load_value(const char* ns, + const char* key, + ValueKind expected_kind, + std::vector& out) +{ + out.clear(); + if (!ns || !key) + { + return false; + } + + DatabaseHandle handle; + if (!handle) + { + return false; + } + + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "SELECT kind, payload FROM settings WHERE namespace=?1 AND key=?2;"; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) != SQLITE_OK) + { + return false; + } + + bool ok = bind_text(stmt, 1, ns) && bind_text(stmt, 2, key); + if (ok && sqlite3_step(stmt) == SQLITE_ROW) + { + const unsigned char* kind_text = sqlite3_column_text(stmt, 0); + if (kind_text != nullptr && + kind_text[0] == static_cast(expected_kind)) { - const auto found = map.find(key); - if (found == map.end()) + const void* blob = sqlite3_column_blob(stmt, 1); + const int len = sqlite3_column_bytes(stmt, 1); + if (len > 0 && blob != nullptr) { - continue; + const auto* bytes = static_cast(blob); + out.assign(bytes, bytes + len); } - stream << key << "|" << static_cast(found->second.kind) << "|" << found->second.payload << "\n"; + else + { + out.clear(); + } + ok = true; + } + else + { + ok = false; } } - - std::error_code ec; - std::filesystem::remove(path, ec); - ec.clear(); - std::filesystem::rename(temp_path, path, ec); - if (ec) + else { - std::filesystem::remove(temp_path, ec); - return false; + ok = false; } - return true; + sqlite3_finalize(stmt); + return ok; } -bool parse_int_value(const std::string& payload, int* out) +std::string bytes_to_string(const std::vector& bytes) +{ + return std::string(bytes.begin(), bytes.end()); +} + +bool parse_int_value(const std::vector& payload, int* out) { if (!out) { @@ -203,7 +266,7 @@ bool parse_int_value(const std::string& payload, int* out) } try { - *out = std::stoi(payload); + *out = std::stoi(bytes_to_string(payload)); return true; } catch (...) @@ -212,7 +275,7 @@ bool parse_int_value(const std::string& payload, int* out) } } -bool parse_uint_value(const std::string& payload, uint32_t* out) +bool parse_uint_value(const std::vector& payload, uint32_t* out) { if (!out) { @@ -220,7 +283,7 @@ bool parse_uint_value(const std::string& payload, uint32_t* out) } try { - *out = static_cast(std::stoul(payload)); + *out = static_cast(std::stoul(bytes_to_string(payload))); return true; } catch (...) @@ -229,34 +292,30 @@ bool parse_uint_value(const std::string& payload, uint32_t* out) } } -void upsert_numeric_value(const char* ns, const char* key, ValueKind kind, const std::string& payload) +void put_numeric_value(const char* ns, + const char* key, + ValueKind kind, + const std::string& payload) { - if (!ns || !key || key[0] == '\0') - { - return; - } - std::lock_guard lock(s_store_mutex); - NamespaceMap map = load_namespace_map(ns); - map[std::string(key)] = StoredValue{kind, payload}; - (void)save_namespace_map(ns, map); + (void)upsert_value(ns, key, kind, payload.data(), payload.size()); } } // namespace void put_int(const char* ns, const char* key, int value) { - upsert_numeric_value(ns, key, ValueKind::Int, std::to_string(value)); + put_numeric_value(ns, key, ValueKind::Int, std::to_string(value)); } void put_bool(const char* ns, const char* key, bool value) { - upsert_numeric_value(ns, key, ValueKind::Bool, value ? "1" : "0"); + put_numeric_value(ns, key, ValueKind::Bool, value ? "1" : "0"); } void put_uint(const char* ns, const char* key, uint32_t value) { - upsert_numeric_value(ns, key, ValueKind::Uint, std::to_string(value)); + put_numeric_value(ns, key, ValueKind::Uint, std::to_string(value)); } bool put_string(const char* ns, const char* key, const char* value) @@ -267,140 +326,91 @@ bool put_string(const char* ns, const char* key, const char* value) } std::lock_guard lock(s_store_mutex); - NamespaceMap map = load_namespace_map(ns); if (value[0] == '\0') { - map.erase(std::string(key)); + DatabaseHandle handle; + return handle ? remove_key(handle.db, ns, key) : false; } - else - { - const auto encoded = hex_encode(reinterpret_cast(value), std::strlen(value)); - map[std::string(key)] = StoredValue{ValueKind::String, encoded}; - } - return save_namespace_map(ns, map); + + return upsert_value(ns, + key, + ValueKind::String, + value, + std::strlen(value)); } bool put_blob(const char* ns, const char* key, const void* data, std::size_t len) { - if (!ns || !key) - { - return false; - } - if (len > 0U && data == nullptr) + if (!ns || !key || (len > 0U && data == nullptr)) { return false; } std::lock_guard lock(s_store_mutex); - NamespaceMap map = load_namespace_map(ns); if (len == 0U) { - map.erase(std::string(key)); + DatabaseHandle handle; + return handle ? remove_key(handle.db, ns, key) : false; } - else - { - const auto* bytes = static_cast(data); - map[std::string(key)] = StoredValue{ValueKind::Blob, hex_encode(bytes, len)}; - } - return save_namespace_map(ns, map); + + return upsert_value(ns, key, ValueKind::Blob, data, len); } int get_int(const char* ns, const char* key, int default_value) { - if (!ns || !key) - { - return default_value; - } - std::lock_guard lock(s_store_mutex); - const NamespaceMap map = load_namespace_map(ns); - const auto found = map.find(key); - if (found == map.end() || found->second.kind != ValueKind::Int) + std::vector payload; + if (!load_value(ns, key, ValueKind::Int, payload)) { return default_value; } int parsed = default_value; - return parse_int_value(found->second.payload, &parsed) ? parsed : default_value; + return parse_int_value(payload, &parsed) ? parsed : default_value; } bool get_bool(const char* ns, const char* key, bool default_value) { - if (!ns || !key) + std::lock_guard lock(s_store_mutex); + std::vector payload; + if (!load_value(ns, key, ValueKind::Bool, payload)) { return default_value; } - std::lock_guard lock(s_store_mutex); - const NamespaceMap map = load_namespace_map(ns); - const auto found = map.find(key); - if (found == map.end() || found->second.kind != ValueKind::Bool) - { - return default_value; - } - return found->second.payload == "1"; + return bytes_to_string(payload) == "1"; } uint32_t get_uint(const char* ns, const char* key, uint32_t default_value) { - if (!ns || !key) - { - return default_value; - } - std::lock_guard lock(s_store_mutex); - const NamespaceMap map = load_namespace_map(ns); - const auto found = map.find(key); - if (found == map.end() || found->second.kind != ValueKind::Uint) + std::vector payload; + if (!load_value(ns, key, ValueKind::Uint, payload)) { return default_value; } uint32_t parsed = default_value; - return parse_uint_value(found->second.payload, &parsed) ? parsed : default_value; + return parse_uint_value(payload, &parsed) ? parsed : default_value; } bool get_string(const char* ns, const char* key, std::string& out) { - if (!ns || !key) - { - return false; - } - std::lock_guard lock(s_store_mutex); - const NamespaceMap map = load_namespace_map(ns); - const auto found = map.find(key); - if (found == map.end() || found->second.kind != ValueKind::String) + std::vector payload; + if (!load_value(ns, key, ValueKind::String, payload)) { return false; } - std::vector decoded; - if (!hex_decode(found->second.payload, &decoded)) - { - return false; - } - - out.assign(decoded.begin(), decoded.end()); + out.assign(payload.begin(), payload.end()); return true; } bool get_blob(const char* ns, const char* key, std::vector& out) { - if (!ns || !key) - { - return false; - } - std::lock_guard lock(s_store_mutex); - const NamespaceMap map = load_namespace_map(ns); - const auto found = map.find(key); - if (found == map.end() || found->second.kind != ValueKind::Blob) - { - return false; - } - - return hex_decode(found->second.payload, &out); + return load_value(ns, key, ValueKind::Blob, out); } void remove_keys(const char* ns, const char* const* keys, std::size_t key_count) @@ -411,15 +421,21 @@ void remove_keys(const char* ns, const char* const* keys, std::size_t key_count) } std::lock_guard lock(s_store_mutex); - NamespaceMap map = load_namespace_map(ns); + DatabaseHandle handle; + if (!handle) + { + return; + } + + (void)exec_sql(handle.db, "BEGIN IMMEDIATE;"); for (std::size_t index = 0; index < key_count; ++index) { if (keys[index] && keys[index][0] != '\0') { - map.erase(std::string(keys[index])); + (void)remove_key(handle.db, ns, keys[index]); } } - (void)save_namespace_map(ns, map); + (void)exec_sql(handle.db, "COMMIT;"); } void clear_namespace(const char* ns) @@ -430,8 +446,20 @@ void clear_namespace(const char* ns) } std::lock_guard lock(s_store_mutex); - std::error_code ec; - std::filesystem::remove(namespace_path(ns), ec); + DatabaseHandle handle; + if (handle) + { + sqlite3_stmt* stmt = nullptr; + constexpr const char* kSql = + "DELETE FROM settings WHERE namespace=?1;"; + if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == + SQLITE_OK) + { + (void)(bind_text(stmt, 1, ns) && + sqlite3_step(stmt) == SQLITE_DONE); + } + sqlite3_finalize(stmt); + } } } // namespace platform::ui::settings_store diff --git a/platform/linux/common/src/ui/mt_protocol_air_compat.cpp b/platform/linux/common/src/ui/mt_protocol_air_compat.cpp index da973a05..04be584d 100644 --- a/platform/linux/common/src/ui/mt_protocol_air_compat.cpp +++ b/platform/linux/common/src/ui/mt_protocol_air_compat.cpp @@ -1,65 +1,5 @@ -#include "meshtastic/config.pb.h" +#include "chat/infra/meshtastic/mt_region.h" -#include - -namespace chat -{ -namespace meshtastic -{ - -void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset preset, bool wide_lora, - float& bw_khz, uint8_t& sf, uint8_t& cr_denom) -{ - switch (preset) - { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: - bw_khz = wide_lora ? 1625.0f : 500.0f; - cr_denom = 5; - sf = 7; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 7; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 8; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 9; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 10; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: - bw_khz = wide_lora ? 1625.0f : 500.0f; - cr_denom = 8; - sf = 11; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - bw_khz = wide_lora ? 406.25f : 125.0f; - cr_denom = 8; - sf = 11; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - bw_khz = wide_lora ? 406.25f : 125.0f; - cr_denom = 8; - sf = 12; - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - default: - bw_khz = wide_lora ? 812.5f : 250.0f; - cr_denom = 5; - sf = 11; - break; - } -} - -} // namespace meshtastic -} // namespace chat +// Compatibility translation unit kept for existing Linux UI shell source lists. +// The shared Meshtastic preset-to-air-parameter implementation lives in +// modules/core_chat/src/infra/meshtastic/mt_region.cpp. diff --git a/platform/linux/uconsole/include/uconsole/uconsole_chat_workspace_model.h b/platform/linux/uconsole/include/uconsole/uconsole_chat_workspace_model.h new file mode 100644 index 00000000..50fdf850 --- /dev/null +++ b/platform/linux/uconsole/include/uconsole/uconsole_chat_workspace_model.h @@ -0,0 +1,169 @@ +#pragma once + +#include +#include +#include + +#include "chat/domain/chat_types.h" + +namespace trailmate::linux_app +{ +class LinuxAppServices; +} + +namespace trailmate::uconsole +{ + +enum class ChatThreadSortMode +{ + Recent, + Hops, + Distance, + LastSeen, +}; + +struct ChatConversationItem +{ + ::chat::ConversationId id{}; + std::string group{}; + std::string title{}; + std::string preview{}; + std::string meta{}; + std::string facts{}; + std::string unread_source{}; + std::uint32_t last_seen = 0; + std::uint8_t hops_away = 0xFF; + double distance_m = 0.0; + int unread = 0; + bool has_distance = false; + bool direct = false; + bool contact = false; + bool broadcast = false; + bool team = false; + bool active = false; +}; + +struct ChatMessageItem +{ + std::string sender{}; + std::string text{}; + std::string meta{}; + bool outgoing = false; + bool failed = false; +}; + +struct ChatNodeInfoItem +{ + ::chat::NodeId node_id = 0; + std::string title{}; + std::string subtitle{}; + std::string signal{}; + std::string position{}; + std::string status{}; + bool via_mqtt = false; + bool has_position = false; + bool is_contact = false; + bool is_ignored = false; + bool has_public_key = false; + bool key_verified = false; +}; + +struct ChatNodeDetailRow +{ + std::string label{}; + std::string value{}; + bool attention = false; +}; + +struct ChatNodeDetailSection +{ + std::string title{}; + std::vector rows{}; +}; + +struct ChatNodeDetailSnapshot +{ + bool found = false; + ::chat::NodeId node_id = 0; + std::string title{}; + std::string subtitle{}; + bool has_position = false; + double lat = 0.0; + double lon = 0.0; + bool has_self_position = false; + double self_lat = 0.0; + double self_lon = 0.0; + double distance_m = 0.0; + double bearing_deg = 0.0; + std::vector sections{}; +}; + +struct ChatWorkspaceSnapshot +{ + std::vector conversations{}; + std::vector messages{}; + std::vector nodes{}; + ::chat::ConversationId active_conversation{}; + std::string active_title{}; + std::string active_meta{}; + std::string action_status{}; + std::size_t total_conversations = 0; + int total_unread = 0; + bool can_send = true; + bool can_contact_active_peer = false; + bool can_request_nodeinfo = false; + bool can_send_position = false; + bool can_send_poi = false; +}; + +class UConsoleChatWorkspaceModel final +{ + public: + explicit UConsoleChatWorkspaceModel(linux_app::LinuxAppServices& services); + + [[nodiscard]] ChatWorkspaceSnapshot snapshot(std::size_t conversation_limit, + std::size_t message_limit, + ChatThreadSortMode sort_mode = + ChatThreadSortMode::Recent); + + bool selectConversationAt(std::size_t index, + std::size_t conversation_limit, + ChatThreadSortMode sort_mode = + ChatThreadSortMode::Recent); + bool selectConversation(const ::chat::ConversationId& conversation); + bool selectPrimaryConversation(); + bool sendText(const std::string& text); + bool sendCurrentPosition(); + bool sendCurrentPoi(); + bool requestActiveNodeInfo(); + bool addActivePeerAsContact(); + bool selectNodeConversation(::chat::NodeId node_id); + bool addNodeAsContact(::chat::NodeId node_id); + bool requestNodeInfo(::chat::NodeId node_id); + bool exchangeUserInfo(::chat::NodeId node_id); + bool toggleNodeIgnored(::chat::NodeId node_id); + bool verifyNodeKey(::chat::NodeId node_id); + [[nodiscard]] ChatNodeDetailSnapshot nodeDetails(::chat::NodeId node_id) const; + + [[nodiscard]] const ::chat::ConversationId& activeConversation() const + { + return active_conversation_; + } + + private: + void ensureActiveConversation(); + [[nodiscard]] ::chat::ConversationId primaryConversation() const; + [[nodiscard]] bool canSendActiveConversation() const; + [[nodiscard]] std::vector<::chat::ConversationMeta> loadConversationPage( + std::size_t limit, + std::size_t* total, + ChatThreadSortMode sort_mode) const; + + linux_app::LinuxAppServices& services_; + ::chat::ConversationId active_conversation_{}; + std::vector<::chat::ConversationId> displayed_conversations_{}; + std::string action_status_{}; + bool active_initialized_ = false; +}; + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/include/uconsole/uconsole_dashboard_model.h b/platform/linux/uconsole/include/uconsole/uconsole_dashboard_model.h new file mode 100644 index 00000000..e54673c2 --- /dev/null +++ b/platform/linux/uconsole/include/uconsole/uconsole_dashboard_model.h @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include +#include + +#include "chat/domain/chat_types.h" + +namespace trailmate::linux_app +{ +class LinuxAppServices; +} + +namespace trailmate::uconsole +{ + +struct ConversationPreview +{ + std::string title{}; + std::string preview{}; + std::string meta{}; + int unread = 0; +}; + +struct ContactPreview +{ + std::string name{}; + std::string node_id{}; + std::string status{}; + std::string protocol{}; +}; + +struct HardwareStatusItem +{ + std::string name{}; + std::string state{}; + std::string detail{}; + bool attention = false; +}; + +struct LocationOverview +{ + std::string state{}; + std::string coordinates{}; + std::string detail{}; + std::string map_meta{}; + bool attention = false; +}; + +struct MessageOverview +{ + std::string title{}; + std::string detail{}; + std::string latest{}; + bool attention = false; +}; + +struct TeamTimelineItem +{ + std::string title{}; + std::string detail{}; + bool attention = false; +}; + +struct OverviewTimelineItem +{ + std::uint64_t timestamp_ms = 0; + std::string time_label{}; + std::string title{}; + std::string detail{}; + std::string badge{}; + std::string kind{}; // message, node, position, telemetry, team, system + bool team = false; + bool direct = false; + bool outgoing = false; + bool attention = false; +}; + +struct RecentContactPreview +{ + ::chat::ConversationId conversation{}; + std::string name{}; + std::string meta{}; + std::string detail{}; + std::string badge{}; + bool direct = false; + bool team = false; + bool has_unread = false; +}; + +struct UConsoleDashboardSnapshot +{ + std::size_t conversation_count = 0; + int unread_count = 0; + std::size_t contact_count = 0; + std::size_t nearby_count = 0; + std::size_t ignored_count = 0; + std::string mesh_protocol{}; + std::string self_node{}; + std::string bottom_status{}; + std::string team_summary{}; + LocationOverview location{}; + MessageOverview messages{}; + std::vector conversations{}; + std::vector contacts{}; + std::vector recent_contacts{}; + std::vector hardware{}; + std::vector team_timeline{}; + std::vector timeline{}; + std::vector capability_lines{}; +}; + +class UConsoleDashboardModel final +{ + public: + explicit UConsoleDashboardModel(linux_app::LinuxAppServices& services); + + [[nodiscard]] UConsoleDashboardSnapshot snapshot() const; + + private: + linux_app::LinuxAppServices& services_; +}; + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/include/uconsole/uconsole_desktop_shell.h b/platform/linux/uconsole/include/uconsole/uconsole_desktop_shell.h new file mode 100644 index 00000000..204c205a --- /dev/null +++ b/platform/linux/uconsole/include/uconsole/uconsole_desktop_shell.h @@ -0,0 +1,18 @@ +#pragma once + +#include "platform/surface_presenter.h" + +namespace trailmate::uconsole +{ + +struct UConsoleShellOptions +{ + int width = 1280; + int height = 720; + int frame_time_ms = 16; +}; + +void runUConsoleShell(::trailmate::cardputer_zero::platform::SurfacePresenter& presenter, + UConsoleShellOptions options = {}); + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/include/uconsole/uconsole_hardware_probe.h b/platform/linux/uconsole/include/uconsole/uconsole_hardware_probe.h new file mode 100644 index 00000000..4439e131 --- /dev/null +++ b/platform/linux/uconsole/include/uconsole/uconsole_hardware_probe.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace trailmate::uconsole +{ + +struct UConsoleHardwareProbe +{ + bool aio2_detected = false; + bool gps_serial_detected = false; + bool lora_spi_detected = false; + bool i2c_detected = false; + + std::string aio2_serial_path{}; + std::string gps_serial_path{}; + std::string lora_spi_path{}; + std::string i2c_summary{}; + std::string summary{}; +}; + +[[nodiscard]] UConsoleHardwareProbe probeUConsoleHardware(); +[[nodiscard]] bool uconsoleAutoGpsSerialPath(std::string& out_path); + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/include/uconsole/uconsole_map_workspace_model.h b/platform/linux/uconsole/include/uconsole/uconsole_map_workspace_model.h new file mode 100644 index 00000000..2345c980 --- /dev/null +++ b/platform/linux/uconsole/include/uconsole/uconsole_map_workspace_model.h @@ -0,0 +1,163 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "platform/linux/map_contour_tile_generator.h" +#include "platform/linux/map_tile_cache.h" + +namespace trailmate::linux_app +{ +class LinuxAppServices; +} + +namespace trailmate::uconsole +{ + +struct MapTileItem +{ + ::platform::linux_runtime::MapTileId id{}; + std::filesystem::path path{}; + bool available = false; +}; + +struct MapContourTileItem +{ + ::platform::linux_runtime::MapContourTileId id{}; + std::filesystem::path path{}; + std::size_t base_tile_index = 0; + bool available = false; +}; + +struct MapNodeOverlayItem +{ + std::uint32_t node_id = 0; + std::string label{}; + double lat = 0.0; + double lon = 0.0; + double x_fraction = 0.0; + double y_fraction = 0.0; + bool via_mqtt = false; + bool is_contact = false; + std::uint32_t last_seen = 0; + float rssi = 0.0F; + float snr = 0.0F; + std::uint8_t hops_away = 0xFF; + std::uint8_t channel = 0xFF; + bool has_altitude = false; + std::int32_t altitude_m = 0; +}; + +struct MapCoordinate +{ + bool valid = false; + double lat = 0.0; + double lon = 0.0; +}; + +struct MapWorkspaceSnapshot +{ + bool has_center = false; + bool has_fix = false; + bool has_configured_center = false; + bool has_manual_center = false; + bool using_default_center = false; + double lat = 0.0; + double lon = 0.0; + double altitude_m = 0.0; + bool has_altitude = false; + double speed_mps = 0.0; + bool has_speed = false; + std::uint8_t satellites = 0; + int zoom = 14; + std::string source_label{}; + std::string fix_label{}; + std::size_t columns = 0; + std::size_t rows = 0; + std::size_t center_tile_index = 0; + std::vector tiles{}; + bool contour_enabled = false; + bool contour_ultra_fine_enabled = false; + bool earthdata_token_configured = false; + std::size_t contour_available_count = 0; + std::size_t contour_missing_count = 0; + std::vector contour_profiles{}; + std::vector contour_tiles{}; + bool show_mqtt_nodes = true; + std::size_t visible_node_count = 0; + std::size_t visible_mqtt_node_count = 0; + std::size_t hidden_mqtt_node_count = 0; + std::vector nodes{}; + ::platform::linux_runtime::MapTileCacheStats cache_stats{}; +}; + +class UConsoleMapWorkspaceModel final +{ + public: + explicit UConsoleMapWorkspaceModel(linux_app::LinuxAppServices& services); + + [[nodiscard]] MapWorkspaceSnapshot snapshot() const; + [[nodiscard]] MapWorkspaceSnapshot snapshotAround(double lat, + double lon, + int zoom, + int radius_x, + int radius_y) const; + [[nodiscard]] ::platform::linux_runtime::MapTileResult ensureTile( + const ::platform::linux_runtime::MapTileId& tile) const; + [[nodiscard]] ::platform::linux_runtime::MapContourGenerationResult + ensureContourTiles( + const std::vector<::platform::linux_runtime::MapContourTileId>& tiles) + const; + + void setSource(::platform::linux_runtime::MapBaseSource source); + void setZoom(int zoom); + void setShowMqttNodes(bool enabled); + void setContourEnabled(bool enabled); + void setContourUltraFineEnabled(bool enabled); + void setEarthdataToken(const std::string& token); + [[nodiscard]] bool contourUltraFineEnabled() const; + [[nodiscard]] std::string earthdataToken() const; + [[nodiscard]] bool earthdataTokenConfigured() const; + [[nodiscard]] MapCoordinate coordinateAtDisplayPoint( + const MapWorkspaceSnapshot& snapshot, + double display_x, + double display_y, + int display_width, + int display_height) const; + void centerOn(double lat, double lon, bool persist); + void zoomInAt(double lat, double lon); + void zoomOutAt(double lat, double lon); + void panByDisplayDelta(double drag_dx, + double drag_dy, + int display_width, + int display_height, + double start_lat, + double start_lon, + int start_zoom, + bool persist); + void clearManualCenter(); + void zoomIn(); + void zoomOut(); + + private: + [[nodiscard]] ::platform::linux_runtime::MapBaseSource source() const; + [[nodiscard]] bool showMqttNodes() const; + [[nodiscard]] bool contourEnabled() const; + void persistZoom() const; + void persistManualCenter() const; + void clearPersistedManualCenter() const; + + linux_app::LinuxAppServices& services_; + ::platform::linux_runtime::MapTileCache tile_cache_{}; + ::platform::linux_runtime::MapContourTileStore contour_store_{}; + ::platform::linux_runtime::MapContourTileGenerator contour_generator_{}; + int zoom_ = 14; + bool manual_center_active_ = false; + double manual_center_lat_ = 0.0; + double manual_center_lon_ = 0.0; +}; + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp new file mode 100644 index 00000000..9567510c --- /dev/null +++ b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp @@ -0,0 +1,1648 @@ +#include "uconsole/uconsole_chat_workspace_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/linux_app_services.h" +#include "chat/domain/contact_types.h" +#include "chat/ports/i_mesh_adapter.h" +#include "chat/usecase/chat_service.h" +#include "chat/usecase/contact_service.h" +#include "meshtastic/mesh.pb.h" +#include "pb_encode.h" +#include "platform/ui/gps_runtime.h" +#include "sys/clock.h" + +namespace trailmate::uconsole +{ +namespace +{ + +[[nodiscard]] const char* protocolLabel(::chat::MeshProtocol protocol) noexcept +{ + switch (protocol) + { + case ::chat::MeshProtocol::Meshtastic: + return "Meshtastic"; + case ::chat::MeshProtocol::MeshCore: + return "MeshCore"; + case ::chat::MeshProtocol::RNode: + return "RNode"; + case ::chat::MeshProtocol::LXMF: + return "LXMF"; + } + return "Unknown"; +} + +[[nodiscard]] const char* statusLabel(::chat::MessageStatus status) noexcept +{ + switch (status) + { + case ::chat::MessageStatus::Incoming: + return "received"; + case ::chat::MessageStatus::Queued: + return "queued"; + case ::chat::MessageStatus::Sent: + return "sent"; + case ::chat::MessageStatus::Failed: + return "failed"; + } + return "unknown"; +} + +[[nodiscard]] std::string formatNodeId(std::uint32_t node_id) +{ + char buffer[16] = {}; + std::snprintf(buffer, sizeof(buffer), "%08lX", + static_cast(node_id)); + return buffer; +} + +[[nodiscard]] std::string formatNodeLabel(std::uint32_t node_id) +{ + return "0x" + formatNodeId(node_id); +} + +[[nodiscard]] std::string contactDisplayName( + const ::chat::contacts::ContactService& contacts, + ::chat::NodeId node_id) +{ + if (node_id == 0) + { + return {}; + } + if (const auto* node = contacts.getNodeInfo(node_id)) + { + if (!node->display_name.empty()) + { + return node->display_name; + } + if (node->short_name[0] != '\0') + { + return std::string(node->short_name); + } + if (node->long_name[0] != '\0') + { + return std::string(node->long_name); + } + } + const std::string stored = contacts.getContactName(node_id); + if (!stored.empty()) + { + return stored; + } + return {}; +} + +[[nodiscard]] std::string displayNameOrId( + const ::chat::contacts::ContactService& contacts, + ::chat::NodeId node_id) +{ + const std::string name = contactDisplayName(contacts, node_id); + return name.empty() ? formatNodeLabel(node_id) : name; +} + +[[nodiscard]] std::string formatChannel(::chat::ChannelId channel) +{ + switch (channel) + { + case ::chat::ChannelId::PRIMARY: + return "Primary"; + case ::chat::ChannelId::SECONDARY: + return "Secondary"; + case ::chat::ChannelId::MAX_CHANNELS: + return "Channel"; + } + return "Channel"; +} + +[[nodiscard]] std::string formatAge(std::uint32_t timestamp) +{ + if (timestamp == 0) return "no activity"; + + const std::uint32_t now = sys::epoch_seconds_now(); + if (timestamp >= now) return "now"; + + const std::uint32_t age = now - timestamp; + if (age < 60U) return "now"; + if (age < 3600U) + { + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%lum ago", + static_cast(age / 60U)); + return buffer; + } + if (age < 86400U) + { + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%luh ago", + static_cast(age / 3600U)); + return buffer; + } + + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%lud ago", + static_cast(age / 86400U)); + return buffer; +} + +[[nodiscard]] double degToRad(double degrees) +{ + return degrees * 3.14159265358979323846 / 180.0; +} + +[[nodiscard]] double distanceMeters(double lat_a, + double lon_a, + double lat_b, + double lon_b) +{ + constexpr double kEarthRadiusM = 6371000.0; + const double d_lat = degToRad(lat_b - lat_a); + const double d_lon = degToRad(lon_b - lon_a); + const double a = + std::sin(d_lat / 2.0) * std::sin(d_lat / 2.0) + + std::cos(degToRad(lat_a)) * std::cos(degToRad(lat_b)) * + std::sin(d_lon / 2.0) * std::sin(d_lon / 2.0); + const double c = 2.0 * std::atan2(std::sqrt(a), std::sqrt(1.0 - a)); + return kEarthRadiusM * c; +} + +[[nodiscard]] double bearingDegrees(double from_lat, + double from_lon, + double to_lat, + double to_lon) +{ + const double lat1 = degToRad(from_lat); + const double lat2 = degToRad(to_lat); + const double d_lon = degToRad(to_lon - from_lon); + const double y = std::sin(d_lon) * std::cos(lat2); + const double x = std::cos(lat1) * std::sin(lat2) - + std::sin(lat1) * std::cos(lat2) * std::cos(d_lon); + double bearing = std::atan2(y, x) * 180.0 / 3.14159265358979323846; + if (bearing < 0.0) + { + bearing += 360.0; + } + return bearing; +} + +[[nodiscard]] std::string formatDistance(double meters) +{ + if (!std::isfinite(meters) || meters < 0.0) + { + return "distance ?"; + } + if (meters < 1000.0) + { + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%.0f m", meters); + return buffer; + } + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%.1f km", meters / 1000.0); + return buffer; +} + +[[nodiscard]] const char* contactProtocolLabel( + ::chat::contacts::NodeProtocolType protocol) noexcept +{ + switch (protocol) + { + case ::chat::contacts::NodeProtocolType::Meshtastic: + return "Meshtastic"; + case ::chat::contacts::NodeProtocolType::MeshCore: + return "MeshCore"; + case ::chat::contacts::NodeProtocolType::RNode: + return "RNode"; + case ::chat::contacts::NodeProtocolType::LXMF: + return "LXMF"; + case ::chat::contacts::NodeProtocolType::Unknown: + default: + return "Unknown"; + } +} + +[[nodiscard]] ::chat::MeshProtocol meshProtocolForNode( + ::chat::contacts::NodeProtocolType protocol, + ::chat::MeshProtocol fallback) noexcept +{ + switch (protocol) + { + case ::chat::contacts::NodeProtocolType::Meshtastic: + return ::chat::MeshProtocol::Meshtastic; + case ::chat::contacts::NodeProtocolType::MeshCore: + return ::chat::MeshProtocol::MeshCore; + case ::chat::contacts::NodeProtocolType::RNode: + return ::chat::MeshProtocol::RNode; + case ::chat::contacts::NodeProtocolType::LXMF: + return ::chat::MeshProtocol::LXMF; + case ::chat::contacts::NodeProtocolType::Unknown: + default: + return fallback; + } +} + +[[nodiscard]] ::chat::ChannelId channelForNode( + const ::chat::contacts::NodeInfo& node) noexcept +{ + return node.channel == 1U ? ::chat::ChannelId::SECONDARY + : ::chat::ChannelId::PRIMARY; +} + +[[nodiscard]] std::string formatCoordinate(std::int32_t value_e7) +{ + char buffer[24] = {}; + std::snprintf(buffer, + sizeof(buffer), + "%.5f", + static_cast(value_e7) / 10000000.0); + return buffer; +} + +[[nodiscard]] const char* roleLabel(::chat::contacts::NodeRoleType role) noexcept +{ + using Role = ::chat::contacts::NodeRoleType; + switch (role) + { + case Role::Client: + return "Client"; + case Role::ClientMute: + return "Client mute"; + case Role::Router: + return "Router"; + case Role::RouterClient: + return "Router client"; + case Role::Repeater: + return "Repeater"; + case Role::Tracker: + return "Tracker"; + case Role::Sensor: + return "Sensor"; + case Role::Tak: + return "TAK"; + case Role::ClientHidden: + return "Client hidden"; + case Role::LostAndFound: + return "Lost and found"; + case Role::TakTracker: + return "TAK tracker"; + case Role::RouterLate: + return "Router late"; + case Role::ClientBase: + return "Client base"; + case Role::Unknown: + default: + return "Unknown"; + } +} + +[[nodiscard]] std::string formatNodeInfoId(std::uint32_t node_id) +{ + char buffer[16] = {}; + if (node_id <= 0xFFFFFFUL) + { + std::snprintf(buffer, + sizeof(buffer), + "!%06lX", + static_cast(node_id)); + } + else + { + std::snprintf(buffer, + sizeof(buffer), + "!%08lX", + static_cast(node_id)); + } + return buffer; +} + +[[nodiscard]] std::string formatMacAddress(const std::uint8_t mac[6]) +{ + char buffer[24] = {}; + std::snprintf(buffer, + sizeof(buffer), + "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], + mac[1], + mac[2], + mac[3], + mac[4], + mac[5]); + return buffer; +} + +[[nodiscard]] std::string formatFloatValue(double value, + const char* suffix, + int precision) +{ + if (!std::isfinite(value)) + { + return "?"; + } + char format[16] = {}; + std::snprintf(format, sizeof(format), "%%.%df%%s", precision); + char buffer[40] = {}; + std::snprintf(buffer, sizeof(buffer), format, value, suffix ? suffix : ""); + return buffer; +} + +[[nodiscard]] std::string formatDop(std::uint32_t value) +{ + if (value == 0) + { + return "?"; + } + char buffer[24] = {}; + std::snprintf(buffer, + sizeof(buffer), + "%.2f", + static_cast(value) / 100.0); + return buffer; +} + +void appendDetailRow(ChatNodeDetailSection& section, + std::string label, + std::string value, + bool attention = false) +{ + if (value.empty()) + { + return; + } + section.rows.push_back( + ChatNodeDetailRow{std::move(label), std::move(value), attention}); +} + +void appendDetailSection(ChatNodeDetailSnapshot& out, + ChatNodeDetailSection section) +{ + if (!section.rows.empty()) + { + out.sections.push_back(std::move(section)); + } +} + +[[nodiscard]] ChatNodeInfoItem makeNodeInfoItem( + const ::chat::contacts::NodeInfo& node) +{ + ChatNodeInfoItem item{}; + item.node_id = node.node_id; + item.title = node.display_name.empty() ? formatNodeLabel(node.node_id) + : node.display_name; + item.subtitle = node.long_name[0] != '\0' ? std::string(node.long_name) + : formatNodeLabel(node.node_id); + item.via_mqtt = node.via_mqtt; + item.has_position = node.position.valid; + item.is_contact = node.is_contact; + item.is_ignored = node.is_ignored; + item.has_public_key = node.has_public_key; + item.key_verified = node.key_manually_verified; + + item.status = std::string(contactProtocolLabel(node.protocol)) + " / " + + (node.via_mqtt ? "MQTT" : "LoRa") + " / " + + formatAge(node.last_seen); + if (node.is_ignored) + { + item.status += " / ignored"; + } + if (node.has_public_key) + { + item.status += node.key_manually_verified ? " / key trusted" + : " / key unverified"; + } + + const std::string hops = node.hops_away == 0xFF + ? "?" + : std::to_string(node.hops_away); + const std::string channel = + node.channel == 0xFF ? "?" : std::to_string(node.channel); + char signal[112] = {}; + std::snprintf(signal, + sizeof(signal), + "RSSI %.1f dBm / SNR %.1f dB / hops %s / ch %s", + static_cast(node.rssi), + static_cast(node.snr), + hops.c_str(), + channel.c_str()); + item.signal = signal; + + if (node.position.valid) + { + item.position = formatCoordinate(node.position.latitude_i) + ", " + + formatCoordinate(node.position.longitude_i); + if (node.position.has_altitude) + { + item.position += " / "; + item.position += std::to_string(node.position.altitude); + item.position += " m"; + } + } + else + { + item.position = "No position packet stored."; + } + return item; +} + +[[nodiscard]] std::string trimCopy(const std::string& text) +{ + const auto is_space = [](unsigned char ch) + { + return std::isspace(ch) != 0; + }; + + auto begin = std::find_if_not(text.begin(), text.end(), is_space); + auto end = std::find_if_not(text.rbegin(), text.rend(), is_space).base(); + if (begin >= end) return {}; + return std::string(begin, end); +} + +[[nodiscard]] bool containsInsensitive(const std::string& text, + const char* needle) +{ + if (needle == nullptr || needle[0] == '\0') + { + return false; + } + std::string lower_text = text; + std::string lower_needle = needle; + std::transform(lower_text.begin(), + lower_text.end(), + lower_text.begin(), + [](unsigned char ch) + { + return static_cast(std::tolower(ch)); + }); + std::transform(lower_needle.begin(), + lower_needle.end(), + lower_needle.begin(), + [](unsigned char ch) + { + return static_cast(std::tolower(ch)); + }); + return lower_text.find(lower_needle) != std::string::npos; +} + +[[nodiscard]] bool sameConversation(const ::chat::ConversationId& left, + const ::chat::ConversationId& right) noexcept +{ + return left == right; +} + +[[nodiscard]] std::string titleForConversation( + const ::chat::ConversationId& id, + const std::string& stored_name, + const ::chat::contacts::ContactService& contacts) +{ + if (!stored_name.empty() && stored_name != "Broadcast") return stored_name; + if (id.peer == 0) + { + return formatChannel(id.channel) + " broadcast"; + } + return displayNameOrId(contacts, id.peer); +} + +[[nodiscard]] std::string metaForConversation( + const ::chat::ConversationMeta& conversation, + const ::chat::contacts::ContactService& contacts) +{ + std::string meta = protocolLabel(conversation.id.protocol); + meta += " / "; + meta += formatChannel(conversation.id.channel); + if (conversation.id.peer != 0) + { + meta += " / "; + meta += formatNodeLabel(conversation.id.peer); + if (const auto* node = contacts.getNodeInfo(conversation.id.peer)) + { + meta += node->via_mqtt ? " / MQTT" : " / LoRa"; + } + } + meta += " / "; + meta += formatAge(conversation.last_timestamp); + if (conversation.unread > 0) + { + char buffer[32] = {}; + std::snprintf(buffer, sizeof(buffer), " / %d unread", + conversation.unread); + meta += buffer; + } + return meta; +} + +[[nodiscard]] const ::chat::contacts::NodeInfo* nodeForConversation( + const ::chat::ConversationId& id, + const ::chat::contacts::ContactService& contacts) +{ + if (id.peer == 0) + { + return nullptr; + } + return contacts.getNodeInfo(id.peer); +} + +[[nodiscard]] std::string groupForConversation( + const ::chat::ConversationMeta& conversation, + const ::chat::contacts::NodeInfo* node) +{ + if (conversation.id.peer == 0) + { + return "Broadcast"; + } + if (containsInsensitive(conversation.name, "team") || + containsInsensitive(conversation.preview, "team")) + { + return "Team"; + } + if (node != nullptr && node->is_contact) + { + return "Contacts"; + } + return "Nearby"; +} + +[[nodiscard]] std::string factsForConversation( + const ::chat::ConversationMeta& conversation, + const ::chat::contacts::NodeInfo* node, + bool has_local_gps, + double local_lat, + double local_lon) +{ + if (conversation.id.peer == 0) + { + return "broadcast / channel-wide"; + } + if (node == nullptr) + { + return "node facts pending"; + } + + std::string facts = "hops "; + facts += node->hops_away == 0xFF ? "?" : std::to_string(node->hops_away); + facts += " / "; + facts += formatAge(node->last_seen); + facts += " / "; + facts += node->via_mqtt ? "MQTT" : "LoRa"; + if (has_local_gps && node->position.valid) + { + const double lat = + static_cast(node->position.latitude_i) / 10000000.0; + const double lon = + static_cast(node->position.longitude_i) / 10000000.0; + facts += " / "; + facts += formatDistance(distanceMeters(local_lat, local_lon, lat, lon)); + } + return facts; +} + +[[nodiscard]] ChatConversationItem makeConversationItem( + const ::chat::ConversationMeta& conversation, + const ::chat::ConversationId& active, + const ::chat::contacts::ContactService& contacts, + bool has_local_gps, + double local_lat, + double local_lon) +{ + ChatConversationItem item{}; + item.id = conversation.id; + const auto* node = nodeForConversation(conversation.id, contacts); + item.group = groupForConversation(conversation, node); + item.title = titleForConversation(conversation.id, conversation.name, + contacts); + if (!conversation.preview.empty()) + { + item.preview = conversation.preview; + } + else if (conversation.id.peer == 0) + { + item.preview = "No messages in this thread yet."; + } + item.meta = metaForConversation(conversation, contacts); + item.facts = factsForConversation(conversation, + node, + has_local_gps, + local_lat, + local_lon); + item.unread = conversation.unread; + item.broadcast = conversation.id.peer == 0; + item.direct = conversation.id.peer != 0; + item.contact = node != nullptr && node->is_contact; + item.team = item.group == "Team"; + if (node != nullptr) + { + item.last_seen = node->last_seen; + item.hops_away = node->hops_away; + if (has_local_gps && node->position.valid) + { + const double lat = + static_cast(node->position.latitude_i) / 10000000.0; + const double lon = + static_cast(node->position.longitude_i) / 10000000.0; + item.distance_m = distanceMeters(local_lat, local_lon, lat, lon); + item.has_distance = std::isfinite(item.distance_m); + } + } + item.active = sameConversation(conversation.id, active); + return item; +} + +void sortConversations(std::vector<::chat::ConversationMeta>& conversations, + ChatThreadSortMode sort_mode, + const ::chat::contacts::ContactService& contacts, + bool has_local_gps, + double local_lat, + double local_lon) +{ + auto distance_for = [&](const ::chat::ConversationMeta& conversation) + { + const auto* node = nodeForConversation(conversation.id, contacts); + if (!has_local_gps || node == nullptr || !node->position.valid) + { + return std::numeric_limits::infinity(); + } + const double lat = + static_cast(node->position.latitude_i) / 10000000.0; + const double lon = + static_cast(node->position.longitude_i) / 10000000.0; + return distanceMeters(local_lat, local_lon, lat, lon); + }; + auto hops_for = [&](const ::chat::ConversationMeta& conversation) + { + const auto* node = nodeForConversation(conversation.id, contacts); + if (node == nullptr || node->hops_away == 0xFF) + { + return 0xFF; + } + return static_cast(node->hops_away); + }; + auto last_seen_for = [&](const ::chat::ConversationMeta& conversation) + { + const auto* node = nodeForConversation(conversation.id, contacts); + return node == nullptr ? conversation.last_timestamp : node->last_seen; + }; + + std::stable_sort(conversations.begin(), + conversations.end(), + [&](const auto& left, const auto& right) + { + switch (sort_mode) + { + case ChatThreadSortMode::Hops: + if (hops_for(left) != hops_for(right)) + { + return hops_for(left) < hops_for(right); + } + break; + case ChatThreadSortMode::Distance: + if (distance_for(left) != distance_for(right)) + { + return distance_for(left) < distance_for(right); + } + break; + case ChatThreadSortMode::LastSeen: + if (last_seen_for(left) != last_seen_for(right)) + { + return last_seen_for(left) > last_seen_for(right); + } + break; + case ChatThreadSortMode::Recent: + default: + break; + } + return left.last_timestamp > right.last_timestamp; + }); +} + +[[nodiscard]] std::string unreadSenderSummary( + ::chat::ChatService& chat_service, + const ::chat::ConversationMeta& conversation, + const ::chat::contacts::ContactService& contacts) +{ + if (conversation.unread <= 0) + { + return {}; + } + + auto messages = chat_service.getRecentMessages(conversation.id, 64); + std::vector<::chat::NodeId> senders{}; + senders.reserve(static_cast(conversation.unread)); + int remaining = conversation.unread; + for (auto it = messages.rbegin(); it != messages.rend() && remaining > 0; + ++it) + { + if (it->status != ::chat::MessageStatus::Incoming) + { + continue; + } + --remaining; + if (it->from == 0) + { + continue; + } + if (std::find(senders.begin(), senders.end(), it->from) == + senders.end()) + { + senders.push_back(it->from); + } + } + + if (senders.empty()) + { + return "Unread"; + } + + std::string out = "Unread from " + + displayNameOrId(contacts, senders.front()); + if (senders.size() > 1U) + { + out += " +"; + out += std::to_string(senders.size() - 1U); + } + return out; +} + +[[nodiscard]] ChatMessageItem makeMessageItem( + const ::chat::ChatMessage& message, + ::chat::NodeId self_node, + const ::chat::contacts::ContactService& contacts) +{ + ChatMessageItem item{}; + item.outgoing = message.from == 0 || message.from == self_node; + item.failed = message.status == ::chat::MessageStatus::Failed; + item.sender = + item.outgoing ? "You" : displayNameOrId(contacts, message.from); + item.text = message.text.empty() ? "(empty)" : message.text; + item.meta = statusLabel(message.status); + item.meta += " / "; + item.meta += formatAge(message.timestamp); + if (message.msg_id != 0) + { + char buffer[32] = {}; + std::snprintf(buffer, sizeof(buffer), " / #%08lX", + static_cast(message.msg_id)); + item.meta += buffer; + } + if (!item.outgoing && message.from != 0) + { + item.meta += " / "; + item.meta += formatNodeLabel(message.from); + } + return item; +} + +[[nodiscard]] ::chat::ConversationMeta makeSyntheticPrimary( + const ::chat::ConversationId& id) +{ + ::chat::ConversationMeta conversation{}; + conversation.id = id; + conversation.name = "Broadcast"; + conversation.preview = "No messages in this thread yet."; + conversation.last_timestamp = 0; + conversation.unread = 0; + return conversation; +} + +[[nodiscard]] bool hasDirectConversationForNode( + const std::vector<::chat::ConversationMeta>& conversations, + ::chat::NodeId node_id) +{ + return std::any_of(conversations.begin(), + conversations.end(), + [node_id](const auto& conversation) + { + return conversation.id.peer == node_id; + }); +} + +void appendNodeConversationIfMissing( + std::vector<::chat::ConversationMeta>& conversations, + const ::chat::contacts::NodeInfo& node, + ::chat::MeshProtocol fallback_protocol) +{ + if (node.node_id == 0 || + hasDirectConversationForNode(conversations, node.node_id)) + { + return; + } + + ::chat::ConversationMeta conversation{}; + conversation.id = + ::chat::ConversationId(channelForNode(node), + node.node_id, + meshProtocolForNode(node.protocol, + fallback_protocol)); + conversation.name = node.display_name.empty() + ? formatNodeLabel(node.node_id) + : node.display_name; + conversation.preview.clear(); + conversation.last_timestamp = node.last_seen; + conversation.unread = 0; + conversations.push_back(std::move(conversation)); +} + +} // namespace + +UConsoleChatWorkspaceModel::UConsoleChatWorkspaceModel( + linux_app::LinuxAppServices& services) + : services_(services) +{ +} + +ChatWorkspaceSnapshot UConsoleChatWorkspaceModel::snapshot( + std::size_t conversation_limit, + std::size_t message_limit, + ChatThreadSortMode sort_mode) +{ + ensureActiveConversation(); + + ChatWorkspaceSnapshot out{}; + out.active_conversation = active_conversation_; + out.action_status = action_status_; + out.total_unread = services_.chat().getTotalUnread(); + out.can_send = canSendActiveConversation(); + out.can_contact_active_peer = active_conversation_.peer != 0; + out.can_request_nodeinfo = + services_.meshAdapter() != nullptr && active_conversation_.peer != 0; + const auto* adapter = services_.meshAdapter(); + const ::chat::MeshCapabilities capabilities = + adapter == nullptr ? ::chat::MeshCapabilities{} + : adapter->getCapabilities(); + const bool appdata_ready = adapter != nullptr && adapter->isReady() && + (active_conversation_.peer == 0 + ? capabilities.supports_broadcast_appdata + : capabilities.supports_unicast_appdata); + auto& contacts = services_.contacts(); + const auto gps = ::platform::ui::gps::get_data(); + const bool has_local_gps = gps.valid; + const double local_lat = gps.lat; + const double local_lon = gps.lng; + out.can_send_position = appdata_ready && has_local_gps; + out.can_send_poi = appdata_ready && has_local_gps && + active_conversation_.protocol == + ::chat::MeshProtocol::Meshtastic; + + std::size_t total = 0; + auto conversations = + loadConversationPage(conversation_limit, &total, sort_mode); + out.total_conversations = total; + + if (!conversations.empty()) + { + const bool active_visible = + std::any_of(conversations.begin(), conversations.end(), + [this](const auto& conversation) + { + return sameConversation(conversation.id, + active_conversation_); + }); + if (!active_visible) + { + auto active_meta = makeSyntheticPrimary(active_conversation_); + active_meta.name = + titleForConversation(active_conversation_, {}, contacts); + conversations.insert(conversations.begin(), std::move(active_meta)); + } + } + + out.conversations.reserve(conversations.size()); + displayed_conversations_.clear(); + displayed_conversations_.reserve(conversations.size()); + for (const auto& conversation : conversations) + { + ChatConversationItem item = makeConversationItem(conversation, + active_conversation_, + contacts, + has_local_gps, + local_lat, + local_lon); + item.unread_source = + unreadSenderSummary(services_.chat(), conversation, contacts); + out.conversations.push_back(std::move(item)); + displayed_conversations_.push_back(conversation.id); + } + + auto active_it = + std::find_if(conversations.begin(), conversations.end(), + [this](const auto& conversation) + { + return sameConversation(conversation.id, + active_conversation_); + }); + if (active_it != conversations.end()) + { + out.active_title = + titleForConversation(active_it->id, active_it->name, contacts); + out.active_meta = metaForConversation(*active_it, contacts); + } + else + { + out.active_title = titleForConversation(active_conversation_, {}, + contacts); + out.active_meta = protocolLabel(active_conversation_.protocol); + } + + auto messages = + services_.chat().getRecentMessages(active_conversation_, message_limit); + out.messages.reserve(messages.size()); + const ::chat::NodeId self_node = services_.selfNodeId(); + for (const auto& message : messages) + { + out.messages.push_back(makeMessageItem(message, self_node, contacts)); + const ::chat::NodeId sender = + message.from == 0 || message.from == self_node ? 0 : message.from; + if (sender == 0) + { + continue; + } + const bool already_listed = + std::any_of(out.nodes.begin(), + out.nodes.end(), + [sender](const auto& node) + { + return node.node_id == sender; + }); + if (!already_listed && out.nodes.size() < 5U) + { + if (const auto* node = contacts.getNodeInfo(sender)) + { + out.nodes.push_back(makeNodeInfoItem(*node)); + } + } + } + + if (active_conversation_.peer != 0 && + std::none_of(out.nodes.begin(), + out.nodes.end(), + [this](const auto& node) + { + return node.node_id == active_conversation_.peer; + })) + { + if (const auto* node = contacts.getNodeInfo(active_conversation_.peer)) + { + out.nodes.insert(out.nodes.begin(), makeNodeInfoItem(*node)); + } + } + + return out; +} + +ChatNodeDetailSnapshot UConsoleChatWorkspaceModel::nodeDetails( + ::chat::NodeId node_id) const +{ + ChatNodeDetailSnapshot out{}; + out.node_id = node_id; + out.title = node_id == 0 ? "Node unavailable" : formatNodeLabel(node_id); + + if (node_id == 0) + { + out.subtitle = "No node id was attached to this action."; + return out; + } + + const auto* node = services_.contacts().getNodeInfo(node_id); + if (node == nullptr) + { + out.subtitle = "No NodeInfo record is stored locally yet."; + return out; + } + + out.found = true; + out.title = node->display_name.empty() ? formatNodeLabel(node->node_id) + : node->display_name; + out.subtitle = std::string(contactProtocolLabel(node->protocol)) + " / " + + (node->via_mqtt ? "MQTT" : "LoRa") + " / " + + formatAge(node->last_seen); + if (node->position.valid) + { + out.has_position = true; + out.lat = static_cast(node->position.latitude_i) / 10000000.0; + out.lon = static_cast(node->position.longitude_i) / 10000000.0; + + if (const auto* self_info = + services_.contacts().getNodeInfo(services_.selfNodeId()); + self_info != nullptr && self_info->position.valid) + { + out.has_self_position = true; + out.self_lat = + static_cast(self_info->position.latitude_i) / + 10000000.0; + out.self_lon = + static_cast(self_info->position.longitude_i) / + 10000000.0; + } + else + { + const auto gps = ::platform::ui::gps::get_data(); + if (gps.valid) + { + out.has_self_position = true; + out.self_lat = gps.lat; + out.self_lon = gps.lng; + } + } + if (out.has_self_position) + { + out.distance_m = + distanceMeters(out.self_lat, out.self_lon, out.lat, out.lon); + out.bearing_deg = + bearingDegrees(out.self_lat, out.self_lon, out.lat, out.lon); + } + } + + ChatNodeDetailSection identity{"Identity", {}}; + appendDetailRow(identity, "Node ID", formatNodeInfoId(node->node_id)); + if (node->short_name[0] != '\0') + { + appendDetailRow(identity, "Short name", node->short_name); + } + if (node->long_name[0] != '\0') + { + appendDetailRow(identity, "Long name", node->long_name); + } + appendDetailRow(identity, "Protocol", contactProtocolLabel(node->protocol)); + appendDetailRow(identity, "Role", roleLabel(node->role)); + if (node->hw_model != 0) + { + appendDetailRow(identity, + "HW model", + "Meshtastic #" + std::to_string(node->hw_model)); + } + appendDetailRow(identity, + "Channel", + node->channel == 0xFF + ? "Unknown" + : std::to_string(static_cast(node->channel))); + appendDetailRow(identity, "Transport", node->via_mqtt ? "MQTT" : "LoRa"); + appendDetailRow(identity, "Contact", node->is_contact ? "Yes" : "No"); + appendDetailRow(identity, + "Ignored", + node->is_ignored ? "Yes" : "No", + node->is_ignored); + appendDetailSection(out, std::move(identity)); + + ChatNodeDetailSection radio{"Radio", {}}; + appendDetailRow(radio, + "RSSI", + std::isfinite(node->rssi) + ? formatFloatValue(node->rssi, " dBm", 0) + : "Unknown"); + appendDetailRow(radio, + "SNR", + std::isfinite(node->snr) + ? formatFloatValue(node->snr, " dB", 1) + : "Unknown"); + appendDetailRow(radio, + "Hops", + node->hops_away == 0xFF + ? "Unknown" + : std::to_string(static_cast( + node->hops_away))); + if (node->next_hop != 0) + { + appendDetailRow(radio, + "Next hop", + std::to_string(static_cast(node->next_hop))); + } + appendDetailRow(radio, "Last seen", formatAge(node->last_seen)); + appendDetailSection(out, std::move(radio)); + + ChatNodeDetailSection position{"Position", {}}; + if (node->position.valid) + { + appendDetailRow(position, + "Latitude", + formatCoordinate(node->position.latitude_i)); + appendDetailRow(position, + "Longitude", + formatCoordinate(node->position.longitude_i)); + if (node->position.has_altitude) + { + appendDetailRow(position, + "Altitude", + std::to_string(node->position.altitude) + " m"); + } + if (node->position.timestamp != 0) + { + appendDetailRow(position, + "Position age", + formatAge(node->position.timestamp)); + } + if (node->position.precision_bits != 0) + { + appendDetailRow(position, + "Precision", + std::to_string(node->position.precision_bits) + + " bits"); + } + if (node->position.gps_accuracy_mm != 0) + { + appendDetailRow( + position, + "GPS accuracy", + formatFloatValue( + static_cast(node->position.gps_accuracy_mm) / + 1000.0, + " m", + 1)); + } + appendDetailRow(position, "PDOP", formatDop(node->position.pdop)); + appendDetailRow(position, "HDOP", formatDop(node->position.hdop)); + appendDetailRow(position, "VDOP", formatDop(node->position.vdop)); + } + else + { + appendDetailRow(position, + "Stored position", + "No POSITION packet has been stored.", + true); + } + appendDetailSection(out, std::move(position)); + + ChatNodeDetailSection security{"Security", {}}; + appendDetailRow(security, + "Public key", + node->has_public_key ? "Stored" : "Not stored", + !node->has_public_key); + appendDetailRow(security, + "Key verification", + node->key_manually_verified ? "Trusted" : "Unverified", + node->has_public_key && !node->key_manually_verified); + if (node->has_macaddr) + { + appendDetailRow(security, "MAC", formatMacAddress(node->macaddr)); + } + appendDetailSection(out, std::move(security)); + + ChatNodeDetailSection metrics{"Device metrics", {}}; + if (node->has_device_metrics) + { + const auto& m = node->device_metrics; + if (m.has_battery_level) + { + appendDetailRow(metrics, + "Battery", + std::to_string(m.battery_level) + "%"); + } + if (m.has_voltage) + { + appendDetailRow(metrics, + "Voltage", + formatFloatValue(m.voltage, " V", 2)); + } + if (m.has_channel_utilization) + { + appendDetailRow(metrics, + "Channel util", + formatFloatValue(m.channel_utilization, "%", 1)); + } + if (m.has_air_util_tx) + { + appendDetailRow(metrics, + "TX air util", + formatFloatValue(m.air_util_tx, "%", 1)); + } + if (m.has_uptime_seconds) + { + appendDetailRow(metrics, + "Uptime", + std::to_string(m.uptime_seconds) + " s"); + } + } + else + { + appendDetailRow(metrics, "Telemetry", "No device metrics stored."); + } + appendDetailSection(out, std::move(metrics)); + + return out; +} + +bool UConsoleChatWorkspaceModel::selectConversationAt( + std::size_t index, + std::size_t conversation_limit, + ChatThreadSortMode sort_mode) +{ + if (displayed_conversations_.empty()) + { + static_cast(snapshot(conversation_limit, 0, sort_mode)); + } + if (index < displayed_conversations_.size()) + { + return selectConversation(displayed_conversations_[index]); + } + + std::size_t total = 0; + auto conversations = loadConversationPage(conversation_limit, + &total, + sort_mode); + if (conversations.empty() && index == 0) + { + return selectPrimaryConversation(); + } + if (index >= conversations.size()) + { + return false; + } + return selectConversation(conversations[index].id); +} + +bool UConsoleChatWorkspaceModel::selectConversation( + const ::chat::ConversationId& conversation) +{ + active_conversation_ = conversation; + active_initialized_ = true; + services_.chat().markConversationRead(active_conversation_); + action_status_ = "Conversation selected."; + return true; +} + +bool UConsoleChatWorkspaceModel::selectPrimaryConversation() +{ + return selectConversation(primaryConversation()); +} + +bool UConsoleChatWorkspaceModel::sendText(const std::string& text) +{ + ensureActiveConversation(); + const std::string trimmed = trimCopy(text); + if (trimmed.empty()) + { + action_status_ = "Type a message before sending."; + return false; + } + if (!canSendActiveConversation()) + { + action_status_ = "No Linux mesh transport is connected."; + return false; + } + + const ::chat::MessageId message_id = + services_.chat().sendText(active_conversation_.channel, trimmed, + active_conversation_.peer); + if (message_id == 0) + { + action_status_ = "Message failed to queue."; + return false; + } + + action_status_ = "Message queued."; + return true; +} + +bool UConsoleChatWorkspaceModel::sendCurrentPosition() +{ + ensureActiveConversation(); + auto* adapter = services_.meshAdapter(); + if (adapter == nullptr || !adapter->isReady()) + { + action_status_ = "No Linux mesh transport is connected."; + return false; + } + const auto gps = ::platform::ui::gps::get_data(); + if (!gps.valid) + { + action_status_ = "No GPS fix to send."; + return false; + } + + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = true; + pos.latitude_i = static_cast(std::lround(gps.lat * 10000000.0)); + pos.has_longitude_i = true; + pos.longitude_i = static_cast(std::lround(gps.lng * 10000000.0)); + pos.timestamp = sys::epoch_seconds_now(); + pos.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + if (gps.has_alt) + { + pos.has_altitude = true; + pos.altitude = static_cast(std::lround(gps.alt_m)); + pos.altitude_source = meshtastic_Position_AltSource_ALT_INTERNAL; + } + + std::uint8_t payload[meshtastic_Position_size] = {}; + pb_ostream_t stream = pb_ostream_from_buffer(payload, sizeof(payload)); + if (!pb_encode(&stream, meshtastic_Position_fields, &pos)) + { + action_status_ = "Position encoding failed."; + return false; + } + if (!adapter->sendAppData(active_conversation_.channel, + meshtastic_PortNum_POSITION_APP, + payload, + stream.bytes_written, + active_conversation_.peer, + false)) + { + action_status_ = "Position failed to queue."; + return false; + } + action_status_ = "Position queued."; + return true; +} + +bool UConsoleChatWorkspaceModel::sendCurrentPoi() +{ + ensureActiveConversation(); + auto* adapter = services_.meshAdapter(); + if (adapter == nullptr || !adapter->isReady()) + { + action_status_ = "No Linux mesh transport is connected."; + return false; + } + if (active_conversation_.protocol != ::chat::MeshProtocol::Meshtastic) + { + action_status_ = "POI sharing is currently Meshtastic only."; + return false; + } + const auto gps = ::platform::ui::gps::get_data(); + if (!gps.valid) + { + action_status_ = "No GPS fix to turn into a POI."; + return false; + } + + meshtastic_Waypoint waypoint = meshtastic_Waypoint_init_zero; + waypoint.id = sys::epoch_seconds_now(); + waypoint.has_latitude_i = true; + waypoint.latitude_i = static_cast(std::lround(gps.lat * 10000000.0)); + waypoint.has_longitude_i = true; + waypoint.longitude_i = static_cast(std::lround(gps.lng * 10000000.0)); + waypoint.expire = waypoint.id + 86400U; + std::strncpy(waypoint.name, "Trail Mate POI", sizeof(waypoint.name) - 1); + std::strncpy(waypoint.description, + "Shared from uConsole current GPS fix", + sizeof(waypoint.description) - 1); + + std::uint8_t payload[meshtastic_Waypoint_size] = {}; + pb_ostream_t stream = pb_ostream_from_buffer(payload, sizeof(payload)); + if (!pb_encode(&stream, meshtastic_Waypoint_fields, &waypoint)) + { + action_status_ = "POI encoding failed."; + return false; + } + if (!adapter->sendAppData(active_conversation_.channel, + meshtastic_PortNum_WAYPOINT_APP, + payload, + stream.bytes_written, + active_conversation_.peer, + false)) + { + action_status_ = "POI failed to queue."; + return false; + } + action_status_ = "POI queued."; + return true; +} + +bool UConsoleChatWorkspaceModel::requestActiveNodeInfo() +{ + ensureActiveConversation(); + auto* adapter = services_.meshAdapter(); + if (adapter == nullptr || active_conversation_.peer == 0) + { + action_status_ = "Select a direct node first."; + return false; + } + if (!adapter->requestNodeInfo(active_conversation_.peer, true)) + { + action_status_ = "NodeInfo request is not supported by this transport."; + return false; + } + action_status_ = "NodeInfo request queued."; + return true; +} + +bool UConsoleChatWorkspaceModel::addActivePeerAsContact() +{ + ensureActiveConversation(); + if (active_conversation_.peer == 0) + { + action_status_ = "Broadcast cannot be added as a contact."; + return false; + } + const std::string name = + displayNameOrId(services_.contacts(), active_conversation_.peer); + if (!services_.contacts().addContact(active_conversation_.peer, + name.c_str())) + { + action_status_ = "Contact could not be saved."; + return false; + } + action_status_ = "Contact saved."; + return true; +} + +bool UConsoleChatWorkspaceModel::selectNodeConversation(::chat::NodeId node_id) +{ + ensureActiveConversation(); + if (node_id == 0) + { + action_status_ = "Node is unavailable."; + return false; + } + const ::chat::ChannelId channel = active_conversation_.channel; + return selectConversation(::chat::ConversationId(channel, + node_id, + services_.meshProtocol())); +} + +bool UConsoleChatWorkspaceModel::addNodeAsContact(::chat::NodeId node_id) +{ + if (node_id == 0) + { + action_status_ = "Node is unavailable."; + return false; + } + const std::string name = displayNameOrId(services_.contacts(), node_id); + if (!services_.contacts().addContact(node_id, name.c_str())) + { + action_status_ = "Contact could not be saved."; + return false; + } + action_status_ = "Contact saved."; + return true; +} + +bool UConsoleChatWorkspaceModel::requestNodeInfo(::chat::NodeId node_id) +{ + if (node_id == 0) + { + action_status_ = "Node is unavailable."; + return false; + } + auto* adapter = services_.meshAdapter(); + if (adapter == nullptr) + { + action_status_ = "No Linux mesh transport is connected."; + return false; + } + if (!adapter->requestNodeInfo(node_id, true)) + { + action_status_ = "NodeInfo request is not supported by this transport."; + return false; + } + action_status_ = "NodeInfo request queued."; + return true; +} + +bool UConsoleChatWorkspaceModel::exchangeUserInfo(::chat::NodeId node_id) +{ + if (node_id == 0) + { + action_status_ = "Node is unavailable."; + return false; + } + auto* adapter = services_.meshAdapter(); + if (adapter == nullptr) + { + action_status_ = "No Linux mesh transport is connected."; + return false; + } + if (!adapter->requestNodeInfo(node_id, true)) + { + action_status_ = "UserInfo exchange is not supported by this transport."; + return false; + } + action_status_ = "UserInfo exchange queued."; + return true; +} + +bool UConsoleChatWorkspaceModel::toggleNodeIgnored(::chat::NodeId node_id) +{ + if (node_id == 0) + { + action_status_ = "Node is unavailable."; + return false; + } + const auto* node = services_.contacts().getNodeInfo(node_id); + if (node == nullptr) + { + action_status_ = "Node record is not stored yet."; + return false; + } + const bool next = !node->is_ignored; + if (!services_.contacts().setNodeIgnored(node_id, next)) + { + action_status_ = "Ignore state could not be saved."; + return false; + } + action_status_ = next ? "Node ignored." : "Node unignored."; + return true; +} + +bool UConsoleChatWorkspaceModel::verifyNodeKey(::chat::NodeId node_id) +{ + if (node_id == 0) + { + action_status_ = "Node is unavailable."; + return false; + } + + auto* adapter = services_.meshAdapter(); + const ::chat::MeshCapabilities capabilities = + adapter == nullptr ? ::chat::MeshCapabilities{} + : adapter->getCapabilities(); + if (adapter != nullptr && capabilities.supports_pki && + adapter->startKeyVerification(node_id)) + { + action_status_ = "Key verification started."; + return true; + } + + const auto* node = services_.contacts().getNodeInfo(node_id); + if (node == nullptr || !node->has_public_key) + { + action_status_ = "No public key is stored for this node."; + return false; + } + if (node->key_manually_verified) + { + action_status_ = "Key is already trusted."; + return true; + } + if (!services_.contacts().setNodeKeyManuallyVerified(node_id, true)) + { + action_status_ = "Key trust state could not be saved."; + return false; + } + action_status_ = "Key marked trusted."; + return true; +} + +void UConsoleChatWorkspaceModel::ensureActiveConversation() +{ + if (active_initialized_) return; + active_conversation_ = primaryConversation(); + active_initialized_ = true; +} + +::chat::ConversationId UConsoleChatWorkspaceModel::primaryConversation() const +{ + return ::chat::ConversationId(::chat::ChannelId::PRIMARY, 0, + services_.meshProtocol()); +} + +bool UConsoleChatWorkspaceModel::canSendActiveConversation() const +{ + const auto* adapter = services_.meshAdapter(); + if (adapter == nullptr || !adapter->isReady()) + { + return false; + } + + const ::chat::MeshCapabilities capabilities = adapter->getCapabilities(); + return capabilities.supports_unicast_text; +} + +std::vector<::chat::ConversationMeta> +UConsoleChatWorkspaceModel::loadConversationPage(std::size_t limit, + std::size_t* total, + ChatThreadSortMode sort_mode) const +{ + const std::size_t fetch_limit = std::max(limit, 64U); + std::size_t stored_total = 0; + auto conversations = services_.chat().getConversations(0, + fetch_limit, + &stored_total); + auto& contacts = services_.contacts(); + const auto contact_nodes = contacts.getContacts(); + const auto nearby_nodes = contacts.getNearby(); + const auto fallback_protocol = services_.meshProtocol(); + const std::size_t before_node_conversations = conversations.size(); + for (const auto& node : contact_nodes) + { + appendNodeConversationIfMissing(conversations, node, fallback_protocol); + } + for (const auto& node : nearby_nodes) + { + appendNodeConversationIfMissing(conversations, node, fallback_protocol); + } + if (total != nullptr) + { + *total = + stored_total + (conversations.size() - before_node_conversations); + } + const auto gps = ::platform::ui::gps::get_data(); + sortConversations(conversations, + sort_mode, + contacts, + gps.valid, + gps.lat, + gps.lng); + if (conversations.size() > limit) + { + conversations.resize(limit); + } + return conversations; +} + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/src/uconsole_dashboard_model.cpp b/platform/linux/uconsole/src/uconsole_dashboard_model.cpp new file mode 100644 index 00000000..cffc97e5 --- /dev/null +++ b/platform/linux/uconsole/src/uconsole_dashboard_model.cpp @@ -0,0 +1,1045 @@ +#include "uconsole/uconsole_dashboard_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/linux_app_services.h" +#include "chat/domain/contact_types.h" +#include "chat/linux_raw_lora_mesh_adapter.h" +#include "chat/ports/i_mesh_adapter.h" +#include "chat/usecase/chat_service.h" +#include "chat/usecase/contact_service.h" +#include "platform/linux/map_tile_cache.h" +#include "platform/linux/runtime_packet_log.h" +#include "platform/linux/runtime_paths.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/team_ui_store_runtime.h" +#include "sys/clock.h" +#include "team/protocol/team_chat.h" +#include "uconsole/uconsole_hardware_probe.h" + +namespace trailmate::uconsole +{ +namespace +{ + +[[nodiscard]] const char* protocolLabel(::chat::MeshProtocol protocol) noexcept +{ + switch (protocol) + { + case ::chat::MeshProtocol::Meshtastic: + return "Meshtastic"; + case ::chat::MeshProtocol::MeshCore: + return "MeshCore"; + case ::chat::MeshProtocol::RNode: + return "RNode"; + case ::chat::MeshProtocol::LXMF: + return "LXMF"; + } + return "Unknown"; +} + +[[nodiscard]] const char* nodeProtocolLabel( + ::chat::contacts::NodeProtocolType protocol) noexcept +{ + switch (protocol) + { + case ::chat::contacts::NodeProtocolType::Meshtastic: + return "Meshtastic"; + case ::chat::contacts::NodeProtocolType::MeshCore: + return "MeshCore"; + case ::chat::contacts::NodeProtocolType::RNode: + return "RNode"; + case ::chat::contacts::NodeProtocolType::LXMF: + return "LXMF"; + case ::chat::contacts::NodeProtocolType::Unknown: + return "Unknown"; + } + return "Unknown"; +} + +[[nodiscard]] std::string formatNodeId(std::uint32_t node_id) +{ + char buffer[16] = {}; + std::snprintf(buffer, sizeof(buffer), "%08lX", + static_cast(node_id)); + return buffer; +} + +[[nodiscard]] std::string formatUnread(int unread) +{ + if (unread <= 0) return "read"; + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%d unread", unread); + return buffer; +} + +[[nodiscard]] std::string formatLastSeen(std::uint32_t timestamp) +{ + if (timestamp == 0) return "no activity"; + + const std::uint32_t now = sys::epoch_seconds_now(); + if (timestamp >= now) return "now"; + + const std::uint32_t age = now - timestamp; + if (age < 60U) return "now"; + if (age < 3600U) + { + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%lum ago", + static_cast(age / 60U)); + return buffer; + } + if (age < 86400U) + { + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%luh ago", + static_cast(age / 3600U)); + return buffer; + } + + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%lud ago", + static_cast(age / 86400U)); + return buffer; +} + +[[nodiscard]] std::uint64_t secondsToMs(std::uint32_t timestamp) +{ + return static_cast(timestamp) * 1000ULL; +} + +[[nodiscard]] std::string formatClock(std::uint64_t timestamp_ms) +{ + const std::time_t seconds = + static_cast(timestamp_ms / 1000ULL); + std::tm local{}; +#if defined(_WIN32) + localtime_s(&local, &seconds); +#else + localtime_r(&seconds, &local); +#endif + char buffer[16] = {}; + std::snprintf(buffer, + sizeof(buffer), + "%02d:%02d", + local.tm_hour, + local.tm_min); + return buffer; +} + +[[nodiscard]] bool containsInsensitive(const std::string& text, + const char* needle) +{ + if (needle == nullptr || needle[0] == '\0') + { + return false; + } + std::string lower_text = text; + std::string lower_needle = needle; + std::transform(lower_text.begin(), + lower_text.end(), + lower_text.begin(), + [](unsigned char ch) + { + return static_cast(std::tolower(ch)); + }); + std::transform(lower_needle.begin(), + lower_needle.end(), + lower_needle.begin(), + [](unsigned char ch) + { + return static_cast(std::tolower(ch)); + }); + return lower_text.find(lower_needle) != std::string::npos; +} + +[[nodiscard]] std::string timelineKindFromText(const std::string& text) +{ + if (containsInsensitive(text, "team")) + { + return "team"; + } + if (containsInsensitive(text, "nodeinfo") || + containsInsensitive(text, "node info")) + { + return "node"; + } + if (containsInsensitive(text, "position") || + containsInsensitive(text, "location")) + { + return "position"; + } + if (containsInsensitive(text, "telemetry") || + containsInsensitive(text, "metrics")) + { + return "telemetry"; + } + if (containsInsensitive(text, "message") || + containsInsensitive(text, "text") || + containsInsensitive(text, "chat")) + { + return "message"; + } + return "system"; +} + +void pushOverviewTimeline(std::vector& out, + OverviewTimelineItem item) +{ + if (item.timestamp_ms == 0) + { + item.timestamp_ms = secondsToMs(sys::epoch_seconds_now()); + } + if (item.time_label.empty()) + { + item.time_label = formatClock(item.timestamp_ms); + } + if (item.kind.empty()) + { + item.kind = timelineKindFromText(item.title + " " + item.detail); + } + out.push_back(std::move(item)); +} + +[[nodiscard]] ContactPreview makeContactPreview( + const ::chat::contacts::NodeInfo& node) +{ + ContactPreview preview{}; + preview.name = node.display_name.empty() ? node.short_name : node.display_name; + if (preview.name.empty()) preview.name = node.long_name; + if (preview.name.empty()) preview.name = formatNodeId(node.node_id); + preview.node_id = formatNodeId(node.node_id); + preview.status = formatLastSeen(node.last_seen); + preview.protocol = nodeProtocolLabel(node.protocol); + return preview; +} + +[[nodiscard]] std::string conversationLabel( + const ::chat::ConversationId& id, + const std::string& stored_name, + const ::chat::contacts::ContactService& contacts) +{ + if (!stored_name.empty() && stored_name != "Broadcast") + { + return stored_name; + } + if (id.peer == 0) + { + return id.channel == ::chat::ChannelId::SECONDARY + ? "Secondary broadcast" + : "Primary broadcast"; + } + if (const auto* node = contacts.getNodeInfo(id.peer)) + { + if (!node->display_name.empty()) + { + return node->display_name; + } + if (node->short_name[0] != '\0') + { + return std::string(node->short_name); + } + if (node->long_name[0] != '\0') + { + return std::string(node->long_name); + } + } + return formatNodeId(id.peer); +} + +[[nodiscard]] RecentContactPreview makeRecentContactPreview( + const ::chat::ConversationMeta& conversation, + const ::chat::contacts::ContactService& contacts) +{ + RecentContactPreview preview{}; + preview.conversation = conversation.id; + preview.name = conversationLabel(conversation.id, conversation.name, contacts); + preview.direct = conversation.id.peer != 0; + preview.team = containsInsensitive(conversation.name, "team") || + containsInsensitive(conversation.preview, "team"); + preview.has_unread = conversation.unread > 0; + preview.badge = preview.team ? "Team" : (preview.direct ? "Direct" : "Broadcast"); + preview.meta = formatUnread(conversation.unread) + " / " + + formatLastSeen(conversation.last_timestamp); + if (conversation.id.peer != 0) + { + if (const auto* node = contacts.getNodeInfo(conversation.id.peer)) + { + preview.detail = + std::string("hops ") + + (node->hops_away == 0xFF + ? "?" + : std::to_string(node->hops_away)) + + " / " + (node->via_mqtt ? "MQTT" : "LoRa"); + } + } + if (preview.detail.empty()) + { + preview.detail = conversation.preview.empty() + ? "No messages in this thread yet." + : conversation.preview; + } + return preview; +} + +[[nodiscard]] bool envConfigured(const char* name) +{ + const char* value = std::getenv(name); + return value != nullptr && value[0] != '\0'; +} + +[[nodiscard]] bool gpsSourceConfigured() +{ + std::string auto_path{}; + return envConfigured("TRAIL_MATE_GPS_DEVICE") || + envConfigured("TRAIL_MATE_GPS_NMEA_FILE") || + envConfigured("TRAIL_MATE_GPS_VALID") || + envConfigured("TRAIL_MATE_GPS_LAT") || + envConfigured("TRAIL_MATE_GPS_LNG") || + uconsoleAutoGpsSerialPath(auto_path); +} + +[[nodiscard]] bool parseEnvDouble(const char* name, double& out) +{ + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + { + return false; + } + + char* end = nullptr; + const double parsed = std::strtod(value, &end); + if (end == value || (end != nullptr && *end != '\0') || + !std::isfinite(parsed)) + { + return false; + } + + out = parsed; + return true; +} + +[[nodiscard]] bool configuredMapCenter(double& lat, double& lon) +{ + return parseEnvDouble("TRAIL_MATE_MAP_LAT", lat) && + parseEnvDouble("TRAIL_MATE_MAP_LNG", lon); +} + +[[nodiscard]] std::string compactStoragePath() +{ + return ::platform::linux_runtime::sqlite_database_path().string(); +} + +[[nodiscard]] std::string formatCoordinate(double lat, double lon) +{ + char buffer[64] = {}; + std::snprintf(buffer, sizeof(buffer), "%.5f, %.5f", lat, lon); + return buffer; +} + +[[nodiscard]] std::string formatSpeed(double speed_mps) +{ + char buffer[32] = {}; + std::snprintf(buffer, sizeof(buffer), "%.1f m/s", speed_mps); + return buffer; +} + +[[nodiscard]] std::string teamIdLabel(const ::team::TeamId& id) +{ + char buffer[17] = {}; + for (std::size_t index = 0; index < 8U && index < id.size(); ++index) + { + std::snprintf(buffer + (index * 2U), 3, "%02X", + static_cast(id[index])); + } + return buffer; +} + +[[nodiscard]] std::string cleanPayloadText(const std::vector& payload) +{ + std::string text{}; + text.reserve(std::min(payload.size(), 80U)); + for (const std::uint8_t byte : payload) + { + if (text.size() >= 80U) + { + text += "..."; + break; + } + const unsigned char ch = static_cast(byte); + text += std::isprint(ch) ? static_cast(ch) : '.'; + } + return text; +} + +struct TimelineCandidate +{ + std::uint32_t timestamp = 0; + TeamTimelineItem item{}; +}; + +void pushTimeline(std::vector& out, + std::uint32_t timestamp, + std::string title, + std::string detail, + bool attention = false) +{ + if (timestamp == 0 && title.empty() && detail.empty()) + { + return; + } + + out.push_back(TimelineCandidate{.timestamp = timestamp, + .item = {.title = std::move(title), + .detail = std::move(detail), + .attention = attention}}); +} + +void appendPacketLogTimeline(std::vector& out) +{ + const ::platform::linux_runtime::PacketLogSource sources[] = { + ::platform::linux_runtime::PacketLogSource::Lora, + ::platform::linux_runtime::PacketLogSource::Mqtt, + }; + for (const auto source : sources) + { + const auto logs = + ::platform::linux_runtime::recent_packet_logs(source, 120U); + for (const auto& log : logs) + { + OverviewTimelineItem item{}; + item.timestamp_ms = log.timestamp_ms; + item.title = log.title.empty() + ? std::string(::platform::linux_runtime:: + packet_log_source_label(source)) + + " activity" + : log.title; + item.detail = log.summary; + item.kind = timelineKindFromText(item.title + " " + item.detail); + item.outgoing = log.direction == + ::platform::linux_runtime::PacketLogDirection::Tx; + item.direct = containsInsensitive(log.summary, " direct ") || + containsInsensitive(log.summary, " to "); + item.team = containsInsensitive(item.title, "team") || + containsInsensitive(item.detail, "team"); + item.badge = + item.team ? "Team" + : std::string(::platform::linux_runtime:: + packet_log_source_label(source)) + + " " + + ::platform::linux_runtime:: + packet_log_direction_label(log.direction); + item.attention = containsInsensitive(item.title, "fail") || + containsInsensitive(item.detail, "fail") || + containsInsensitive(item.detail, "error"); + pushOverviewTimeline(out, std::move(item)); + } + } +} + +void appendChatTimeline(std::vector& out, + ::chat::ChatService& chat_service, + const ::chat::contacts::ContactService& contacts) +{ + std::size_t total = 0; + const auto conversations = chat_service.getConversations(0, 48U, &total); + for (const auto& conversation : conversations) + { + const auto messages = + chat_service.getRecentMessages(conversation.id, 12U); + for (const auto& message : messages) + { + OverviewTimelineItem item{}; + item.timestamp_ms = secondsToMs(message.timestamp); + item.kind = "message"; + item.outgoing = message.status != ::chat::MessageStatus::Incoming; + item.direct = conversation.id.peer != 0; + item.team = containsInsensitive(conversation.name, "team") || + message.team_location_icon != 0; + item.badge = item.team ? "Team" : (item.direct ? "Direct" : "Broadcast"); + item.title = item.outgoing ? "Sent message" : "Received message"; + item.detail = + conversationLabel(conversation.id, conversation.name, contacts); + if (!message.text.empty()) + { + item.detail += " / "; + item.detail += message.text.size() > 72U + ? message.text.substr(0, 72U) + "..." + : message.text; + } + item.attention = message.status == ::chat::MessageStatus::Failed; + pushOverviewTimeline(out, std::move(item)); + } + } +} + +void appendNodeTimeline(std::vector& out, + const std::vector<::chat::contacts::NodeInfo>& nodes) +{ + for (const auto& node : nodes) + { + const std::string name = + node.display_name.empty() ? formatNodeId(node.node_id) + : node.display_name; + if (node.last_seen != 0) + { + OverviewTimelineItem item{}; + item.timestamp_ms = secondsToMs(node.last_seen); + item.kind = "node"; + item.title = "NodeInfo received"; + item.detail = name + " / " + formatNodeId(node.node_id); + item.badge = node.via_mqtt ? "MQTT" : "LoRa"; + pushOverviewTimeline(out, std::move(item)); + } + if (node.position.valid && node.position.timestamp != 0) + { + OverviewTimelineItem item{}; + item.timestamp_ms = secondsToMs(node.position.timestamp); + item.kind = "position"; + item.title = "Position received"; + item.detail = + name + " / " + + formatCoordinate(static_cast(node.position.latitude_i) / + 10000000.0, + static_cast(node.position.longitude_i) / + 10000000.0); + item.badge = node.via_mqtt ? "MQTT" : "LoRa"; + pushOverviewTimeline(out, std::move(item)); + } + if (node.has_device_metrics && node.last_seen != 0) + { + OverviewTimelineItem item{}; + item.timestamp_ms = secondsToMs(node.last_seen); + item.kind = "telemetry"; + item.title = "Telemetry received"; + item.detail = name; + if (node.device_metrics.has_battery_level) + { + item.detail += " / battery " + + std::to_string(node.device_metrics.battery_level) + + "%"; + } + if (node.device_metrics.has_voltage) + { + char voltage[24] = {}; + std::snprintf(voltage, + sizeof(voltage), + " / %.2f V", + static_cast(node.device_metrics.voltage)); + item.detail += voltage; + } + item.badge = node.via_mqtt ? "MQTT" : "LoRa"; + pushOverviewTimeline(out, std::move(item)); + } + } +} + +[[nodiscard]] std::string memberDisplayName( + const ::team::ui::TeamMemberUi& member) +{ + if (!member.name.empty()) + { + return member.name; + } + return formatNodeId(member.node_id); +} + +void buildTeamOverview(UConsoleDashboardSnapshot& out) +{ + ::team::ui::TeamUiSnapshot team_snapshot{}; + if (!::team::ui::team_ui_get_store().load(team_snapshot)) + { + out.team_summary = "No team state stored locally."; + return; + } + + if (team_snapshot.in_team) + { + out.team_summary = + "Team: " + + (team_snapshot.team_name.empty() ? std::string("unnamed") + : team_snapshot.team_name) + + (team_snapshot.self_is_leader ? " / leader" : " / member") + + " / members " + std::to_string(team_snapshot.members.size()) + + " / unread " + std::to_string(team_snapshot.team_chat_unread); + } + else if (team_snapshot.pending_join) + { + out.team_summary = "Team join is pending."; + } + else if (team_snapshot.kicked_out) + { + out.team_summary = "Last team state: kicked out."; + } + else + { + out.team_summary = "Not in a team."; + } + + std::vector timeline{}; + + if (team_snapshot.pending_join) + { + pushTimeline(timeline, + team_snapshot.pending_join_started_s, + "Join pending", + formatLastSeen(team_snapshot.pending_join_started_s), + true); + } + if (team_snapshot.kicked_out) + { + pushTimeline(timeline, + team_snapshot.last_update_s, + "Kicked out", + formatLastSeen(team_snapshot.last_update_s), + true); + } + if (team_snapshot.last_update_s != 0) + { + std::string detail = formatLastSeen(team_snapshot.last_update_s); + if (team_snapshot.has_team_id) + { + detail += " / team " + teamIdLabel(team_snapshot.team_id); + } + pushTimeline(timeline, + team_snapshot.last_update_s, + "Team state updated", + detail); + } + + for (const auto& member : team_snapshot.members) + { + if (member.last_seen_s == 0) + { + continue; + } + std::string detail = formatLastSeen(member.last_seen_s); + detail += member.online ? " / online" : " / offline"; + if (member.leader) + { + detail += " / leader"; + } + pushTimeline(timeline, + member.last_seen_s, + memberDisplayName(member) + " seen", + detail); + } + + if (team_snapshot.has_team_id) + { + std::vector<::team::ui::TeamChatLogEntry> chat_log{}; + if (::team::ui::team_ui_chatlog_load_recent( + team_snapshot.team_id, 4U, chat_log)) + { + for (const auto& entry : chat_log) + { + std::string detail = formatLastSeen(entry.ts) + " / " + + formatNodeId(entry.peer_id); + if (entry.type == ::team::proto::TeamChatType::Text) + { + const std::string text = cleanPayloadText(entry.payload); + if (!text.empty()) + { + detail += " / " + text; + } + } + else + { + detail += " / structured team message"; + } + pushTimeline(timeline, + entry.ts, + entry.incoming ? "Team chat received" + : "Team chat sent", + detail); + } + } + + std::vector<::team::ui::TeamPosSample> positions{}; + if (::team::ui::team_ui_posring_load_latest(team_snapshot.team_id, + positions)) + { + std::sort(positions.begin(), + positions.end(), + [](const auto& left, const auto& right) + { + return left.ts > right.ts; + }); + const std::size_t count = + std::min(positions.size(), 4U); + for (std::size_t index = 0; index < count; ++index) + { + const auto& position = positions[index]; + const double lat = + static_cast(position.lat_e7) / 10000000.0; + const double lon = + static_cast(position.lon_e7) / 10000000.0; + std::string detail = formatLastSeen(position.ts) + " / " + + formatCoordinate(lat, lon) + " / alt " + + std::to_string(position.alt_m) + " m"; + pushTimeline(timeline, + position.ts, + formatNodeId(position.member_id) + + " position update", + detail); + } + } + } + + std::sort(timeline.begin(), + timeline.end(), + [](const auto& left, const auto& right) + { + return left.timestamp > right.timestamp; + }); + + const std::size_t count = std::min(timeline.size(), 7U); + out.team_timeline.reserve(count); + for (std::size_t index = 0; index < count; ++index) + { + OverviewTimelineItem item{}; + item.timestamp_ms = secondsToMs(timeline[index].timestamp); + item.title = timeline[index].item.title; + item.detail = timeline[index].item.detail; + item.kind = "team"; + item.team = true; + item.badge = "Team"; + item.attention = timeline[index].item.attention; + pushOverviewTimeline(out.timeline, std::move(item)); + out.team_timeline.push_back(std::move(timeline[index].item)); + } +} + +} // namespace + +UConsoleDashboardModel::UConsoleDashboardModel( + linux_app::LinuxAppServices& services) + : services_(services) +{ +} + +UConsoleDashboardSnapshot UConsoleDashboardModel::snapshot() const +{ + UConsoleDashboardSnapshot out{}; + + auto& chat_service = services_.chat(); + auto& contact_service = services_.contacts(); + const auto* mesh_adapter = services_.meshAdapter(); + const auto* raw_lora_adapter = + dynamic_cast(mesh_adapter); + const UConsoleHardwareProbe hardware_probe = probeUConsoleHardware(); + + std::size_t conversation_total = 0; + const auto conversations = + chat_service.getConversations(0, 16, &conversation_total); + out.conversation_count = conversation_total; + out.unread_count = chat_service.getTotalUnread(); + + out.mesh_protocol = protocolLabel(services_.meshProtocol()); + out.self_node = formatNodeId(services_.selfNodeId()); + + out.conversations.reserve(std::min(conversations.size(), 6U)); + for (std::size_t index = 0; + index < conversations.size() && index < 6U; + ++index) + { + const auto& item = conversations[index]; + ConversationPreview preview{}; + preview.title = item.name.empty() ? "Primary channel" : item.name; + preview.preview = + item.preview.empty() ? "No messages in this thread yet." + : item.preview; + preview.meta = formatUnread(item.unread) + " / " + + formatLastSeen(item.last_timestamp); + preview.unread = item.unread; + out.conversations.push_back(std::move(preview)); + } + out.recent_contacts.reserve(5U); + for (std::size_t index = 0; + index < conversations.size() && out.recent_contacts.size() < 5U; + ++index) + { + out.recent_contacts.push_back( + makeRecentContactPreview(conversations[index], contact_service)); + } + + auto contacts = contact_service.getContacts(); + auto nearby = contact_service.getNearby(); + auto ignored = contact_service.getIgnoredNodes(); + out.contact_count = contacts.size(); + out.nearby_count = nearby.size(); + out.ignored_count = ignored.size(); + + std::vector<::chat::contacts::NodeInfo> visible_nodes{}; + visible_nodes.reserve(contacts.size() + nearby.size()); + visible_nodes.insert(visible_nodes.end(), contacts.begin(), contacts.end()); + visible_nodes.insert(visible_nodes.end(), nearby.begin(), nearby.end()); + std::sort(visible_nodes.begin(), visible_nodes.end(), + [](const auto& left, const auto& right) + { + return left.last_seen > right.last_seen; + }); + + const std::size_t contact_limit = + std::min(visible_nodes.size(), 7U); + out.contacts.reserve(contact_limit); + for (std::size_t index = 0; index < contact_limit; ++index) + { + out.contacts.push_back(makeContactPreview(visible_nodes[index])); + } + + appendPacketLogTimeline(out.timeline); + appendChatTimeline(out.timeline, chat_service, contact_service); + appendNodeTimeline(out.timeline, visible_nodes); + + std::string mesh_line = "Mesh: "; + mesh_line += out.mesh_protocol; + HardwareStatusItem lora_status{}; + lora_status.name = "LoRa"; + bool mesh_transport_ready = false; + if (mesh_adapter == nullptr) + { + mesh_line += " transport unavailable"; + if (hardware_probe.lora_spi_detected) + { + lora_status.state = "Endpoint"; + lora_status.detail = "SPI radio endpoint present at " + + hardware_probe.lora_spi_path + + "; Trail Mate LoRa transport driver is not bound yet."; + } + else + { + lora_status.state = "Unavailable"; + lora_status.detail = "No LoRa SPI endpoint or mesh transport adapter is attached."; + } + lora_status.attention = true; + } + else + { + mesh_transport_ready = mesh_adapter->isReady(); + mesh_line += mesh_transport_ready ? " transport ready" + : " transport not connected"; + if (raw_lora_adapter != nullptr) + { + lora_status.state = mesh_transport_ready ? "Ready" : "Endpoint"; + lora_status.detail = raw_lora_adapter->statusText() + " / " + + raw_lora_adapter->radioConfigText() + " / " + + raw_lora_adapter->radioStatsText(); + } + else if (mesh_transport_ready) + { + lora_status.state = "Ready"; + lora_status.detail = "Mesh text transport is available."; + } + else if (hardware_probe.lora_spi_detected) + { + lora_status.state = "Endpoint"; + lora_status.detail = "SPI radio endpoint present at " + + hardware_probe.lora_spi_path + + "; protocol transport is not bound yet."; + } + else + { + lora_status.state = "Offline"; + lora_status.detail = "AIO2/LoRa transport is not connected."; + } + lora_status.attention = !mesh_transport_ready; + } + + const ::platform::linux_runtime::MapTileCache tile_cache; + const auto tile_stats = tile_cache.stats(); + + HardwareStatusItem aio2_status{}; + aio2_status.name = "AIO2"; + if (hardware_probe.aio2_detected) + { + aio2_status.state = "Detected"; + aio2_status.detail = hardware_probe.summary; + aio2_status.attention = false; + } + else + { + aio2_status.state = "Not detected"; + aio2_status.detail = "No uConsole/AIO2 Linux endpoint was found."; + aio2_status.attention = true; + } + + HardwareStatusItem gps_status{}; + gps_status.name = "GPS"; + if (!::platform::ui::gps::is_enabled()) + { + gps_status.state = "Disabled"; + gps_status.detail = "GPS runtime is disabled."; + gps_status.attention = true; + } + else if (!::platform::ui::gps::is_powered()) + { + gps_status.state = "Power off"; + gps_status.detail = "GPS runtime reports receiver power off."; + gps_status.attention = true; + } + else if (!gpsSourceConfigured()) + { + gps_status.state = "No source"; + gps_status.detail = "No GPS serial endpoint or NMEA file configured."; + gps_status.attention = true; + } + else + { + const auto gps = ::platform::ui::gps::get_data(); + if (gps.valid) + { + gps_status.state = "Fix"; + gps_status.detail = "GPS/NMEA source has a valid fix."; + } + else if (hardware_probe.gps_serial_detected) + { + gps_status.state = "Endpoint"; + gps_status.detail = "Serial endpoint present at " + + hardware_probe.gps_serial_path + + "; waiting for valid NMEA fix."; + } + else + { + gps_status.state = "No fix"; + gps_status.detail = + "GPS/NMEA source is present but no valid fix yet."; + } + gps_status.attention = !gps.valid; + } + + HardwareStatusItem storage_status{}; + storage_status.name = "Storage"; + storage_status.state = "SQLite"; + storage_status.detail = compactStoragePath(); + storage_status.attention = false; + + HardwareStatusItem map_status{}; + map_status.name = "Map"; + map_status.state = std::to_string(tile_stats.cached_tiles) + " tiles"; + map_status.detail = tile_stats.root.string(); + map_status.attention = false; + + double configured_lat = 0.0; + double configured_lon = 0.0; + const auto map_source = ::platform::linux_runtime::sanitize_map_base_source( + services_.config().map_source); + out.location.map_meta = + std::string(::platform::linux_runtime::map_base_source_label( + map_source)) + + " / " + std::to_string(tile_stats.cached_tiles) + " cached tiles"; + if (configuredMapCenter(configured_lat, configured_lon)) + { + out.location.state = "Map center"; + out.location.coordinates = + formatCoordinate(configured_lat, configured_lon); + out.location.detail = + "Using explicit map center from TRAIL_MATE_MAP_LAT/LNG."; + out.location.attention = false; + } + else if (!::platform::ui::gps::is_enabled()) + { + out.location.state = "GPS disabled"; + out.location.coordinates = "No coordinates"; + out.location.detail = "GPS runtime is disabled."; + out.location.attention = true; + } + else if (!::platform::ui::gps::is_powered()) + { + out.location.state = "GPS power off"; + out.location.coordinates = "No coordinates"; + out.location.detail = "GPS receiver is not powered."; + out.location.attention = true; + } + else if (!gpsSourceConfigured()) + { + out.location.state = "No location source"; + out.location.coordinates = "No coordinates"; + out.location.detail = "No GPS serial endpoint or NMEA file configured."; + out.location.attention = true; + } + else + { + const auto gps = ::platform::ui::gps::get_data(); + out.location.state = gps.valid ? "GPS fix" : "Waiting for fix"; + out.location.coordinates = + gps.valid ? formatCoordinate(gps.lat, gps.lng) : "No coordinates"; + out.location.detail = + gps.valid ? "Configured GPS/NMEA source is reporting position." + : "GPS/NMEA source is present but has no valid fix."; + if (!gps.valid && hardware_probe.gps_serial_detected) + { + out.location.detail = "AIO2 serial endpoint present at " + + hardware_probe.gps_serial_path + + "; waiting for NMEA fix."; + } + if (gps.valid && gps.has_speed) + { + out.location.detail += " Speed " + formatSpeed(gps.speed_mps) + "."; + } + out.location.attention = !gps.valid; + } + + out.messages.title = conversation_total == 0U + ? "No stored messages" + : std::to_string(conversation_total) + " threads"; + out.messages.detail = + std::to_string(out.unread_count) + " unread / " + + (mesh_transport_ready ? "mesh transport ready" + : "LoRa transport offline"); + out.messages.latest = + out.conversations.empty() ? "No messages are stored locally." + : out.conversations.front().title + " - " + + out.conversations.front().preview; + out.messages.attention = out.unread_count > 0 || !mesh_transport_ready; + + buildTeamOverview(out); + std::sort(out.timeline.begin(), + out.timeline.end(), + [](const auto& left, const auto& right) + { + return left.timestamp_ms > right.timestamp_ms; + }); + if (out.timeline.size() > 200U) + { + out.timeline.resize(200U); + } + + out.hardware = {aio2_status, lora_status, gps_status, storage_status, + map_status}; + out.bottom_status = + "AIO2: " + aio2_status.state + " | LoRa: " + lora_status.state + + " | GPS: " + gps_status.state + " | Node: " + out.self_node + + " | Unread: " + std::to_string(out.unread_count); + + out.capability_lines = { + "Mode: Linux desktop-class handheld", + "AIO2: " + aio2_status.state + " - " + aio2_status.detail, + mesh_line, + "GPS: " + gps_status.state + " - " + gps_status.detail, + "BLE: not used on Linux", + "Storage: SQLite " + + ::platform::linux_runtime::sqlite_database_path().string(), + "Map cache: " + std::to_string(tile_stats.cached_tiles) + + " tiles at " + tile_stats.root.string(), + }; + if (raw_lora_adapter != nullptr) + { + for (const auto& line : raw_lora_adapter->diagnosticLines()) + { + out.capability_lines.push_back("LoRa: " + line); + } + } + + return out; +} + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/src/uconsole_desktop_shell.cpp b/platform/linux/uconsole/src/uconsole_desktop_shell.cpp new file mode 100644 index 00000000..c41aa664 --- /dev/null +++ b/platform/linux/uconsole/src/uconsole_desktop_shell.cpp @@ -0,0 +1,1340 @@ +#include "uconsole/uconsole_desktop_shell.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/input_event.h" +#include "app/linux_app_services.h" +#include "core/canvas.h" +#include "lvgl.h" +#include "uconsole/uconsole_chat_workspace_model.h" +#include "uconsole/uconsole_dashboard_model.h" + +namespace trailmate::uconsole +{ +namespace +{ + +using clock = std::chrono::steady_clock; +using InputEvent = cardputer_zero::app::InputEvent; +using InputKey = cardputer_zero::app::InputKey; +using Canvas = cardputer_zero::core::Canvas; + +constexpr int kConversationRows = 6; +constexpr int kContactRows = 7; +constexpr int kMetricCount = 4; +constexpr int kCapabilityRows = 5; +constexpr int kChatConversationRows = 7; +constexpr int kChatMessageRows = 7; + +constexpr std::array kNavLabels{ + "Overview", + "Chat", + "Contacts", + "Map", + "Team", + "Data", + "Diagnostics", + "Settings", +}; + +std::chrono::steady_clock::time_point g_lvgl_start_time = clock::now(); + +enum class Section : std::uint8_t +{ + Overview = 0, + Chat, + Contacts, + Map, + Team, + Data, + Diagnostics, + Settings, +}; + +struct QueuedKeyEvent +{ + std::uint32_t key = 0; + lv_indev_state_t state = LV_INDEV_STATE_RELEASED; +}; + +[[nodiscard]] std::uint32_t tickNow() noexcept +{ + return static_cast( + std::chrono::duration_cast( + clock::now() - g_lvgl_start_time) + .count()); +} + +[[nodiscard]] std::uint32_t mapInputEvent(const InputEvent& event) noexcept +{ + switch (event.key) + { + case InputKey::Character: + return event.text == '\0' ? 0U : static_cast(event.text); + case InputKey::Backspace: + return LV_KEY_BACKSPACE; + case InputKey::Enter: + return LV_KEY_ENTER; + case InputKey::Tab: + return LV_KEY_NEXT; + case InputKey::Home: + return LV_KEY_ESC; + case InputKey::Next: + return LV_KEY_NEXT; + case InputKey::Power: + return LV_KEY_ESC; + case InputKey::Left: + return LV_KEY_LEFT; + case InputKey::Right: + return LV_KEY_RIGHT; + case InputKey::Up: + return LV_KEY_UP; + case InputKey::Down: + return LV_KEY_DOWN; + case InputKey::Unknown: + case InputKey::Fn: + case InputKey::Ctrl: + case InputKey::Alt: + case InputKey::Shift: + return 0U; + } + return 0U; +} + +[[nodiscard]] std::uint8_t expand5(std::uint16_t value) noexcept +{ + return static_cast((value * 255U) / 31U); +} + +[[nodiscard]] std::uint8_t expand6(std::uint16_t value) noexcept +{ + return static_cast((value * 255U) / 63U); +} + +[[nodiscard]] cardputer_zero::core::Color rgb565ToColor( + std::uint16_t pixel) noexcept +{ + const auto red = static_cast((pixel >> 11U) & 0x1FU); + const auto green = static_cast((pixel >> 5U) & 0x3FU); + const auto blue = static_cast(pixel & 0x1FU); + return cardputer_zero::core::rgba(expand5(red), expand6(green), + expand5(blue)); +} + +[[nodiscard]] lv_color_t color(std::uint32_t hex) noexcept +{ + return lv_color_hex(hex); +} + +void resetBox(lv_obj_t* obj) +{ + lv_obj_remove_style_all(obj); + lv_obj_set_style_shadow_width(obj, 0, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); +} + +void applyPanel(lv_obj_t* obj, std::uint32_t bg, std::uint32_t border = 0xD3D9D2) +{ + resetBox(obj); + lv_obj_set_style_bg_color(obj, color(bg), 0); + lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(obj, color(border), 0); + lv_obj_set_style_border_width(obj, 1, 0); + lv_obj_set_style_radius(obj, 6, 0); + lv_obj_set_style_pad_all(obj, 14, 0); +} + +void applyTransparent(lv_obj_t* obj) +{ + resetBox(obj); + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_all(obj, 0, 0); +} + +lv_obj_t* createLabel(lv_obj_t* parent, + const char* text, + const lv_font_t* font, + std::uint32_t text_color, + lv_label_long_mode_t long_mode = LV_LABEL_LONG_DOT) +{ + lv_obj_t* label = lv_label_create(parent); + lv_obj_set_style_text_color(label, color(text_color), 0); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_pad_all(label, 0, 0); + lv_label_set_long_mode(label, long_mode); + lv_label_set_text(label, text); + return label; +} + +void setLabel(lv_obj_t* label, const std::string& text) +{ + if (label != nullptr) + { + lv_label_set_text(label, text.c_str()); + } +} + +void setLabel(lv_obj_t* label, const char* text) +{ + if (label != nullptr) + { + lv_label_set_text(label, text ? text : ""); + } +} + +[[nodiscard]] const char* sectionTitle(Section section) noexcept +{ + switch (section) + { + case Section::Overview: + return "Operational workspace"; + case Section::Chat: + return "Chat workspace"; + case Section::Contacts: + return "Contacts workspace"; + case Section::Map: + return "Map workspace"; + case Section::Team: + return "Team workspace"; + case Section::Data: + return "Data workspace"; + case Section::Diagnostics: + return "Diagnostics workspace"; + case Section::Settings: + return "Settings workspace"; + } + return "Operational workspace"; +} + +[[nodiscard]] const char* sectionSubtitle(Section section) noexcept +{ + switch (section) + { + case Section::Overview: + return "Live service snapshot for the Linux handheld target."; + case Section::Chat: + return "Conversation list and message activity preview."; + case Section::Contacts: + return "Known nodes, nearby peers and trust status preview."; + case Section::Map: + return "Map canvas slot reserved for Linux package workflows."; + case Section::Team: + return "Roster, pairing and activity slot for team operations."; + case Section::Data: + return "Import/export and local storage jobs will surface here."; + case Section::Diagnostics: + return "Runtime logs, capability state and hardware checks."; + case Section::Settings: + return "Grouped configuration entrypoint for Linux targets."; + } + return ""; +} + +[[nodiscard]] std::string formatCount(std::size_t value) +{ + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%lu", + static_cast(value)); + return buffer; +} + +[[nodiscard]] std::string formatCount(int value) +{ + char buffer[24] = {}; + std::snprintf(buffer, sizeof(buffer), "%d", value); + return buffer; +} + +[[nodiscard]] UConsoleShellOptions validateOptions(UConsoleShellOptions options) +{ + if (options.width <= 0 || options.height <= 0) + { + throw std::runtime_error("uConsole shell dimensions must be positive."); + } + if (options.frame_time_ms <= 0) + { + options.frame_time_ms = 16; + } + return options; +} + +class UConsoleDesktopShell +{ + public: + UConsoleDesktopShell() + : services_(), + dashboard_model_(services_), + chat_model_(services_) + { + } + + ~UConsoleDesktopShell() + { + services_.shutdown(); + } + + bool begin() + { + if (initialized_) return true; + if (!services_.initialize()) return false; + + group_ = lv_group_create(); + if (group_ == nullptr) + { + return false; + } + lv_group_set_default(group_); + + buildUi(); + initialized_ = true; + refreshDashboard(true); + return true; + } + + void releaseLvglObjects() noexcept + { + if (group_ != nullptr) + { + lv_group_del(group_); + group_ = nullptr; + } + } + + void tick() + { + if (!initialized_) return; + + services_.tick(); + const auto now = clock::now(); + if ((now - last_refresh_) >= std::chrono::milliseconds(500)) + { + refreshDashboard(false); + } + } + + void enqueueInputs(const std::vector& events) + { + for (const auto& event : events) + { + const std::uint32_t mapped = mapInputEvent(event); + if (mapped == 0U) continue; + key_events_.push_back({mapped, LV_INDEV_STATE_PRESSED}); + key_events_.push_back({mapped, LV_INDEV_STATE_RELEASED}); + } + } + + [[nodiscard]] lv_group_t* inputGroup() const noexcept + { + return group_; + } + + bool dequeueKeyEvent(std::uint32_t* key, lv_indev_state_t* state) + { + if (key_events_.empty()) return false; + const QueuedKeyEvent event = key_events_.front(); + key_events_.pop_front(); + *key = event.key; + *state = event.state; + return true; + } + + [[nodiscard]] bool hasPendingKeyEvent() const noexcept + { + return !key_events_.empty(); + } + + private: + struct NavBinding + { + UConsoleDesktopShell* shell = nullptr; + Section section = Section::Overview; + }; + + struct ChatConversationBinding + { + UConsoleDesktopShell* shell = nullptr; + std::size_t index = 0; + }; + + static void navEventCb(lv_event_t* event) + { + const lv_event_code_t code = lv_event_get_code(event); + if (code != LV_EVENT_CLICKED && code != LV_EVENT_KEY) + { + return; + } + + if (code == LV_EVENT_KEY) + { + const std::uint32_t key = lv_event_get_key(event); + if (key != LV_KEY_ENTER && key != LV_KEY_RIGHT) + { + return; + } + } + + auto* binding = + static_cast(lv_event_get_user_data(event)); + if (binding == nullptr || binding->shell == nullptr) + { + return; + } + binding->shell->selectSection(binding->section); + } + + static void chatConversationEventCb(lv_event_t* event) + { + const lv_event_code_t code = lv_event_get_code(event); + if (code != LV_EVENT_CLICKED && code != LV_EVENT_KEY) + { + return; + } + + if (code == LV_EVENT_KEY) + { + const std::uint32_t key = lv_event_get_key(event); + if (key != LV_KEY_ENTER && key != LV_KEY_RIGHT) + { + return; + } + } + + auto* binding = + static_cast(lv_event_get_user_data(event)); + if (binding == nullptr || binding->shell == nullptr) + { + return; + } + + if (binding->shell->chat_model_.selectConversationAt( + binding->index, kChatConversationRows)) + { + binding->shell->refreshDashboard(true); + } + } + + static void chatSendEventCb(lv_event_t* event) + { + const lv_event_code_t code = lv_event_get_code(event); + if (code != LV_EVENT_CLICKED && code != LV_EVENT_KEY) + { + return; + } + + if (code == LV_EVENT_KEY) + { + const std::uint32_t key = lv_event_get_key(event); + if (key != LV_KEY_ENTER) + { + return; + } + } + + auto* shell = + static_cast(lv_event_get_user_data(event)); + if (shell == nullptr || shell->chat_input_ == nullptr) + { + return; + } + + const char* text = lv_textarea_get_text(shell->chat_input_); + const bool sent = shell->chat_model_.sendText(text == nullptr ? "" : text); + if (sent) + { + lv_textarea_set_text(shell->chat_input_, ""); + } + shell->refreshDashboard(true); + shell->refreshChatWorkspace(true); + } + + void selectSection(Section section) + { + active_section_ = section; + refreshNavStyles(); + setLabel(workspace_title_, sectionTitle(active_section_)); + setLabel(workspace_subtitle_, sectionSubtitle(active_section_)); + refreshSectionVisibility(); + if (active_section_ == Section::Chat) + { + refreshChatWorkspace(true); + } + } + + void buildUi() + { + lv_obj_t* root = lv_scr_act(); + lv_obj_clean(root); + resetBox(root); + lv_obj_set_style_bg_color(root, color(0xECEFEA), 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + buildTopBar(root); + + lv_obj_t* body = lv_obj_create(root); + applyTransparent(body); + lv_obj_set_width(body, LV_PCT(100)); + lv_obj_set_flex_grow(body, 1); + lv_obj_set_style_pad_all(body, 14, 0); + lv_obj_set_style_pad_column(body, 14, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + buildSidebar(body); + buildWorkspace(body); + buildStatusPanel(body); + + selectSection(Section::Overview); + } + + void buildTopBar(lv_obj_t* root) + { + lv_obj_t* bar = lv_obj_create(root); + resetBox(bar); + lv_obj_set_width(bar, LV_PCT(100)); + lv_obj_set_height(bar, 58); + lv_obj_set_style_bg_color(bar, color(0x202426), 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + lv_obj_set_style_pad_left(bar, 18, 0); + lv_obj_set_style_pad_right(bar, 18, 0); + lv_obj_set_style_pad_top(bar, 8, 0); + lv_obj_set_style_pad_bottom(bar, 8, 0); + lv_obj_set_style_pad_column(bar, 12, 0); + lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + + lv_obj_t* title_wrap = lv_obj_create(bar); + applyTransparent(title_wrap); + lv_obj_set_height(title_wrap, LV_SIZE_CONTENT); + lv_obj_set_flex_grow(title_wrap, 1); + lv_obj_set_flex_flow(title_wrap, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(title_wrap, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + createLabel(title_wrap, "Trail Mate uConsole", &lv_font_montserrat_20, + 0xF7F8F4); + createLabel(title_wrap, "Linux desktop-class target", + &lv_font_montserrat_12, 0xAAB2AF); + + top_mesh_label_ = createChip(bar, "Mesh: -", 0x2F5D62, 0xDDF8F0); + top_node_label_ = createChip(bar, "Node: -", 0x584A1F, 0xFFF1BF); + top_unread_label_ = createChip(bar, "Unread: 0", 0x4F3B4D, 0xF8DDF2); + } + + lv_obj_t* createChip(lv_obj_t* parent, + const char* text, + std::uint32_t bg, + std::uint32_t fg) + { + lv_obj_t* chip = lv_obj_create(parent); + resetBox(chip); + lv_obj_set_size(chip, LV_SIZE_CONTENT, 32); + lv_obj_set_style_bg_color(chip, color(bg), 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); + lv_obj_set_style_radius(chip, 6, 0); + lv_obj_set_style_pad_left(chip, 10, 0); + lv_obj_set_style_pad_right(chip, 10, 0); + lv_obj_t* label = + createLabel(chip, text, &lv_font_montserrat_12, fg); + lv_obj_center(label); + return label; + } + + void buildSidebar(lv_obj_t* parent) + { + sidebar_ = lv_obj_create(parent); + applyPanel(sidebar_, 0x252A29, 0x252A29); + lv_obj_set_width(sidebar_, 220); + lv_obj_set_height(sidebar_, LV_PCT(100)); + lv_obj_set_style_pad_all(sidebar_, 12, 0); + lv_obj_set_style_pad_row(sidebar_, 8, 0); + lv_obj_set_flex_flow(sidebar_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(sidebar_, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + createLabel(sidebar_, "Workspace", &lv_font_montserrat_14, 0xE8EDE8); + + for (std::size_t index = 0; index < kNavLabels.size(); ++index) + { + nav_bindings_[index] = {this, static_cast
(index)}; + nav_buttons_[index] = + createNavButton(sidebar_, kNavLabels[index], + &nav_bindings_[index]); + } + } + + lv_obj_t* createNavButton(lv_obj_t* parent, + const char* label_text, + NavBinding* binding) + { + lv_obj_t* button = lv_btn_create(parent); + resetBox(button); + lv_obj_set_width(button, LV_PCT(100)); + lv_obj_set_height(button, 42); + lv_obj_set_style_radius(button, 6, 0); + lv_obj_set_style_pad_left(button, 10, 0); + lv_obj_set_style_pad_right(button, 10, 0); + lv_obj_set_style_bg_color(button, color(0x303635), 0); + lv_obj_set_style_bg_opa(button, LV_OPA_COVER, 0); + lv_obj_add_event_cb(button, navEventCb, LV_EVENT_CLICKED, binding); + lv_obj_add_event_cb(button, navEventCb, LV_EVENT_KEY, binding); + lv_group_add_obj(group_, button); + + lv_obj_t* label = + createLabel(button, label_text, &lv_font_montserrat_14, 0xD7DED9); + lv_obj_set_width(label, LV_PCT(100)); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, 0); + lv_obj_center(label); + return button; + } + + void buildWorkspace(lv_obj_t* parent) + { + lv_obj_t* workspace = lv_obj_create(parent); + applyTransparent(workspace); + lv_obj_set_height(workspace, LV_PCT(100)); + lv_obj_set_flex_grow(workspace, 1); + lv_obj_set_style_pad_row(workspace, 12, 0); + lv_obj_set_flex_flow(workspace, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(workspace, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + lv_obj_t* header = lv_obj_create(workspace); + applyTransparent(header); + lv_obj_set_width(header, LV_PCT(100)); + lv_obj_set_height(header, 54); + lv_obj_set_flex_flow(header, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(header, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + workspace_title_ = createLabel(header, sectionTitle(active_section_), + &lv_font_montserrat_20, 0x1F2523); + workspace_subtitle_ = + createLabel(header, sectionSubtitle(active_section_), + &lv_font_montserrat_12, 0x61706A); + + metrics_panel_ = lv_obj_create(workspace); + applyTransparent(metrics_panel_); + lv_obj_set_width(metrics_panel_, LV_PCT(100)); + lv_obj_set_height(metrics_panel_, 96); + lv_obj_set_style_pad_column(metrics_panel_, 10, 0); + lv_obj_set_flex_flow(metrics_panel_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(metrics_panel_, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + createMetric(metrics_panel_, 0, "Threads"); + createMetric(metrics_panel_, 1, "Unread"); + createMetric(metrics_panel_, 2, "Contacts"); + createMetric(metrics_panel_, 3, "Nearby"); + + conversation_panel_ = lv_obj_create(workspace); + applyPanel(conversation_panel_, 0xFFFFFF); + lv_obj_set_width(conversation_panel_, LV_PCT(100)); + lv_obj_set_flex_grow(conversation_panel_, 1); + lv_obj_set_style_pad_row(conversation_panel_, 8, 0); + lv_obj_set_flex_flow(conversation_panel_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(conversation_panel_, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + createLabel(conversation_panel_, "Recent conversations", + &lv_font_montserrat_16, 0x24302C); + for (int index = 0; index < kConversationRows; ++index) + { + buildConversationRow(index); + } + + buildChatWorkspace(workspace); + } + + void createMetric(lv_obj_t* parent, int index, const char* title) + { + lv_obj_t* box = lv_obj_create(parent); + applyPanel(box, 0xFFFFFF); + lv_obj_set_height(box, LV_PCT(100)); + lv_obj_set_flex_grow(box, 1); + lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(box, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + createLabel(box, title, &lv_font_montserrat_12, 0x66716E); + metric_value_labels_[index] = + createLabel(box, "0", &lv_font_montserrat_24, 0x1E2B27); + } + + void buildConversationRow(int index) + { + lv_obj_t* row = lv_obj_create(conversation_panel_); + applyTransparent(row); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, 62); + lv_obj_set_style_pad_top(row, 4, 0); + lv_obj_set_style_pad_bottom(row, 4, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + conversation_rows_[index] = row; + conversation_title_labels_[index] = + createLabel(row, "-", &lv_font_montserrat_14, 0x22302B); + lv_obj_set_width(conversation_title_labels_[index], LV_PCT(100)); + conversation_preview_labels_[index] = + createLabel(row, "", &lv_font_montserrat_12, 0x596760); + lv_obj_set_width(conversation_preview_labels_[index], LV_PCT(100)); + conversation_meta_labels_[index] = + createLabel(row, "", &lv_font_montserrat_12, 0x8A6A20); + lv_obj_set_width(conversation_meta_labels_[index], LV_PCT(100)); + } + + void buildChatWorkspace(lv_obj_t* parent) + { + chat_panel_ = lv_obj_create(parent); + applyPanel(chat_panel_, 0xFFFFFF); + lv_obj_set_width(chat_panel_, LV_PCT(100)); + lv_obj_set_flex_grow(chat_panel_, 1); + lv_obj_set_style_pad_column(chat_panel_, 12, 0); + lv_obj_set_flex_flow(chat_panel_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chat_panel_, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + lv_obj_t* conversation_list = lv_obj_create(chat_panel_); + applyTransparent(conversation_list); + lv_obj_set_width(conversation_list, 310); + lv_obj_set_height(conversation_list, LV_PCT(100)); + lv_obj_set_style_pad_row(conversation_list, 8, 0); + lv_obj_set_flex_flow(conversation_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(conversation_list, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + createLabel(conversation_list, "Conversations", &lv_font_montserrat_16, + 0x24302C); + for (int index = 0; index < kChatConversationRows; ++index) + { + buildChatConversationRow(conversation_list, index); + } + + lv_obj_t* thread = lv_obj_create(chat_panel_); + applyTransparent(thread); + lv_obj_set_height(thread, LV_PCT(100)); + lv_obj_set_flex_grow(thread, 1); + lv_obj_set_style_pad_row(thread, 10, 0); + lv_obj_set_flex_flow(thread, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(thread, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + lv_obj_t* thread_header = lv_obj_create(thread); + applyTransparent(thread_header); + lv_obj_set_width(thread_header, LV_PCT(100)); + lv_obj_set_height(thread_header, 48); + lv_obj_set_flex_flow(thread_header, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(thread_header, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + chat_title_label_ = + createLabel(thread_header, "-", &lv_font_montserrat_16, 0x1F2523); + chat_meta_label_ = + createLabel(thread_header, "-", &lv_font_montserrat_12, 0x61706A); + lv_obj_set_width(chat_title_label_, LV_PCT(100)); + lv_obj_set_width(chat_meta_label_, LV_PCT(100)); + + chat_messages_panel_ = lv_obj_create(thread); + applyPanel(chat_messages_panel_, 0xF8FAF6); + lv_obj_set_width(chat_messages_panel_, LV_PCT(100)); + lv_obj_set_flex_grow(chat_messages_panel_, 1); + lv_obj_set_style_pad_row(chat_messages_panel_, 8, 0); + lv_obj_set_flex_flow(chat_messages_panel_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(chat_messages_panel_, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + chat_empty_label_ = + createLabel(chat_messages_panel_, "No messages yet.", + &lv_font_montserrat_14, 0x65716B); + lv_obj_set_width(chat_empty_label_, LV_PCT(100)); + for (int index = 0; index < kChatMessageRows; ++index) + { + buildChatMessageRow(index); + } + + lv_obj_t* input_row = lv_obj_create(thread); + applyTransparent(input_row); + lv_obj_set_width(input_row, LV_PCT(100)); + lv_obj_set_height(input_row, 72); + lv_obj_set_style_pad_column(input_row, 10, 0); + lv_obj_set_flex_flow(input_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(input_row, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + chat_input_ = lv_textarea_create(input_row); + lv_obj_set_height(chat_input_, 54); + lv_obj_set_flex_grow(chat_input_, 1); + lv_textarea_set_one_line(chat_input_, true); + lv_textarea_set_placeholder_text(chat_input_, "Type a message"); + lv_obj_set_style_text_font(chat_input_, &lv_font_montserrat_14, 0); + lv_obj_set_style_radius(chat_input_, 6, 0); + lv_group_add_obj(group_, chat_input_); + + chat_send_button_ = lv_btn_create(input_row); + resetBox(chat_send_button_); + lv_obj_set_size(chat_send_button_, 92, 54); + lv_obj_set_style_radius(chat_send_button_, 6, 0); + lv_obj_set_style_bg_color(chat_send_button_, color(0x2F5D62), 0); + lv_obj_set_style_bg_opa(chat_send_button_, LV_OPA_COVER, 0); + lv_obj_add_event_cb(chat_send_button_, chatSendEventCb, + LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(chat_send_button_, chatSendEventCb, LV_EVENT_KEY, + this); + lv_group_add_obj(group_, chat_send_button_); + lv_obj_t* send_label = + createLabel(chat_send_button_, "Send", &lv_font_montserrat_14, + 0xF7F8F4); + lv_obj_center(send_label); + + chat_status_label_ = + createLabel(thread, "Ready.", &lv_font_montserrat_12, 0x66716E); + lv_obj_set_width(chat_status_label_, LV_PCT(100)); + } + + void buildChatConversationRow(lv_obj_t* parent, int index) + { + chat_conversation_bindings_[index] = {this, + static_cast(index)}; + lv_obj_t* button = lv_btn_create(parent); + resetBox(button); + lv_obj_set_width(button, LV_PCT(100)); + lv_obj_set_height(button, 74); + lv_obj_set_style_radius(button, 6, 0); + lv_obj_set_style_pad_all(button, 8, 0); + lv_obj_set_style_bg_color(button, color(0xF3F6F2), 0); + lv_obj_set_style_bg_opa(button, LV_OPA_COVER, 0); + lv_obj_set_flex_flow(button, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(button, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_add_event_cb(button, chatConversationEventCb, LV_EVENT_CLICKED, + &chat_conversation_bindings_[index]); + lv_obj_add_event_cb(button, chatConversationEventCb, LV_EVENT_KEY, + &chat_conversation_bindings_[index]); + lv_group_add_obj(group_, button); + + chat_conversation_buttons_[index] = button; + chat_conversation_title_labels_[index] = + createLabel(button, "-", &lv_font_montserrat_14, 0x22302B); + chat_conversation_preview_labels_[index] = + createLabel(button, "", &lv_font_montserrat_12, 0x596760); + chat_conversation_meta_labels_[index] = + createLabel(button, "", &lv_font_montserrat_12, 0x8A6A20); + lv_obj_set_width(chat_conversation_title_labels_[index], LV_PCT(100)); + lv_obj_set_width(chat_conversation_preview_labels_[index], LV_PCT(100)); + lv_obj_set_width(chat_conversation_meta_labels_[index], LV_PCT(100)); + } + + void buildChatMessageRow(int index) + { + lv_obj_t* row = lv_obj_create(chat_messages_panel_); + applyPanel(row, 0xFFFFFF); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, 72); + lv_obj_set_style_pad_all(row, 8, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + chat_message_rows_[index] = row; + chat_message_sender_labels_[index] = + createLabel(row, "-", &lv_font_montserrat_12, 0x34413D); + chat_message_text_labels_[index] = + createLabel(row, "", &lv_font_montserrat_14, 0x17211E, + LV_LABEL_LONG_WRAP); + chat_message_meta_labels_[index] = + createLabel(row, "", &lv_font_montserrat_12, 0x66716E); + lv_obj_set_width(chat_message_sender_labels_[index], LV_PCT(100)); + lv_obj_set_width(chat_message_text_labels_[index], LV_PCT(100)); + lv_obj_set_width(chat_message_meta_labels_[index], LV_PCT(100)); + } + + void buildStatusPanel(lv_obj_t* parent) + { + lv_obj_t* panel = lv_obj_create(parent); + applyPanel(panel, 0xFFFFFF); + lv_obj_set_width(panel, 330); + lv_obj_set_height(panel, LV_PCT(100)); + lv_obj_set_style_pad_row(panel, 12, 0); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + createLabel(panel, "Runtime and capabilities", &lv_font_montserrat_16, + 0x24302C); + for (int index = 0; index < kCapabilityRows; ++index) + { + capability_labels_[index] = + createLabel(panel, "-", &lv_font_montserrat_12, 0x4F5C57, + LV_LABEL_LONG_WRAP); + lv_obj_set_width(capability_labels_[index], LV_PCT(100)); + } + + lv_obj_t* divider = lv_obj_create(panel); + resetBox(divider); + lv_obj_set_width(divider, LV_PCT(100)); + lv_obj_set_height(divider, 1); + lv_obj_set_style_bg_color(divider, color(0xD9DEDA), 0); + lv_obj_set_style_bg_opa(divider, LV_OPA_COVER, 0); + + createLabel(panel, "Contacts and nearby nodes", &lv_font_montserrat_16, + 0x24302C); + for (int index = 0; index < kContactRows; ++index) + { + buildContactRow(panel, index); + } + } + + void buildContactRow(lv_obj_t* parent, int index) + { + lv_obj_t* row = lv_obj_create(parent); + applyTransparent(row); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, 48); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + contact_rows_[index] = row; + contact_name_labels_[index] = + createLabel(row, "-", &lv_font_montserrat_14, 0x22302B); + lv_obj_set_width(contact_name_labels_[index], LV_PCT(100)); + contact_meta_labels_[index] = + createLabel(row, "", &lv_font_montserrat_12, 0x66716E); + lv_obj_set_width(contact_meta_labels_[index], LV_PCT(100)); + } + + void refreshNavStyles() + { + const auto active_index = static_cast(active_section_); + for (std::size_t index = 0; index < nav_buttons_.size(); ++index) + { + lv_obj_t* button = nav_buttons_[index]; + if (button == nullptr) continue; + const bool active = index == active_index; + lv_obj_set_style_bg_color(button, + color(active ? 0xD7E8DF : 0x303635), 0); + lv_obj_set_style_border_width(button, active ? 1 : 0, 0); + lv_obj_set_style_border_color(button, color(0x7EA48F), 0); + lv_obj_t* label = lv_obj_get_child(button, 0); + if (label != nullptr) + { + lv_obj_set_style_text_color( + label, color(active ? 0x15251F : 0xD7DED9), 0); + } + } + } + + void refreshSectionVisibility() + { + const bool chat_active = active_section_ == Section::Chat; + if (metrics_panel_ != nullptr) + { + if (chat_active) + lv_obj_add_flag(metrics_panel_, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_clear_flag(metrics_panel_, LV_OBJ_FLAG_HIDDEN); + } + if (conversation_panel_ != nullptr) + { + if (chat_active) + lv_obj_add_flag(conversation_panel_, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_clear_flag(conversation_panel_, LV_OBJ_FLAG_HIDDEN); + } + if (chat_panel_ != nullptr) + { + if (chat_active) + lv_obj_clear_flag(chat_panel_, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(chat_panel_, LV_OBJ_FLAG_HIDDEN); + } + } + + void refreshDashboard(bool force) + { + const auto now = clock::now(); + if (!force && (now - last_refresh_) < std::chrono::milliseconds(500)) + { + return; + } + last_refresh_ = now; + + const UConsoleDashboardSnapshot snapshot = dashboard_model_.snapshot(); + + setLabel(top_mesh_label_, "Mesh: " + snapshot.mesh_protocol); + setLabel(top_node_label_, "Node: " + snapshot.self_node); + setLabel(top_unread_label_, "Unread: " + formatCount(snapshot.unread_count)); + + setLabel(metric_value_labels_[0], + formatCount(snapshot.conversation_count)); + setLabel(metric_value_labels_[1], formatCount(snapshot.unread_count)); + setLabel(metric_value_labels_[2], formatCount(snapshot.contact_count)); + setLabel(metric_value_labels_[3], formatCount(snapshot.nearby_count)); + + for (int index = 0; index < kConversationRows; ++index) + { + const bool visible = + index < static_cast(snapshot.conversations.size()); + if (visible) + { + lv_obj_clear_flag(conversation_rows_[index], + LV_OBJ_FLAG_HIDDEN); + const auto& item = snapshot.conversations[index]; + setLabel(conversation_title_labels_[index], item.title); + setLabel(conversation_preview_labels_[index], item.preview); + setLabel(conversation_meta_labels_[index], item.meta); + lv_obj_set_style_text_color( + conversation_meta_labels_[index], + color(item.unread > 0 ? 0xA06316 : 0x78827D), 0); + } + else + { + lv_obj_add_flag(conversation_rows_[index], + LV_OBJ_FLAG_HIDDEN); + } + } + + for (int index = 0; index < kCapabilityRows; ++index) + { + if (index < static_cast(snapshot.capability_lines.size())) + { + setLabel(capability_labels_[index], + snapshot.capability_lines[index]); + } + else + { + setLabel(capability_labels_[index], ""); + } + } + + for (int index = 0; index < kContactRows; ++index) + { + const bool visible = index < static_cast(snapshot.contacts.size()); + if (visible) + { + lv_obj_clear_flag(contact_rows_[index], LV_OBJ_FLAG_HIDDEN); + const auto& contact = snapshot.contacts[index]; + setLabel(contact_name_labels_[index], contact.name); + setLabel(contact_meta_labels_[index], + contact.node_id + " / " + contact.protocol + " / " + + contact.status); + } + else + { + lv_obj_add_flag(contact_rows_[index], LV_OBJ_FLAG_HIDDEN); + } + } + + refreshChatWorkspace(force); + } + + void refreshChatWorkspace(bool force) + { + if (chat_panel_ == nullptr) return; + if (!force && active_section_ != Section::Chat) return; + + const ChatWorkspaceSnapshot snapshot = + chat_model_.snapshot(kChatConversationRows, kChatMessageRows); + + setLabel(chat_title_label_, snapshot.active_title); + setLabel(chat_meta_label_, snapshot.active_meta); + setLabel(chat_status_label_, + snapshot.action_status.empty() ? "Ready." + : snapshot.action_status); + + for (int index = 0; index < kChatConversationRows; ++index) + { + const bool visible = + index < static_cast(snapshot.conversations.size()); + if (!visible) + { + lv_obj_add_flag(chat_conversation_buttons_[index], + LV_OBJ_FLAG_HIDDEN); + continue; + } + + lv_obj_clear_flag(chat_conversation_buttons_[index], + LV_OBJ_FLAG_HIDDEN); + const auto& item = snapshot.conversations[index]; + setLabel(chat_conversation_title_labels_[index], item.title); + setLabel(chat_conversation_preview_labels_[index], item.preview); + setLabel(chat_conversation_meta_labels_[index], item.meta); + lv_obj_set_style_bg_color( + chat_conversation_buttons_[index], + color(item.active ? 0xD7E8DF : 0xF3F6F2), 0); + lv_obj_set_style_border_width(chat_conversation_buttons_[index], + item.active ? 1 : 0, 0); + lv_obj_set_style_border_color(chat_conversation_buttons_[index], + color(0x7EA48F), 0); + lv_obj_set_style_text_color( + chat_conversation_meta_labels_[index], + color(item.unread > 0 ? 0xA06316 : 0x66716E), 0); + } + + const bool has_messages = !snapshot.messages.empty(); + if (chat_empty_label_ != nullptr) + { + if (has_messages) + lv_obj_add_flag(chat_empty_label_, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_clear_flag(chat_empty_label_, LV_OBJ_FLAG_HIDDEN); + } + + for (int index = 0; index < kChatMessageRows; ++index) + { + const bool visible = + index < static_cast(snapshot.messages.size()); + if (!visible) + { + lv_obj_add_flag(chat_message_rows_[index], LV_OBJ_FLAG_HIDDEN); + continue; + } + + lv_obj_clear_flag(chat_message_rows_[index], LV_OBJ_FLAG_HIDDEN); + const auto& item = snapshot.messages[index]; + setLabel(chat_message_sender_labels_[index], item.sender); + setLabel(chat_message_text_labels_[index], item.text); + setLabel(chat_message_meta_labels_[index], item.meta); + lv_obj_set_style_bg_color( + chat_message_rows_[index], + color(item.failed ? 0xFFF0EE + : (item.outgoing ? 0xEEF8F3 : 0xFFFFFF)), + 0); + lv_obj_set_style_text_color( + chat_message_meta_labels_[index], + color(item.failed ? 0xA23B30 : 0x66716E), 0); + } + } + + linux_app::LinuxAppServices services_; + UConsoleDashboardModel dashboard_model_; + UConsoleChatWorkspaceModel chat_model_; + bool initialized_ = false; + Section active_section_ = Section::Overview; + lv_group_t* group_ = nullptr; + std::deque key_events_{}; + clock::time_point last_refresh_{}; + + lv_obj_t* sidebar_ = nullptr; + lv_obj_t* workspace_title_ = nullptr; + lv_obj_t* workspace_subtitle_ = nullptr; + lv_obj_t* top_mesh_label_ = nullptr; + lv_obj_t* top_node_label_ = nullptr; + lv_obj_t* top_unread_label_ = nullptr; + lv_obj_t* metrics_panel_ = nullptr; + lv_obj_t* conversation_panel_ = nullptr; + lv_obj_t* chat_panel_ = nullptr; + lv_obj_t* chat_messages_panel_ = nullptr; + lv_obj_t* chat_title_label_ = nullptr; + lv_obj_t* chat_meta_label_ = nullptr; + lv_obj_t* chat_empty_label_ = nullptr; + lv_obj_t* chat_input_ = nullptr; + lv_obj_t* chat_send_button_ = nullptr; + lv_obj_t* chat_status_label_ = nullptr; + + std::array nav_bindings_{}; + std::array nav_buttons_{}; + std::array metric_value_labels_{}; + std::array conversation_rows_{}; + std::array conversation_title_labels_{}; + std::array conversation_preview_labels_{}; + std::array conversation_meta_labels_{}; + std::array capability_labels_{}; + std::array contact_rows_{}; + std::array contact_name_labels_{}; + std::array contact_meta_labels_{}; + std::array + chat_conversation_bindings_{}; + std::array chat_conversation_buttons_{}; + std::array + chat_conversation_title_labels_{}; + std::array + chat_conversation_preview_labels_{}; + std::array + chat_conversation_meta_labels_{}; + std::array chat_message_rows_{}; + std::array chat_message_sender_labels_{}; + std::array chat_message_text_labels_{}; + std::array chat_message_meta_labels_{}; +}; + +class UConsoleLvglHost +{ + public: + UConsoleLvglHost(UConsoleDesktopShell& shell, UConsoleShellOptions options) + : shell_(shell), + options_(validateOptions(options)), + canvas_(options_.width, options_.height), + frame_buffer_(static_cast(options_.width) * + static_cast(options_.height), + 0) + { + g_lvgl_start_time = clock::now(); + lv_init(); + lv_tick_set_cb(tickNow); + + display_ = lv_display_create(options_.width, options_.height); + if (display_ == nullptr) + { + throw std::runtime_error( + "Failed to create LVGL display for uConsole shell."); + } + lv_display_set_default(display_); + lv_display_set_user_data(display_, this); + lv_display_set_color_format(display_, LV_COLOR_FORMAT_RGB565); + lv_display_set_buffers( + display_, frame_buffer_.data(), nullptr, + static_cast(frame_buffer_.size() * + sizeof(std::uint16_t)), + LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_flush_cb(display_, flushCb); + + if (!shell_.begin()) + { + throw std::runtime_error("Failed to begin uConsole desktop shell."); + } + + keypad_ = lv_indev_create(); + if (keypad_ == nullptr) + { + throw std::runtime_error( + "Failed to create LVGL keypad input for uConsole shell."); + } + lv_indev_set_type(keypad_, LV_INDEV_TYPE_KEYPAD); + lv_indev_set_display(keypad_, display_); + lv_indev_set_user_data(keypad_, this); + lv_indev_set_read_cb(keypad_, readInputCb); + if (shell_.inputGroup() != nullptr) + { + lv_indev_set_group(keypad_, shell_.inputGroup()); + } + + tick(); + } + + ~UConsoleLvglHost() + { + if (keypad_ != nullptr) + { + lv_indev_delete(keypad_); + keypad_ = nullptr; + } + shell_.releaseLvglObjects(); + if (display_ != nullptr) + { + lv_display_delete(display_); + display_ = nullptr; + } + lv_deinit(); + } + + void tick() + { + shell_.tick(); + lv_timer_handler(); + if (dirty_) + { + copyFrameBufferToCanvas(); + dirty_ = false; + } + } + + [[nodiscard]] const Canvas& canvas() const noexcept + { + return canvas_; + } + + static void flushCb(lv_display_t* display, + const lv_area_t* /*area*/, + std::uint8_t* /*px_map*/) + { + auto* host = + static_cast(lv_display_get_user_data(display)); + if (host != nullptr) host->dirty_ = true; + lv_display_flush_ready(display); + } + + static void readInputCb(lv_indev_t* indev, lv_indev_data_t* data) + { + auto* host = + static_cast(lv_indev_get_user_data(indev)); + if (host == nullptr || data == nullptr) return; + + data->state = LV_INDEV_STATE_RELEASED; + data->key = 0U; + + std::uint32_t key = 0U; + lv_indev_state_t state = LV_INDEV_STATE_RELEASED; + if (!host->shell_.dequeueKeyEvent(&key, &state)) return; + + data->state = state; + data->key = key; + data->continue_reading = host->shell_.hasPendingKeyEvent(); + } + + private: + void copyFrameBufferToCanvas() + { + for (int y = 0; y < options_.height; ++y) + { + for (int x = 0; x < options_.width; ++x) + { + const auto index = + static_cast((y * options_.width) + x); + canvas_.setPixel(x, y, rgb565ToColor(frame_buffer_[index])); + } + } + } + + UConsoleDesktopShell& shell_; + UConsoleShellOptions options_{}; + lv_display_t* display_ = nullptr; + lv_indev_t* keypad_ = nullptr; + Canvas canvas_; + std::vector frame_buffer_{}; + bool dirty_ = true; +}; + +} // namespace + +void runUConsoleShell(::trailmate::cardputer_zero::platform::SurfacePresenter& presenter, + UConsoleShellOptions options) +{ + options = validateOptions(options); + UConsoleDesktopShell shell; + UConsoleLvglHost host{shell, options}; + + auto next_frame = clock::now(); + const auto frame_time = std::chrono::milliseconds(options.frame_time_ms); + + while (presenter.pump()) + { + shell.enqueueInputs(presenter.drainInput()); + host.tick(); + presenter.present(host.canvas()); + + next_frame += frame_time; + std::this_thread::sleep_until(next_frame); + + if (clock::now() > next_frame + std::chrono::milliseconds(250)) + { + next_frame = clock::now(); + } + } +} + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/src/uconsole_hardware_probe.cpp b/platform/linux/uconsole/src/uconsole_hardware_probe.cpp new file mode 100644 index 00000000..7b04a436 --- /dev/null +++ b/platform/linux/uconsole/src/uconsole_hardware_probe.cpp @@ -0,0 +1,186 @@ +#include "uconsole/uconsole_hardware_probe.h" + +#include +#include +#include +#include +#include +#include + +namespace trailmate::uconsole +{ +namespace +{ + +namespace fs = std::filesystem; + +[[nodiscard]] bool containsToken(const std::string& text, + const char* token) noexcept +{ + return text.find(token) != std::string::npos; +} + +[[nodiscard]] std::string pathString(const fs::path& path) +{ + return path.string(); +} + +[[nodiscard]] bool existingPath(const fs::path& path) +{ + std::error_code ec; + return fs::exists(path, ec) && !ec; +} + +[[nodiscard]] std::vector directoryEntries(const fs::path& dir) +{ + std::vector out{}; + std::error_code ec; + if (!fs::exists(dir, ec) || ec) + { + return out; + } + + for (const auto& entry : fs::directory_iterator(dir, ec)) + { + if (ec) + { + break; + } + out.push_back(entry.path()); + } + std::sort(out.begin(), out.end()); + return out; +} + +[[nodiscard]] fs::path resolvedDevicePath(const fs::path& path) +{ + std::error_code ec; + fs::path resolved = fs::canonical(path, ec); + if (ec) + { + return path; + } + return resolved; +} + +[[nodiscard]] bool findClockworkPiSerial(fs::path& out_path) +{ + for (const auto& path : directoryEntries("/dev/serial/by-id")) + { + const std::string name = path.filename().string(); + if (containsToken(name, "ClockworkPI") && + containsToken(name, "uConsole")) + { + out_path = path; + return true; + } + } + + return false; +} + +[[nodiscard]] bool findSpiDevice(fs::path& out_path) +{ + if (const char* configured = std::getenv("TRAIL_MATE_LORA_SPI"); + configured != nullptr && configured[0] != '\0' && + existingPath(configured)) + { + out_path = configured; + return true; + } + + if (existingPath("/dev/spidev1.0")) + { + out_path = "/dev/spidev1.0"; + return true; + } + return false; +} + +[[nodiscard]] std::string summarizeI2c() +{ + std::vector names{}; + for (const auto& path : directoryEntries("/dev")) + { + const std::string name = path.filename().string(); + if (name.rfind("i2c-", 0) == 0) + { + names.push_back("/dev/" + name); + } + } + + if (names.empty()) + { + return {}; + } + + std::ostringstream out; + for (std::size_t index = 0; index < names.size(); ++index) + { + if (index != 0) + { + out << ", "; + } + out << names[index]; + } + return out.str(); +} + +} // namespace + +bool uconsoleAutoGpsSerialPath(std::string& out_path) +{ + fs::path path{}; + if (!findClockworkPiSerial(path)) + { + out_path.clear(); + return false; + } + out_path = pathString(path); + return true; +} + +UConsoleHardwareProbe probeUConsoleHardware() +{ + UConsoleHardwareProbe out{}; + + fs::path serial_path{}; + if (findClockworkPiSerial(serial_path)) + { + out.aio2_detected = true; + out.gps_serial_detected = true; + out.aio2_serial_path = pathString(resolvedDevicePath(serial_path)); + out.gps_serial_path = pathString(serial_path); + } + + fs::path spi_path{}; + if (findSpiDevice(spi_path)) + { + out.aio2_detected = true; + out.lora_spi_detected = true; + out.lora_spi_path = pathString(spi_path); + } + + out.i2c_summary = summarizeI2c(); + out.i2c_detected = !out.i2c_summary.empty(); + + std::ostringstream summary; + summary << (out.aio2_detected ? "AIO2 endpoints present" + : "No AIO2 endpoint detected"); + if (!out.aio2_serial_path.empty()) + { + summary << " / serial " << out.aio2_serial_path; + } + if (!out.lora_spi_path.empty()) + { + summary << " / SPI " << out.lora_spi_path; + } + if (out.i2c_detected) + { + summary << " / I2C " << out.i2c_summary; + } + out.summary = summary.str(); + return out; +} + +} // namespace trailmate::uconsole diff --git a/platform/linux/uconsole/src/uconsole_map_workspace_model.cpp b/platform/linux/uconsole/src/uconsole_map_workspace_model.cpp new file mode 100644 index 00000000..d25d6e8a --- /dev/null +++ b/platform/linux/uconsole/src/uconsole_map_workspace_model.cpp @@ -0,0 +1,724 @@ +#include "uconsole/uconsole_map_workspace_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/linux_app_services.h" +#include "chat/usecase/contact_service.h" +#include "platform/linux/env_config.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/settings_store.h" +#include "uconsole/uconsole_hardware_probe.h" + +namespace trailmate::uconsole +{ +namespace +{ + +constexpr const char* kMapNamespace = "uconsole_map"; +constexpr const char* kZoomKey = "zoom"; +constexpr const char* kShowMqttNodesKey = "show_mqtt_nodes"; +constexpr const char* kContourUltraFineKey = "contour_ultra_fine"; +constexpr const char* kEarthdataTokenKey = "earthdata_token"; +constexpr const char* kManualCenterActiveKey = "manual_center_active"; +constexpr const char* kManualCenterLatE7Key = "manual_center_lat_e7"; +constexpr const char* kManualCenterLonE7Key = "manual_center_lon_e7"; +constexpr int kMinZoom = 1; +constexpr int kMaxZoom = 18; +constexpr int kDefaultWorldZoom = 2; +constexpr double kDefaultWorldLat = 0.0; +constexpr double kDefaultWorldLon = 0.0; +constexpr int kLandscapeTileRadiusX = 2; +constexpr int kLandscapeTileRadiusY = 1; +constexpr int kTileSizePx = 256; +constexpr double kPi = 3.14159265358979323846; + +bool parse_env_double(const char* name, double& out) +{ + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') + { + return false; + } + + char* end = nullptr; + const double parsed = std::strtod(value, &end); + if (end == value || (end != nullptr && *end != '\0') || + !std::isfinite(parsed)) + { + return false; + } + + out = parsed; + return true; +} + +bool external_gps_source_configured() +{ + const char* names[] = { + "TRAIL_MATE_GPS_DEVICE", + "TRAIL_MATE_GPS_NMEA_FILE", + "TRAIL_MATE_GPS_VALID", + "TRAIL_MATE_GPS_LAT", + "TRAIL_MATE_GPS_LNG", + }; + + for (const char* name : names) + { + const char* value = std::getenv(name); + if (value != nullptr && value[0] != '\0') + { + return true; + } + } + std::string auto_path{}; + return uconsoleAutoGpsSerialPath(auto_path); +} + +bool configured_map_center(double& lat, double& lon) +{ + return parse_env_double("TRAIL_MATE_MAP_LAT", lat) && + parse_env_double("TRAIL_MATE_MAP_LNG", lon); +} + +double clamp_web_mercator_lat(double lat) +{ + return std::clamp(lat, -85.05112878, 85.05112878); +} + +double longitude_to_world_px(double lon, int zoom) +{ + const double tiles = static_cast(1U << zoom); + return ((lon + 180.0) / 360.0) * tiles * kTileSizePx; +} + +double latitude_to_world_px(double lat, int zoom) +{ + const double clamped_lat = clamp_web_mercator_lat(lat); + const double lat_rad = clamped_lat * kPi / 180.0; + const double tiles = static_cast(1U << zoom); + const double mercator = + std::log(std::tan(lat_rad) + (1.0 / std::cos(lat_rad))); + return ((1.0 - mercator / kPi) / 2.0) * tiles * kTileSizePx; +} + +double world_px_to_longitude(double x, int zoom) +{ + const double world_px = + static_cast(1U << zoom) * static_cast(kTileSizePx); + double wrapped = std::fmod(x, world_px); + if (wrapped < 0.0) + { + wrapped += world_px; + } + return (wrapped / world_px) * 360.0 - 180.0; +} + +double world_px_to_latitude(double y, int zoom) +{ + const double world_px = + static_cast(1U << zoom) * static_cast(kTileSizePx); + const double clamped = std::clamp(y, 0.0, world_px); + const double n = kPi - (2.0 * kPi * clamped / world_px); + return std::atan(std::sinh(n)) * 180.0 / kPi; +} + +int coordinate_to_e7(double value) +{ + return static_cast(std::lround(value * 10000000.0)); +} + +double e7_to_coordinate(int value) +{ + return static_cast(value) / 10000000.0; +} + +std::string trim_copy(std::string value) +{ + auto not_space = [](unsigned char ch) + { + return std::isspace(ch) == 0; + }; + value.erase(value.begin(), + std::find_if(value.begin(), value.end(), not_space)); + value.erase(std::find_if(value.rbegin(), value.rend(), not_space).base(), + value.end()); + return value; +} + +std::string node_label(const ::chat::contacts::NodeInfo& node) +{ + if (!node.display_name.empty()) + { + return node.display_name; + } + if (node.short_name[0] != '\0') + { + return std::string(node.short_name); + } + + char label[12] = {}; + std::snprintf(label, sizeof(label), "%04X", + static_cast(node.node_id & 0xFFFFU)); + return std::string(label); +} + +void append_projected_node(MapWorkspaceSnapshot& out, + const ::chat::contacts::NodeInfo& node, + const ::platform::linux_runtime::MapTileId& top_left) +{ + if (!node.position.valid) + { + return; + } + + const double lat = + static_cast(node.position.latitude_i) / 10000000.0; + const double lon = + static_cast(node.position.longitude_i) / 10000000.0; + if (!std::isfinite(lat) || !std::isfinite(lon)) + { + return; + } + + if (node.via_mqtt && !out.show_mqtt_nodes) + { + ++out.hidden_mqtt_node_count; + return; + } + + const double map_width_px = + static_cast(std::max(1U, out.columns)) * + static_cast(kTileSizePx); + const double map_height_px = + static_cast(std::max(1U, out.rows)) * + static_cast(kTileSizePx); + const double world_width_px = + static_cast(1U << out.zoom) * static_cast(kTileSizePx); + const double left_px = + static_cast(top_left.x) * static_cast(kTileSizePx); + const double top_px = + static_cast(top_left.y) * static_cast(kTileSizePx); + + double x = longitude_to_world_px(lon, out.zoom) - left_px; + if (x < 0.0) + { + x += world_width_px; + } + if (x > map_width_px && (x - world_width_px) >= 0.0) + { + x -= world_width_px; + } + const double y = latitude_to_world_px(lat, out.zoom) - top_px; + + if (x < 0.0 || x > map_width_px || y < 0.0 || y > map_height_px) + { + return; + } + + MapNodeOverlayItem item{}; + item.node_id = node.node_id; + item.label = node_label(node); + item.lat = lat; + item.lon = lon; + item.x_fraction = map_width_px > 0.0 ? x / map_width_px : 0.0; + item.y_fraction = map_height_px > 0.0 ? y / map_height_px : 0.0; + item.via_mqtt = node.via_mqtt; + item.is_contact = node.is_contact; + item.last_seen = node.last_seen; + item.rssi = node.rssi; + item.snr = node.snr; + item.hops_away = node.hops_away; + item.channel = node.channel; + item.has_altitude = node.position.has_altitude; + item.altitude_m = node.position.altitude; + out.nodes.push_back(std::move(item)); + ++out.visible_node_count; + if (node.via_mqtt) + { + ++out.visible_mqtt_node_count; + } +} + +} // namespace + +UConsoleMapWorkspaceModel::UConsoleMapWorkspaceModel( + linux_app::LinuxAppServices& services) + : services_(services), + zoom_(std::clamp( + platform::ui::settings_store::get_int(kMapNamespace, kZoomKey, 14), + kMinZoom, + kMaxZoom)), + manual_center_active_(platform::ui::settings_store::get_bool( + kMapNamespace, kManualCenterActiveKey, false)), + manual_center_lat_(e7_to_coordinate(platform::ui::settings_store::get_int( + kMapNamespace, kManualCenterLatE7Key, 0))), + manual_center_lon_(e7_to_coordinate(platform::ui::settings_store::get_int( + kMapNamespace, kManualCenterLonE7Key, 0))) +{ +} + +MapWorkspaceSnapshot UConsoleMapWorkspaceModel::snapshot() const +{ + MapWorkspaceSnapshot out{}; + out.zoom = zoom_; + out.source_label = + ::platform::linux_runtime::map_base_source_label(source()); + out.cache_stats = tile_cache_.stats(); + out.show_mqtt_nodes = showMqttNodes(); + out.contour_enabled = contourEnabled(); + out.contour_ultra_fine_enabled = contourUltraFineEnabled(); + out.earthdata_token_configured = earthdataTokenConfigured(); + + double configured_lat = 0.0; + double configured_lon = 0.0; + const bool has_configured_center = + configured_map_center(configured_lat, configured_lon); + if (manual_center_active_) + { + out.has_center = true; + out.has_fix = false; + out.has_manual_center = true; + out.lat = manual_center_lat_; + out.lon = manual_center_lon_; + out.fix_label = "Panned map center"; + } + else if (has_configured_center) + { + out.has_center = true; + out.has_fix = false; + out.has_configured_center = true; + out.lat = configured_lat; + out.lon = configured_lon; + out.fix_label = "Configured center"; + } + else + { + const bool has_external_source = external_gps_source_configured(); + const auto gps = ::platform::ui::gps::get_data(); + out.has_fix = has_external_source && gps.valid; + out.has_center = out.has_fix; + out.lat = gps.lat; + out.lon = gps.lng; + out.altitude_m = gps.alt_m; + out.has_altitude = gps.has_alt; + out.speed_mps = gps.speed_mps; + out.has_speed = gps.has_speed; + out.satellites = gps.satellites; + out.fix_label = out.has_fix ? "GPS fix" : "Default map center"; + if (!out.has_center) + { + out.has_center = true; + out.using_default_center = true; + out.zoom = kDefaultWorldZoom; + out.lat = kDefaultWorldLat; + out.lon = kDefaultWorldLon; + out.altitude_m = 0.0; + out.has_altitude = false; + out.speed_mps = 0.0; + out.has_speed = false; + out.fix_label = + has_external_source ? "GPS waiting; OSM world view" + : "OSM world view"; + } + } + + if (!out.has_center) + { + return out; + } + + out.columns = static_cast(kLandscapeTileRadiusX * 2 + 1); + out.rows = static_cast(kLandscapeTileRadiusY * 2 + 1); + out.center_tile_index = + static_cast(kLandscapeTileRadiusY) * out.columns + + static_cast(kLandscapeTileRadiusX); + + const auto ids = ::platform::linux_runtime::map_tiles_around( + out.lat, + out.lon, + out.zoom, + source(), + kLandscapeTileRadiusX, + kLandscapeTileRadiusY); + out.tiles.reserve(ids.size()); + for (const auto& id : ids) + { + MapTileItem item{}; + item.id = id; + item.path = tile_cache_.tile_path(id); + item.available = tile_cache_.tile_available(id); + out.tiles.push_back(std::move(item)); + } + + if (out.contour_enabled) + { + const auto profiles = + ::platform::linux_runtime::contour_profiles_for_zoom( + out.zoom, out.contour_ultra_fine_enabled); + out.contour_profiles.reserve(profiles.size()); + out.contour_tiles.reserve(out.tiles.size() * profiles.size()); + + for (const auto& profile : profiles) + { + out.contour_profiles.push_back( + ::platform::linux_runtime::map_contour_profile_key(profile)); + } + + for (std::size_t index = 0; index < out.tiles.size(); ++index) + { + const auto& base_id = out.tiles[index].id; + for (const auto& profile : profiles) + { + ::platform::linux_runtime::MapContourTileId id{}; + id.profile = profile; + id.z = base_id.z; + id.x = base_id.x; + id.y = base_id.y; + + MapContourTileItem item{}; + item.id = id; + item.path = contour_store_.existing_tile_path(id); + item.base_tile_index = index; + item.available = contour_store_.tile_available(id); + if (item.available) + { + ++out.contour_available_count; + } + else + { + ++out.contour_missing_count; + } + out.contour_tiles.push_back(std::move(item)); + } + } + } + + if (!out.tiles.empty()) + { + const auto top_left = out.tiles.front().id; + const auto contacts = services_.contacts().getContacts(); + const auto nearby = services_.contacts().getNearby(); + out.nodes.reserve(contacts.size() + nearby.size()); + for (const auto& node : contacts) + { + append_projected_node(out, node, top_left); + } + for (const auto& node : nearby) + { + append_projected_node(out, node, top_left); + } + } + + return out; +} + +MapWorkspaceSnapshot UConsoleMapWorkspaceModel::snapshotAround( + double lat, + double lon, + int zoom, + int radius_x, + int radius_y) const +{ + MapWorkspaceSnapshot out{}; + if (!std::isfinite(lat) || !std::isfinite(lon)) + { + return out; + } + + const int clamped_radius_x = std::clamp(radius_x, 0, 4); + const int clamped_radius_y = std::clamp(radius_y, 0, 4); + out.has_center = true; + out.has_configured_center = true; + out.lat = clamp_web_mercator_lat(lat); + out.lon = std::clamp(lon, -180.0, 180.0); + out.zoom = std::clamp(zoom, kMinZoom, kMaxZoom); + out.source_label = + ::platform::linux_runtime::map_base_source_label(source()); + out.fix_label = "NodeInfo"; + out.columns = static_cast(clamped_radius_x * 2 + 1); + out.rows = static_cast(clamped_radius_y * 2 + 1); + out.center_tile_index = + static_cast(clamped_radius_y) * out.columns + + static_cast(clamped_radius_x); + out.tiles.reserve(out.columns * out.rows); + + const auto tiles = ::platform::linux_runtime::map_tiles_around( + out.lat, + out.lon, + out.zoom, + source(), + clamped_radius_x, + clamped_radius_y); + for (const auto& tile : tiles) + { + MapTileItem item{}; + item.id = tile; + item.path = tile_cache_.tile_path(tile); + item.available = tile_cache_.tile_available(tile); + out.tiles.push_back(std::move(item)); + } + out.cache_stats = tile_cache_.stats(); + return out; +} + +::platform::linux_runtime::MapTileResult +UConsoleMapWorkspaceModel::ensureTile( + const ::platform::linux_runtime::MapTileId& tile) const +{ + return tile_cache_.ensure_tile(tile); +} + +::platform::linux_runtime::MapContourGenerationResult +UConsoleMapWorkspaceModel::ensureContourTiles( + const std::vector<::platform::linux_runtime::MapContourTileId>& tiles) const +{ + return contour_generator_.ensure_tiles(tiles, earthdataToken()); +} + +void UConsoleMapWorkspaceModel::setSource( + ::platform::linux_runtime::MapBaseSource source_value) +{ + services_.config().map_source = static_cast(source_value); + services_.saveConfig(); +} + +void UConsoleMapWorkspaceModel::setZoom(int zoom) +{ + zoom_ = std::clamp(zoom, kMinZoom, kMaxZoom); + persistZoom(); +} + +void UConsoleMapWorkspaceModel::setShowMqttNodes(bool enabled) +{ + ::platform::ui::settings_store::put_bool( + kMapNamespace, kShowMqttNodesKey, enabled); +} + +void UConsoleMapWorkspaceModel::setContourEnabled(bool enabled) +{ + services_.config().map_contour_enabled = enabled; + services_.saveConfig(); +} + +void UConsoleMapWorkspaceModel::setContourUltraFineEnabled(bool enabled) +{ + ::platform::ui::settings_store::put_bool( + kMapNamespace, kContourUltraFineKey, enabled); +} + +void UConsoleMapWorkspaceModel::setEarthdataToken(const std::string& token) +{ + const std::string trimmed = trim_copy(token); + (void)::platform::ui::settings_store::put_string( + kMapNamespace, kEarthdataTokenKey, trimmed.c_str()); +} + +bool UConsoleMapWorkspaceModel::contourUltraFineEnabled() const +{ + return ::platform::ui::settings_store::get_bool( + kMapNamespace, kContourUltraFineKey, false); +} + +std::string UConsoleMapWorkspaceModel::earthdataToken() const +{ + std::string token{}; + (void)::platform::ui::settings_store::get_string( + kMapNamespace, kEarthdataTokenKey, token); + return token; +} + +bool UConsoleMapWorkspaceModel::earthdataTokenConfigured() const +{ + return !earthdataToken().empty(); +} + +MapCoordinate UConsoleMapWorkspaceModel::coordinateAtDisplayPoint( + const MapWorkspaceSnapshot& snapshot, + double display_x, + double display_y, + int display_width, + int display_height) const +{ + MapCoordinate out{}; + if (!snapshot.has_center || snapshot.tiles.empty() || + display_width <= 0 || display_height <= 0 || + !std::isfinite(display_x) || !std::isfinite(display_y)) + { + return out; + } + + const auto top_left = snapshot.tiles.front().id; + const double viewport_width_px = + static_cast(std::max(1U, snapshot.columns)) * + static_cast(kTileSizePx); + const double viewport_height_px = + static_cast(std::max(1U, snapshot.rows)) * + static_cast(kTileSizePx); + const double world_px = + static_cast(1U << snapshot.zoom) * + static_cast(kTileSizePx); + const double normalized_x = + std::clamp(display_x / static_cast(display_width), 0.0, 1.0); + const double normalized_y = + std::clamp(display_y / static_cast(display_height), 0.0, 1.0); + const double world_x = + static_cast(top_left.x) * static_cast(kTileSizePx) + + normalized_x * viewport_width_px; + const double world_y = std::clamp( + static_cast(top_left.y) * static_cast(kTileSizePx) + + normalized_y * viewport_height_px, + 0.0, + world_px); + + out.valid = true; + out.lat = world_px_to_latitude(world_y, snapshot.zoom); + out.lon = world_px_to_longitude(world_x, snapshot.zoom); + return out; +} + +void UConsoleMapWorkspaceModel::centerOn(double lat, + double lon, + bool persist) +{ + if (!std::isfinite(lat) || !std::isfinite(lon)) + { + return; + } + + manual_center_active_ = true; + manual_center_lat_ = clamp_web_mercator_lat(lat); + manual_center_lon_ = std::clamp(lon, -180.0, 180.0); + if (persist) + { + persistManualCenter(); + } +} + +void UConsoleMapWorkspaceModel::zoomInAt(double lat, double lon) +{ + setZoom(zoom_ + 1); + centerOn(lat, lon, true); +} + +void UConsoleMapWorkspaceModel::zoomOutAt(double lat, double lon) +{ + setZoom(zoom_ - 1); + centerOn(lat, lon, true); +} + +void UConsoleMapWorkspaceModel::panByDisplayDelta(double drag_dx, + double drag_dy, + int display_width, + int display_height, + double start_lat, + double start_lon, + int start_zoom, + bool persist) +{ + if (display_width <= 0 || display_height <= 0 || + !std::isfinite(drag_dx) || !std::isfinite(drag_dy) || + !std::isfinite(start_lat) || !std::isfinite(start_lon)) + { + return; + } + + zoom_ = std::clamp(start_zoom, kMinZoom, kMaxZoom); + + const double viewport_width_px = + static_cast(kLandscapeTileRadiusX * 2 + 1) * + static_cast(kTileSizePx); + const double viewport_height_px = + static_cast(kLandscapeTileRadiusY * 2 + 1) * + static_cast(kTileSizePx); + const double world_px = + static_cast(1U << zoom_) * static_cast(kTileSizePx); + + double center_x = longitude_to_world_px(start_lon, zoom_); + double center_y = latitude_to_world_px(start_lat, zoom_); + center_x -= (drag_dx / static_cast(display_width)) * + viewport_width_px; + center_y -= (drag_dy / static_cast(display_height)) * + viewport_height_px; + center_y = std::clamp(center_y, 0.0, world_px); + + centerOn(world_px_to_latitude(center_y, zoom_), + world_px_to_longitude(center_x, zoom_), + persist); + if (persist) + { + persistZoom(); + } +} + +void UConsoleMapWorkspaceModel::clearManualCenter() +{ + manual_center_active_ = false; + manual_center_lat_ = 0.0; + manual_center_lon_ = 0.0; + clearPersistedManualCenter(); +} + +void UConsoleMapWorkspaceModel::zoomIn() +{ + setZoom(zoom_ + 1); +} + +void UConsoleMapWorkspaceModel::zoomOut() +{ + setZoom(zoom_ - 1); +} + +::platform::linux_runtime::MapBaseSource +UConsoleMapWorkspaceModel::source() const +{ + return ::platform::linux_runtime::sanitize_map_base_source( + services_.config().map_source); +} + +bool UConsoleMapWorkspaceModel::showMqttNodes() const +{ + return ::platform::ui::settings_store::get_bool( + kMapNamespace, kShowMqttNodesKey, true); +} + +bool UConsoleMapWorkspaceModel::contourEnabled() const +{ + return services_.config().map_contour_enabled; +} + +void UConsoleMapWorkspaceModel::persistZoom() const +{ + ::platform::ui::settings_store::put_int(kMapNamespace, kZoomKey, zoom_); +} + +void UConsoleMapWorkspaceModel::persistManualCenter() const +{ + ::platform::ui::settings_store::put_bool( + kMapNamespace, kManualCenterActiveKey, manual_center_active_); + ::platform::ui::settings_store::put_int( + kMapNamespace, kManualCenterLatE7Key, + coordinate_to_e7(manual_center_lat_)); + ::platform::ui::settings_store::put_int( + kMapNamespace, kManualCenterLonE7Key, + coordinate_to_e7(manual_center_lon_)); +} + +void UConsoleMapWorkspaceModel::clearPersistedManualCenter() const +{ + constexpr const char* keys[] = { + kManualCenterActiveKey, + kManualCenterLatE7Key, + kManualCenterLonE7Key, + }; + ::platform::ui::settings_store::remove_keys( + kMapNamespace, keys, sizeof(keys) / sizeof(keys[0])); +} + +} // namespace trailmate::uconsole diff --git a/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp b/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp index 92910a01..aaf6022c 100644 --- a/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp +++ b/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp @@ -2,6 +2,7 @@ #include "chat/domain/contact_types.h" #include "chat/infra/meshtastic/mt_codec_pb.h" +#include "chat/infra/meshtastic/mt_node_payload.h" #include "chat/infra/meshtastic/mt_packet_wire.h" #include "chat/infra/meshtastic/mt_pki_crypto.h" #include "chat/infra/meshtastic/mt_protocol_helpers.h" @@ -1231,216 +1232,61 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) decoded.payload.size > 0 && (node_store_ || contact_service_)) { - meshtastic_NodeInfo node = meshtastic_NodeInfo_init_default; - pb_istream_t nstream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); + ::chat::meshtastic::NodePayloadDecodeContext context{}; + context.fallback_node_id = header.from; + context.snr = last_rx_snr_; + context.rssi = last_rx_rssi_; + context.timestamp = nowSeconds(); + context.hops_away = ::chat::meshtastic::computeHopsAway(header.flags); + context.channel = static_cast(channel); + context.via_mqtt = + (header.flags & ::chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0; - if (pb_decode(&nstream, meshtastic_NodeInfo_fields, &node)) + ::chat::meshtastic::DecodedNodePayload node{}; + if (::chat::meshtastic::decodeNodeInfoPayload(decoded, context, &node)) { - const ::chat::NodeId effective_node_id = header.from; - - if (node.num != 0 && node.num != header.from) + if (node.node_id != 0 && node.node_id != header.from) { logMeshtasticRx("[gat562][mt] reject nodeinfo mismatch from=%08lX claimed=%08lX\n", static_cast(header.from), - static_cast(node.num)); - } - else if (effective_node_id == node_id_ && header.from != node_id_) - { - logMeshtasticRx("[gat562][mt] reject foreign nodeinfo targeting self from=%08lX\n", - static_cast(header.from)); + static_cast(node.node_id)); } else { - const float snr = std::isnan(last_rx_snr_) ? node.snr : last_rx_snr_; - const uint8_t hops_away = - node.has_hops_away ? node.hops_away - : ::chat::meshtastic::computeHopsAway(header.flags); - - ::chat::contacts::NodeUpdate update{}; - update.has_last_seen = true; - update.last_seen = nowSeconds(); - update.has_snr = !std::isnan(snr); - update.snr = snr; - update.has_rssi = !std::isnan(last_rx_rssi_); - update.rssi = last_rx_rssi_; - update.has_protocol = true; - update.protocol = static_cast(::chat::contacts::NodeProtocolType::Meshtastic); - update.has_hops_away = true; - update.hops_away = hops_away; - update.has_channel = true; - update.channel = static_cast(channel); - update.has_via_mqtt = true; - update.via_mqtt = node.via_mqtt || - ((header.flags & ::chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0); - update.has_is_ignored = true; - update.is_ignored = node.is_ignored; - if (node.has_position && ::chat::meshtastic::hasValidPosition(node.position)) - { - update.has_position = true; - update.position.valid = true; - update.position.latitude_i = node.position.latitude_i; - update.position.longitude_i = node.position.longitude_i; - update.position.has_altitude = node.position.has_altitude; - update.position.altitude = node.position.altitude; - update.position.timestamp = - node.position.timestamp != 0 ? node.position.timestamp : node.position.time; - update.position.precision_bits = node.position.precision_bits; - update.position.pdop = node.position.PDOP; - update.position.hdop = node.position.HDOP; - update.position.vdop = node.position.VDOP; - update.position.gps_accuracy_mm = node.position.gps_accuracy; - } - if (node.has_device_metrics) - { - update.has_device_metrics = true; - update.device_metrics.has_battery_level = node.device_metrics.has_battery_level; - update.device_metrics.battery_level = node.device_metrics.battery_level; - update.device_metrics.has_voltage = node.device_metrics.has_voltage; - update.device_metrics.voltage = node.device_metrics.voltage; - update.device_metrics.has_channel_utilization = node.device_metrics.has_channel_utilization; - update.device_metrics.channel_utilization = node.device_metrics.channel_utilization; - update.device_metrics.has_air_util_tx = node.device_metrics.has_air_util_tx; - update.device_metrics.air_util_tx = node.device_metrics.air_util_tx; - update.device_metrics.has_uptime_seconds = node.device_metrics.has_uptime_seconds; - update.device_metrics.uptime_seconds = node.device_metrics.uptime_seconds; - } - - if (node.has_user) - { - if (node.user.short_name[0] != '\0') - { - update.short_name = node.user.short_name; - } - if (node.user.long_name[0] != '\0') - { - update.long_name = node.user.long_name; - } - update.has_role = true; - update.role = static_cast(node.user.role); - if (node.user.hw_model != meshtastic_HardwareModel_UNSET) - { - update.has_hw_model = true; - update.hw_model = static_cast(node.user.hw_model); - } - - bool has_macaddr = false; - for (std::size_t idx = 0; idx < sizeof(update.macaddr); ++idx) - { - if (node.user.macaddr[idx] != 0) - { - has_macaddr = true; - break; - } - } - if (has_macaddr) - { - update.has_macaddr = true; - std::memcpy(update.macaddr, node.user.macaddr, sizeof(update.macaddr)); - } - - update.has_public_key = true; - update.public_key_present = (node.user.public_key.size > 0); - } - + const ::chat::contacts::NodeUpdate update = node.toNodeUpdate(); if (!duplicate || history.was_fallback) { - apply_observed_node_update(effective_node_id, update); + apply_observed_node_update(node.node_id, update); + if (node.has_public_key) + { + savePkiNodeKey(node.node_id, + node.public_key.data(), + node.public_key.size()); + } } - nodeinfo_last_seen_ms_[effective_node_id] = millis(); + nodeinfo_last_seen_ms_[node.node_id] = millis(); - if (node.has_user) + if (!node.short_name.empty() || !node.long_name.empty()) { logMeshtasticRx("[gat562][mt] nodeinfo observed from=%08lX owner=%08lX short=\"%s\" long=\"%s\"\n", static_cast(header.from), - static_cast(effective_node_id), - node.user.short_name, - node.user.long_name); + static_cast(node.node_id), + node.short_name.c_str(), + node.long_name.c_str()); } else { logMeshtasticRx("[gat562][mt] nodeinfo observed from=%08lX owner=%08lX\n", static_cast(header.from), - static_cast(effective_node_id)); + static_cast(node.node_id)); } } } else { - meshtastic_User user = meshtastic_User_init_default; - pb_istream_t ustream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); - if (pb_decode(&ustream, meshtastic_User_fields, &user)) - { - const ::chat::NodeId effective_node_id = header.from; - - if (effective_node_id == node_id_ && header.from != node_id_) - { - logMeshtasticRx("[gat562][mt] reject foreign user payload targeting self from=%08lX\n", - static_cast(header.from)); - } - else - { - ::chat::contacts::NodeUpdate update{}; - if (user.short_name[0] != '\0') - { - update.short_name = user.short_name; - } - if (user.long_name[0] != '\0') - { - update.long_name = user.long_name; - } - update.has_last_seen = true; - update.last_seen = nowSeconds(); - update.has_snr = !std::isnan(last_rx_snr_); - update.snr = last_rx_snr_; - update.has_rssi = !std::isnan(last_rx_rssi_); - update.rssi = last_rx_rssi_; - update.has_protocol = true; - update.protocol = static_cast(::chat::contacts::NodeProtocolType::Meshtastic); - update.has_hops_away = true; - update.hops_away = ::chat::meshtastic::computeHopsAway(header.flags); - update.has_channel = true; - update.channel = static_cast(channel); - update.has_via_mqtt = true; - update.via_mqtt = ((header.flags & ::chat::meshtastic::PACKET_FLAGS_VIA_MQTT_MASK) != 0); - update.has_role = true; - update.role = static_cast(user.role); - if (user.hw_model != meshtastic_HardwareModel_UNSET) - { - update.has_hw_model = true; - update.hw_model = static_cast(user.hw_model); - } - - bool has_macaddr = false; - for (std::size_t idx = 0; idx < sizeof(update.macaddr); ++idx) - { - if (user.macaddr[idx] != 0) - { - has_macaddr = true; - break; - } - } - if (has_macaddr) - { - update.has_macaddr = true; - std::memcpy(update.macaddr, user.macaddr, sizeof(update.macaddr)); - } - - update.has_public_key = true; - update.public_key_present = (user.public_key.size > 0); - - if (!duplicate || history.was_fallback) - { - apply_observed_node_update(effective_node_id, update); - } - nodeinfo_last_seen_ms_[effective_node_id] = millis(); - - logMeshtasticRx("[gat562][mt] user observed from=%08lX owner=%08lX short=\"%s\" long=\"%s\"\n", - static_cast(header.from), - static_cast(effective_node_id), - user.short_name, - user.long_name); - } - } + logMeshtasticRx("[gat562][mt] nodeinfo decode fail from=%08lX payload=%u\n", + static_cast(header.from), + static_cast(decoded.payload.size)); } } @@ -1449,24 +1295,19 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) decoded.payload.size > 0 && contact_service_) { - meshtastic_Position position_pb = meshtastic_Position_init_zero; - pb_istream_t pstream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); - if (pb_decode(&pstream, meshtastic_Position_fields, &position_pb) && - ::chat::meshtastic::hasValidPosition(position_pb)) + ::chat::meshtastic::DecodedPositionPayload position{}; + if (::chat::meshtastic::decodePositionPayload(decoded, + header.from, + nowSeconds(), + &position)) { - ::chat::contacts::NodePosition pos{}; - pos.valid = true; - pos.latitude_i = position_pb.latitude_i; - pos.longitude_i = position_pb.longitude_i; - pos.has_altitude = position_pb.has_altitude; - pos.altitude = position_pb.altitude; - pos.timestamp = position_pb.timestamp != 0 ? position_pb.timestamp : position_pb.time; - pos.precision_bits = position_pb.precision_bits; - pos.pdop = position_pb.PDOP; - pos.hdop = position_pb.HDOP; - pos.vdop = position_pb.VDOP; - pos.gps_accuracy_mm = position_pb.gps_accuracy; - contact_service_->updateNodePosition(header.from, pos); + contact_service_->updateNodePosition(position.node_id, position.position); + } + else + { + logMeshtasticRx("[gat562][mt] position decode fail from=%08lX payload=%u\n", + static_cast(header.from), + static_cast(decoded.payload.size)); } } diff --git a/platform/shared/include/board/GpsBoard.h b/platform/shared/include/board/GpsBoard.h index add5c5d5..ab26c8ec 100644 --- a/platform/shared/include/board/GpsBoard.h +++ b/platform/shared/include/board/GpsBoard.h @@ -1,6 +1,7 @@ #pragma once #include "TLoRaPagerTypes.h" +#include "gps/usecase/gps_runtime_config.h" class GPS; @@ -10,6 +11,8 @@ class GpsBoard public: virtual ~GpsBoard() = default; + virtual void setGPSReceiverInitConfig(const gps::GpsReceiverInitConfig& config) { (void)config; } + virtual gps::GpsReceiverProtocol getGPSReceiverProtocol() const { return gps::GpsReceiverProtocol::Unknown; } virtual bool initGPS() = 0; virtual void setGPSOnline(bool online) = 0; virtual void deinitGPS() { setGPSOnline(false); } diff --git a/site/index.html b/site/index.html index f1ebf6de..c3a9abc7 100644 --- a/site/index.html +++ b/site/index.html @@ -106,13 +106,12 @@

Localization

-

0.1.24-alpha makes language packs versioned, reviewable, and installable without bloating firmware images.

+

0.1.25-alpha adds GPS receiver controls, diagnostics, and T-Deck UART noise guidance.

- English remains built in for safe defaults. Additional locale, font, and IME - resources are published as versioned bundles. Release-quality locales can appear in - Settings, while review packs remain downloadable for validation without surprising - everyday users. + The release keeps the installable language-pack catalog from 0.1.24-alpha and + adds clearer GPS setup paths for receiver baud, receiver profile, initialization + policy, diagnostics, and ordinary T-Deck hardware checks.

@@ -139,8 +138,8 @@

Release Gate

Review packs are visible in the catalog but kept out of the runtime language picker.

- The 0.1.24-alpha catalog carries translation status, archive hashes, and package - versions so updates are visible without pretending unfinished locales are ready. + The catalog carries translation status, archive hashes, and package versions so + updates are visible without pretending unfinished locales are ready.

@@ -165,6 +164,21 @@
+
+

+ 0.1.25-alpha separates GPS transport readiness from receiver health, exposes UART + traffic diagnostics in Settings, and documents the ordinary T-Deck case where LoRa TX + can induce non-NMEA bytes on a floating or weakly driven GPS UART RX line. +

+
+ GPS Settings + Receiver Profiles + UART Diagnostics + T-Deck Hardware Notes + LoRa TX Noise Checks +
+
+