From cff41d4fa04af238231b745287f2ab56c2ac198b Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Wed, 4 Mar 2026 21:05:28 -0500 Subject: [PATCH] Add offline map display and Sideband-compatible telemetry location sharing - LVGL PNG decoder (lodepng) + SD card filesystem driver for loading OSM tiles - MapScreen with 2x2 tile grid, GPS marker, peer location markers, pan/zoom - 5th nav button (GPS icon) on conversation list for map access - TelemetryCodec: Sideband/Columba-compatible LXMF telemetry encode/decode - TelemetryManager: per-peer sharing sessions with duration/expiry, SPIFFS persistence - ChatScreen location share button with duration picker (15min/1hr/4hr/indefinite) - UIManager integration: telemetry send/receive via LXMF fields, map marker updates Co-Authored-By: Claude Opus 4.6 --- lib/lv_conf.h | 1 + lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.cpp | 139 ++++++ lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.h | 42 ++ lib/tdeck_ui/Telemetry/TelemetryCodec.h | 267 +++++++++++ lib/tdeck_ui/Telemetry/TelemetryManager.cpp | 316 +++++++++++++ lib/tdeck_ui/Telemetry/TelemetryManager.h | 104 +++++ lib/tdeck_ui/UI/LVGL/LVGLInit.cpp | 9 + lib/tdeck_ui/UI/LXMF/ChatScreen.cpp | 86 +++- lib/tdeck_ui/UI/LXMF/ChatScreen.h | 18 + .../UI/LXMF/ConversationListScreen.cpp | 19 +- lib/tdeck_ui/UI/LXMF/ConversationListScreen.h | 8 + lib/tdeck_ui/UI/LXMF/MapScreen.cpp | 442 ++++++++++++++++++ lib/tdeck_ui/UI/LXMF/MapScreen.h | 121 +++++ lib/tdeck_ui/UI/LXMF/TileMath.h | 82 ++++ lib/tdeck_ui/UI/LXMF/UIManager.cpp | 223 +++++++++ lib/tdeck_ui/UI/LXMF/UIManager.h | 18 +- lib/tdeck_ui/library.json | 3 +- 17 files changed, 1889 insertions(+), 9 deletions(-) create mode 100644 lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.cpp create mode 100644 lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.h create mode 100644 lib/tdeck_ui/Telemetry/TelemetryCodec.h create mode 100644 lib/tdeck_ui/Telemetry/TelemetryManager.cpp create mode 100644 lib/tdeck_ui/Telemetry/TelemetryManager.h create mode 100644 lib/tdeck_ui/UI/LXMF/MapScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/MapScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/TileMath.h diff --git a/lib/lv_conf.h b/lib/lv_conf.h index 428fd31f..224937ad 100644 --- a/lib/lv_conf.h +++ b/lib/lv_conf.h @@ -156,6 +156,7 @@ 3RD PARTY LIBRARIES *====================*/ #define LV_USE_QRCODE 1 +#define LV_USE_PNG 1 /*==================== THEMES diff --git a/lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.cpp b/lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.cpp new file mode 100644 index 00000000..a44104d6 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.cpp @@ -0,0 +1,139 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "LVGLFSDriver.h" + +#ifdef ARDUINO + +#include "SDAccess.h" +#include +#include + +namespace Hardware { +namespace TDeck { + +// File handle wrapper — holds an open SD File object +struct FSFileHandle { + File file; +}; + +void LVGLFSDriver::init() { + if (!SDAccess::is_ready()) { + WARNING("LVGLFSDriver: SD card not ready, skipping init"); + return; + } + + static lv_fs_drv_t drv; + lv_fs_drv_init(&drv); + + drv.letter = 'S'; + drv.open_cb = fs_open; + drv.close_cb = fs_close; + drv.read_cb = fs_read; + drv.seek_cb = fs_seek; + drv.tell_cb = fs_tell; + + lv_fs_drv_register(&drv); + + INFO("LVGLFSDriver: Registered drive 'S' for SD card"); +} + +void* LVGLFSDriver::fs_open(lv_fs_drv_t* drv, const char* path, lv_fs_mode_t mode) { + (void)drv; + + if (mode != LV_FS_MODE_RD) { + return NULL; // Read-only + } + + // Build full SD path: prepend / prefix + // SD.open() already uses the /sd mount point internally + char full_path[128]; + snprintf(full_path, sizeof(full_path), "/%s", path); + + if (!SDAccess::acquire_bus(500)) { + return NULL; + } + + FSFileHandle* handle = new FSFileHandle(); + handle->file = SD.open(full_path, FILE_READ); + + SDAccess::release_bus(); + + if (!handle->file) { + delete handle; + return NULL; + } + + return handle; +} + +lv_fs_res_t LVGLFSDriver::fs_close(lv_fs_drv_t* drv, void* file_p) { + (void)drv; + FSFileHandle* handle = (FSFileHandle*)file_p; + + if (!SDAccess::acquire_bus(500)) { + // Still delete handle to avoid leak + delete handle; + return LV_FS_RES_HW_ERR; + } + + handle->file.close(); + SDAccess::release_bus(); + + delete handle; + return LV_FS_RES_OK; +} + +lv_fs_res_t LVGLFSDriver::fs_read(lv_fs_drv_t* drv, void* file_p, + void* buf, uint32_t btr, uint32_t* br) { + (void)drv; + FSFileHandle* handle = (FSFileHandle*)file_p; + + if (!SDAccess::acquire_bus(500)) { + *br = 0; + return LV_FS_RES_HW_ERR; + } + + *br = handle->file.read((uint8_t*)buf, btr); + + SDAccess::release_bus(); + return LV_FS_RES_OK; +} + +lv_fs_res_t LVGLFSDriver::fs_seek(lv_fs_drv_t* drv, void* file_p, + uint32_t pos, lv_fs_whence_t whence) { + (void)drv; + FSFileHandle* handle = (FSFileHandle*)file_p; + + if (!SDAccess::acquire_bus(500)) { + return LV_FS_RES_HW_ERR; + } + + SeekMode mode = SeekSet; + if (whence == LV_FS_SEEK_CUR) mode = SeekCur; + else if (whence == LV_FS_SEEK_END) mode = SeekEnd; + + bool ok = handle->file.seek(pos, mode); + + SDAccess::release_bus(); + return ok ? LV_FS_RES_OK : LV_FS_RES_UNKNOWN; +} + +lv_fs_res_t LVGLFSDriver::fs_tell(lv_fs_drv_t* drv, void* file_p, uint32_t* pos_p) { + (void)drv; + FSFileHandle* handle = (FSFileHandle*)file_p; + + if (!SDAccess::acquire_bus(500)) { + return LV_FS_RES_HW_ERR; + } + + *pos_p = handle->file.position(); + + SDAccess::release_bus(); + return LV_FS_RES_OK; +} + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO diff --git a/lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.h b/lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.h new file mode 100644 index 00000000..9790064f --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/LVGLFSDriver.h @@ -0,0 +1,42 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_LVGLFSDRIVER_H +#define HARDWARE_TDECK_LVGLFSDRIVER_H + +#ifdef ARDUINO + +#include + +namespace Hardware { +namespace TDeck { + +/** + * LVGL filesystem driver for SD card access. + * + * Registers drive letter 'S' so LVGL can load files like: + * "S:tiles/14/8192/5455.png" + * + * Uses SDAccess mutex for SPI bus arbitration. The mutex is acquired + * per-read (not per-open) to avoid blocking display flushes during + * long tile decode operations. + */ +class LVGLFSDriver { +public: + static void init(); + +private: + static void* fs_open(lv_fs_drv_t* drv, const char* path, lv_fs_mode_t mode); + static lv_fs_res_t fs_close(lv_fs_drv_t* drv, void* file_p); + static lv_fs_res_t fs_read(lv_fs_drv_t* drv, void* file_p, + void* buf, uint32_t btr, uint32_t* br); + static lv_fs_res_t fs_seek(lv_fs_drv_t* drv, void* file_p, + uint32_t pos, lv_fs_whence_t whence); + static lv_fs_res_t fs_tell(lv_fs_drv_t* drv, void* file_p, uint32_t* pos_p); +}; + +} // namespace TDeck +} // namespace Hardware + +#endif // ARDUINO +#endif // HARDWARE_TDECK_LVGLFSDRIVER_H diff --git a/lib/tdeck_ui/Telemetry/TelemetryCodec.h b/lib/tdeck_ui/Telemetry/TelemetryCodec.h new file mode 100644 index 00000000..a1b5c57f --- /dev/null +++ b/lib/tdeck_ui/Telemetry/TelemetryCodec.h @@ -0,0 +1,267 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef TELEMETRY_TELEMETRYCODEC_H +#define TELEMETRY_TELEMETRYCODEC_H + +#ifdef ARDUINO + +#include +#include +#include "Bytes.h" + +namespace Telemetry { + +// LXMF field key constants +static const uint8_t FIELD_TELEMETRY = 0x02; +static const uint8_t FIELD_ICON_APPEARANCE = 0x04; +static const uint8_t FIELD_COLUMBA_META = 0x70; + +// Sensor IDs (Sideband standard) +static const uint8_t SID_TIME = 0x01; +static const uint8_t SID_LOCATION = 0x02; + +struct LocationData { + double lat; // degrees + double lon; // degrees + double altitude; // meters + double speed; // m/s + double bearing; // degrees + double accuracy; // meters + uint32_t timestamp; + bool valid; +}; + +/** + * Encode telemetry data in Sideband-compatible LXMF FIELD_TELEMETRY format. + * + * Wire format: msgpack map {SID_TIME: uint32, SID_LOCATION: [7 elements]} + * Location array elements match Sideband sense.py Location.pack(): + * [0] lat: BIN(4) signed i32 BE, microdegrees + * [1] lon: BIN(4) signed i32 BE, microdegrees + * [2] alt: BIN(4) signed i32 BE, centimeters + * [3] speed: BIN(4) unsigned u32 BE, cm/s + * [4] bearing: BIN(4) signed i32 BE, centidegrees + * [5] accuracy: BIN(2) unsigned u16 BE, centimeters (clamped to 65535) + * [6] last_update: plain integer (not BIN-wrapped) + */ +inline RNS::Bytes encode_telemetry(const LocationData& loc) { + MsgPack::Packer packer; + + // Outer map with 2 entries: {SID_TIME: timestamp, SID_LOCATION: [...]} + packer.packMapSize(2); + + // SID_TIME + packer.pack((uint8_t)SID_TIME); + packer.pack(loc.timestamp); + + // SID_LOCATION + packer.pack((uint8_t)SID_LOCATION); + packer.packArraySize(7); + + // Helper: pack a signed i32 as BIN(4) big-endian + auto pack_i32_bin = [&](int32_t val) { + uint8_t buf[4]; + buf[0] = (uint8_t)(val >> 24); + buf[1] = (uint8_t)(val >> 16); + buf[2] = (uint8_t)(val >> 8); + buf[3] = (uint8_t)(val); + packer.packBinary(buf, 4); + }; + + // Helper: pack an unsigned u32 as BIN(4) big-endian + auto pack_u32_bin = [&](uint32_t val) { + uint8_t buf[4]; + buf[0] = (uint8_t)(val >> 24); + buf[1] = (uint8_t)(val >> 16); + buf[2] = (uint8_t)(val >> 8); + buf[3] = (uint8_t)(val); + packer.packBinary(buf, 4); + }; + + // [0] lat: microdegrees + pack_i32_bin((int32_t)(loc.lat * 1e6)); + + // [1] lon: microdegrees + pack_i32_bin((int32_t)(loc.lon * 1e6)); + + // [2] alt: centimeters + pack_i32_bin((int32_t)(loc.altitude * 100.0)); + + // [3] speed: cm/s (unsigned) + pack_u32_bin((uint32_t)(loc.speed * 100.0)); + + // [4] bearing: centidegrees + pack_i32_bin((int32_t)(loc.bearing * 100.0)); + + // [5] accuracy: centimeters, u16 BE, clamped + { + uint32_t acc_cm = (uint32_t)(loc.accuracy * 100.0); + if (acc_cm > 65535) acc_cm = 65535; + uint8_t buf[2]; + buf[0] = (uint8_t)(acc_cm >> 8); + buf[1] = (uint8_t)(acc_cm); + packer.packBinary(buf, 2); + } + + // [6] last_update: plain integer (not BIN-wrapped) + packer.pack(loc.timestamp); + + return RNS::Bytes(packer.data(), packer.size()); +} + +/** + * Decode telemetry data from Sideband/Columba/MeshChat FIELD_TELEMETRY format. + */ +inline LocationData decode_telemetry(const RNS::Bytes& data) { + LocationData loc; + loc.valid = false; + loc.lat = loc.lon = loc.altitude = loc.speed = loc.bearing = loc.accuracy = 0.0; + loc.timestamp = 0; + + MsgPack::Unpacker unpacker; + unpacker.feed(data.data(), data.size()); + + // Outer structure is a map + MsgPack::map_size_t map_size; + if (!unpacker.deserialize(map_size)) return loc; + + for (size_t i = 0; i < map_size.size(); i++) { + uint8_t key; + if (!unpacker.deserialize(key)) return loc; + + if (key == SID_TIME) { + unpacker.deserialize(loc.timestamp); + } else if (key == SID_LOCATION) { + MsgPack::arr_size_t arr_size; + if (!unpacker.deserialize(arr_size)) return loc; + if (arr_size.size() < 7) return loc; + + // Helper: read BIN(4) as signed i32 BE + auto read_i32_bin = [&](int32_t& out) -> bool { + MsgPack::bin_t bin; + if (!unpacker.deserialize(bin)) return false; + if (bin.size() < 4) return false; + const uint8_t* b = bin.data(); + out = ((int32_t)b[0] << 24) | ((int32_t)b[1] << 16) | + ((int32_t)b[2] << 8) | (int32_t)b[3]; + return true; + }; + + auto read_u32_bin = [&](uint32_t& out) -> bool { + MsgPack::bin_t bin; + if (!unpacker.deserialize(bin)) return false; + if (bin.size() < 4) return false; + const uint8_t* b = bin.data(); + out = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) | + ((uint32_t)b[2] << 8) | (uint32_t)b[3]; + return true; + }; + + int32_t i32_val; + uint32_t u32_val; + + // [0] lat + if (!read_i32_bin(i32_val)) return loc; + loc.lat = (double)i32_val / 1e6; + + // [1] lon + if (!read_i32_bin(i32_val)) return loc; + loc.lon = (double)i32_val / 1e6; + + // [2] alt + if (!read_i32_bin(i32_val)) return loc; + loc.altitude = (double)i32_val / 100.0; + + // [3] speed + if (!read_u32_bin(u32_val)) return loc; + loc.speed = (double)u32_val / 100.0; + + // [4] bearing + if (!read_i32_bin(i32_val)) return loc; + loc.bearing = (double)i32_val / 100.0; + + // [5] accuracy (BIN(2) u16 BE) + { + MsgPack::bin_t bin; + if (!unpacker.deserialize(bin)) return loc; + if (bin.size() >= 2) { + uint16_t acc = ((uint16_t)bin.data()[0] << 8) | bin.data()[1]; + loc.accuracy = (double)acc / 100.0; + } + } + + // [6] last_update (plain integer) + uint32_t last_update; + if (!unpacker.deserialize(last_update)) return loc; + + loc.valid = true; + } else { + // Skip unknown key's value + unpacker.unpackNil(); + } + } + + return loc; +} + +/** + * Encode Columba meta field (FIELD_COLUMBA_META). + * msgpack map with optional keys: expires, approxRadius, cease + */ +inline RNS::Bytes encode_columba_meta(uint32_t expires, int approx_radius, bool cease) { + MsgPack::Packer packer; + + int count = 0; + if (expires > 0) count++; + if (approx_radius > 0) count++; + if (cease) count++; + + packer.packMapSize(count); + + if (expires > 0) { + packer.packString("expires", 7); + packer.pack(expires); + } + if (approx_radius > 0) { + packer.packString("approxRadius", 12); + packer.pack(approx_radius); + } + if (cease) { + packer.packString("cease", 5); + packer.pack(true); + } + + return RNS::Bytes(packer.data(), packer.size()); +} + +/** + * Check if a Columba meta field contains a cease signal. + */ +inline bool decode_columba_cease(const RNS::Bytes& data) { + MsgPack::Unpacker unpacker; + unpacker.feed(data.data(), data.size()); + + MsgPack::map_size_t map_size; + if (!unpacker.deserialize(map_size)) return false; + + for (size_t i = 0; i < map_size.size(); i++) { + MsgPack::str_t key; + if (!unpacker.deserialize(key)) return false; + + if (String(key.c_str()) == "cease") { + bool val; + if (unpacker.deserialize(val)) return val; + return false; + } else { + // Skip value + unpacker.unpackNil(); + } + } + return false; +} + +} // namespace Telemetry + +#endif // ARDUINO +#endif // TELEMETRY_TELEMETRYCODEC_H diff --git a/lib/tdeck_ui/Telemetry/TelemetryManager.cpp b/lib/tdeck_ui/Telemetry/TelemetryManager.cpp new file mode 100644 index 00000000..61a4ce44 --- /dev/null +++ b/lib/tdeck_ui/Telemetry/TelemetryManager.cpp @@ -0,0 +1,316 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "TelemetryManager.h" + +#ifdef ARDUINO + +#include +#include "Log.h" +#include "Utilities/OS.h" + +using namespace RNS; + +namespace Telemetry { + +static const char* SESSIONS_FILE = "/telemetry_sessions.dat"; +static const char* LOCATIONS_FILE = "/telemetry_locations.dat"; + +TelemetryManager::TelemetryManager() + : _dirty(false), _last_save(0) { +} + +TelemetryManager::~TelemetryManager() { + if (_dirty) { + save(); + } +} + +void TelemetryManager::start_sharing(const Bytes& peer_hash, ShareDuration duration) { + // Check if already sharing with this peer + for (auto& session : _sessions) { + if (session.peer_hash == peer_hash) { + // Update existing session + uint32_t now = (uint32_t)Utilities::OS::time(); + session.duration = duration; + session.start_time = now; + session.end_time = compute_end_time(now, duration); + session.active = true; + session.last_send = 0; // Send immediately + _dirty = true; + + char log_buf[64]; + snprintf(log_buf, sizeof(log_buf), "Telemetry: Updated sharing with %.8s", + peer_hash.toHex().c_str()); + INFO(log_buf); + return; + } + } + + // Create new session + SharingSession session; + session.peer_hash = peer_hash; + session.duration = duration; + uint32_t now = (uint32_t)Utilities::OS::time(); + session.start_time = now; + session.end_time = compute_end_time(now, duration); + session.active = true; + session.last_send = 0; + _sessions.push_back(session); + _dirty = true; + + char log_buf[64]; + snprintf(log_buf, sizeof(log_buf), "Telemetry: Started sharing with %.8s", + peer_hash.toHex().c_str()); + INFO(log_buf); +} + +void TelemetryManager::stop_sharing(const Bytes& peer_hash) { + for (auto it = _sessions.begin(); it != _sessions.end(); ++it) { + if (it->peer_hash == peer_hash) { + _sessions.erase(it); + _dirty = true; + INFO("Telemetry: Stopped sharing"); + return; + } + } +} + +bool TelemetryManager::is_sharing(const Bytes& peer_hash) const { + for (const auto& session : _sessions) { + if (session.peer_hash == peer_hash && session.active) { + return true; + } + } + return false; +} + +std::vector TelemetryManager::update(uint32_t now) { + std::vector peers_needing_send; + + // Check for expired sessions + for (auto it = _sessions.begin(); it != _sessions.end(); ) { + if (it->end_time > 0 && now >= it->end_time) { + char log_buf[64]; + snprintf(log_buf, sizeof(log_buf), "Telemetry: Session expired for %.8s", + it->peer_hash.toHex().c_str()); + INFO(log_buf); + it = _sessions.erase(it); + _dirty = true; + } else { + ++it; + } + } + + // Check which peers need telemetry sent + for (auto& session : _sessions) { + if (session.active && (now - session.last_send >= SEND_INTERVAL)) { + peers_needing_send.push_back(session.peer_hash); + session.last_send = now; + } + } + + // Periodic save + if (_dirty && (now - _last_save >= SAVE_INTERVAL)) { + save(); + } + + return peers_needing_send; +} + +void TelemetryManager::on_location_received(const Bytes& peer_hash, const LocationData& loc) { + // Update existing entry or add new + for (auto& entry : _received) { + if (entry.peer_hash == peer_hash) { + entry.lat = loc.lat; + entry.lon = loc.lon; + entry.altitude = loc.altitude; + entry.speed = loc.speed; + entry.bearing = loc.bearing; + entry.accuracy = loc.accuracy; + entry.timestamp = loc.timestamp; + entry.received_time = (uint32_t)Utilities::OS::time(); + _dirty = true; + return; + } + } + + // Add new entry (cap at MAX_RECEIVED) + if ((int)_received.size() >= MAX_RECEIVED) { + // Remove oldest + uint32_t oldest_time = UINT32_MAX; + size_t oldest_idx = 0; + for (size_t i = 0; i < _received.size(); i++) { + if (_received[i].received_time < oldest_time) { + oldest_time = _received[i].received_time; + oldest_idx = i; + } + } + _received.erase(_received.begin() + oldest_idx); + } + + ReceivedLocation entry; + entry.peer_hash = peer_hash; + entry.lat = loc.lat; + entry.lon = loc.lon; + entry.altitude = loc.altitude; + entry.speed = loc.speed; + entry.bearing = loc.bearing; + entry.accuracy = loc.accuracy; + entry.timestamp = loc.timestamp; + entry.received_time = (uint32_t)Utilities::OS::time(); + _received.push_back(entry); + _dirty = true; + + char log_buf[80]; + snprintf(log_buf, sizeof(log_buf), "Telemetry: Location from %.8s (%.4f, %.4f)", + peer_hash.toHex().c_str(), loc.lat, loc.lon); + INFO(log_buf); +} + +void TelemetryManager::on_cease_received(const Bytes& peer_hash) { + // Remove received location for this peer + for (auto it = _received.begin(); it != _received.end(); ++it) { + if (it->peer_hash == peer_hash) { + _received.erase(it); + _dirty = true; + INFO("Telemetry: Peer ceased sharing"); + return; + } + } +} + +uint32_t TelemetryManager::compute_end_time(uint32_t start, ShareDuration duration) { + switch (duration) { + case ShareDuration::MINUTES_15: + return start + 15 * 60; + case ShareDuration::HOURS_1: + return start + 3600; + case ShareDuration::HOURS_4: + return start + 4 * 3600; + case ShareDuration::UNTIL_MIDNIGHT: { + // Calculate seconds until midnight UTC + uint32_t secs_today = start % 86400; + return start + (86400 - secs_today); + } + case ShareDuration::INDEFINITE: + default: + return 0; // No expiry + } +} + +void TelemetryManager::save() { + // Save sessions + { + File f = SPIFFS.open(SESSIONS_FILE, FILE_WRITE); + if (f) { + uint8_t count = (uint8_t)_sessions.size(); + f.write(&count, 1); + for (const auto& s : _sessions) { + uint8_t hash_len = (uint8_t)s.peer_hash.size(); + f.write(&hash_len, 1); + f.write(s.peer_hash.data(), hash_len); + f.write((const uint8_t*)&s.duration, sizeof(s.duration)); + f.write((const uint8_t*)&s.start_time, sizeof(s.start_time)); + f.write((const uint8_t*)&s.end_time, sizeof(s.end_time)); + uint8_t active = s.active ? 1 : 0; + f.write(&active, 1); + } + f.close(); + } + } + + // Save received locations + { + File f = SPIFFS.open(LOCATIONS_FILE, FILE_WRITE); + if (f) { + uint8_t count = (uint8_t)_received.size(); + f.write(&count, 1); + for (const auto& r : _received) { + uint8_t hash_len = (uint8_t)r.peer_hash.size(); + f.write(&hash_len, 1); + f.write(r.peer_hash.data(), hash_len); + f.write((const uint8_t*)&r.lat, sizeof(r.lat)); + f.write((const uint8_t*)&r.lon, sizeof(r.lon)); + f.write((const uint8_t*)&r.altitude, sizeof(r.altitude)); + f.write((const uint8_t*)&r.speed, sizeof(r.speed)); + f.write((const uint8_t*)&r.bearing, sizeof(r.bearing)); + f.write((const uint8_t*)&r.accuracy, sizeof(r.accuracy)); + f.write((const uint8_t*)&r.timestamp, sizeof(r.timestamp)); + f.write((const uint8_t*)&r.received_time, sizeof(r.received_time)); + } + f.close(); + } + } + + _dirty = false; + _last_save = (uint32_t)Utilities::OS::time(); + DEBUG("Telemetry: State saved to SPIFFS"); +} + +void TelemetryManager::load() { + // Load sessions + { + File f = SPIFFS.open(SESSIONS_FILE, FILE_READ); + if (f) { + uint8_t count = 0; + f.read(&count, 1); + for (uint8_t i = 0; i < count; i++) { + SharingSession s; + uint8_t hash_len = 0; + f.read(&hash_len, 1); + if (hash_len > 32) break; + uint8_t hash_buf[32]; + f.read(hash_buf, hash_len); + s.peer_hash = Bytes(hash_buf, hash_len); + f.read((uint8_t*)&s.duration, sizeof(s.duration)); + f.read((uint8_t*)&s.start_time, sizeof(s.start_time)); + f.read((uint8_t*)&s.end_time, sizeof(s.end_time)); + uint8_t active = 0; + f.read(&active, 1); + s.active = (active != 0); + s.last_send = 0; + _sessions.push_back(s); + } + f.close(); + char log_buf[48]; + snprintf(log_buf, sizeof(log_buf), "Telemetry: Loaded %zu sessions", _sessions.size()); + INFO(log_buf); + } + } + + // Load received locations + { + File f = SPIFFS.open(LOCATIONS_FILE, FILE_READ); + if (f) { + uint8_t count = 0; + f.read(&count, 1); + for (uint8_t i = 0; i < count; i++) { + ReceivedLocation r; + uint8_t hash_len = 0; + f.read(&hash_len, 1); + if (hash_len > 32) break; + uint8_t hash_buf[32]; + f.read(hash_buf, hash_len); + r.peer_hash = Bytes(hash_buf, hash_len); + f.read((uint8_t*)&r.lat, sizeof(r.lat)); + f.read((uint8_t*)&r.lon, sizeof(r.lon)); + f.read((uint8_t*)&r.altitude, sizeof(r.altitude)); + f.read((uint8_t*)&r.speed, sizeof(r.speed)); + f.read((uint8_t*)&r.bearing, sizeof(r.bearing)); + f.read((uint8_t*)&r.accuracy, sizeof(r.accuracy)); + f.read((uint8_t*)&r.timestamp, sizeof(r.timestamp)); + f.read((uint8_t*)&r.received_time, sizeof(r.received_time)); + _received.push_back(r); + } + f.close(); + char log_buf[48]; + snprintf(log_buf, sizeof(log_buf), "Telemetry: Loaded %zu locations", _received.size()); + INFO(log_buf); + } + } +} + +} // namespace Telemetry + +#endif // ARDUINO diff --git a/lib/tdeck_ui/Telemetry/TelemetryManager.h b/lib/tdeck_ui/Telemetry/TelemetryManager.h new file mode 100644 index 00000000..527e6575 --- /dev/null +++ b/lib/tdeck_ui/Telemetry/TelemetryManager.h @@ -0,0 +1,104 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef TELEMETRY_TELEMETRYMANAGER_H +#define TELEMETRY_TELEMETRYMANAGER_H + +#ifdef ARDUINO + +#include +#include +#include "Bytes.h" +#include "TelemetryCodec.h" + +namespace Telemetry { + +enum class ShareDuration { + MINUTES_15, + HOURS_1, + HOURS_4, + UNTIL_MIDNIGHT, + INDEFINITE +}; + +struct SharingSession { + RNS::Bytes peer_hash; + ShareDuration duration; + uint32_t start_time; + uint32_t end_time; // 0 = indefinite + uint32_t last_send; // Last time we sent telemetry to this peer + bool active; +}; + +struct ReceivedLocation { + RNS::Bytes peer_hash; + double lat, lon, altitude; + double speed, bearing, accuracy; + uint32_t timestamp; + uint32_t received_time; +}; + +/** + * Manages location sharing sessions and received peer locations. + * + * - Tracks active sharing sessions (per-recipient, with duration) + * - Stores received locations from peers (capped at 32 entries) + * - Persists to SPIFFS with dirty threshold to avoid fragmentation + */ +class TelemetryManager { +public: + TelemetryManager(); + ~TelemetryManager(); + + /** Start sharing location with a peer */ + void start_sharing(const RNS::Bytes& peer_hash, ShareDuration duration); + + /** Stop sharing location with a peer */ + void stop_sharing(const RNS::Bytes& peer_hash); + + /** Check if sharing is active with a peer */ + bool is_sharing(const RNS::Bytes& peer_hash) const; + + /** + * Update: check expired sessions, return peers needing telemetry sent. + * @param now Current timestamp (seconds since epoch) + * @return Vector of peer hashes that need telemetry sent now + */ + std::vector update(uint32_t now); + + /** Store a received location from a peer */ + void on_location_received(const RNS::Bytes& peer_hash, const LocationData& loc); + + /** Handle a cease signal from a peer */ + void on_cease_received(const RNS::Bytes& peer_hash); + + /** Get all received locations (for map display) */ + const std::vector& get_received_locations() const { return _received; } + + /** Get active sharing sessions */ + const std::vector& get_sessions() const { return _sessions; } + + /** Save state to SPIFFS (called periodically) */ + void save(); + + /** Load state from SPIFFS */ + void load(); + +private: + std::vector _sessions; + std::vector _received; + + static const int MAX_RECEIVED = 32; + static const uint32_t SEND_INTERVAL = 60; // Send telemetry every 60s + static const uint32_t SAVE_INTERVAL = 60; // Save at most every 60s + + bool _dirty; + uint32_t _last_save; + + uint32_t compute_end_time(uint32_t start, ShareDuration duration); +}; + +} // namespace Telemetry + +#endif // ARDUINO +#endif // TELEMETRY_TELEMETRYMANAGER_H diff --git a/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp b/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp index 2ee429bd..bee34a69 100644 --- a/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp +++ b/lib/tdeck_ui/UI/LVGL/LVGLInit.cpp @@ -12,6 +12,9 @@ #include "../../Hardware/TDeck/Keyboard.h" #include "../../Hardware/TDeck/Touch.h" #include "../../Hardware/TDeck/Trackball.h" +#include "../../Hardware/TDeck/LVGLFSDriver.h" +#include +#include "extra/libs/png/lv_png.h" using namespace RNS; using namespace Hardware::TDeck; @@ -46,6 +49,9 @@ bool LVGLInit::init() { // Initialize LVGL library lv_init(); + // Initialize PNG decoder (lodepng, ~25KB flash) + lv_png_init(); + // LVGL 8.x logging is configured via lv_conf.h LV_USE_LOG // No runtime callback registration needed @@ -56,6 +62,9 @@ bool LVGLInit::init() { } _display = lv_disp_get_default(); + // Register LVGL filesystem driver for SD card (drive letter 'S') + Hardware::TDeck::LVGLFSDriver::init(); + INFO(" Display initialized"); // Create default input group for keyboard navigation diff --git a/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp b/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp index 8bc34216..b0068e99 100644 --- a/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/ChatScreen.cpp @@ -21,7 +21,9 @@ namespace LXMF { ChatScreen::ChatScreen(lv_obj_t* parent) : _screen(nullptr), _header(nullptr), _message_list(nullptr), _input_area(nullptr), _text_area(nullptr), _btn_send(nullptr), _btn_back(nullptr), _btn_call(nullptr), - _message_store(nullptr), _display_start_idx(0), _loading_more(false) { + _btn_location(nullptr), + _message_store(nullptr), _sharing_active(false), + _display_start_idx(0), _loading_more(false) { LVGL_LOCK(); // Create screen object @@ -85,9 +87,22 @@ void ChatScreen::create_header() { lv_obj_align(label_peer, LV_ALIGN_LEFT_MID, 60, 0); lv_obj_set_style_text_color(label_peer, Theme::textPrimary(), 0); lv_obj_set_style_text_font(label_peer, &lv_font_montserrat_16, 0); - lv_obj_set_width(label_peer, 195); + lv_obj_set_width(label_peer, 145); lv_label_set_long_mode(label_peer, LV_LABEL_LONG_DOT); + // Location share button (right of peer name, left of call button) + _btn_location = lv_btn_create(_header); + lv_obj_set_size(_btn_location, 40, 28); + lv_obj_align(_btn_location, LV_ALIGN_RIGHT_MID, -54, 0); + lv_obj_set_style_bg_color(_btn_location, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(_btn_location, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(_btn_location, on_location_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_loc = lv_label_create(_btn_location); + lv_label_set_text(label_loc, LV_SYMBOL_GPS); + lv_obj_center(label_loc); + lv_obj_set_style_text_color(label_loc, Theme::textSecondary(), 0); + // Voice call button (right side of header) _btn_call = lv_btn_create(_header); lv_obj_set_size(_btn_call, 50, 28); @@ -490,6 +505,22 @@ void ChatScreen::set_call_callback(CallCallback callback) { _call_callback = callback; } +void ChatScreen::set_location_share_callback(LocationShareCallback callback) { + _location_share_callback = callback; +} + +void ChatScreen::set_sharing_state(bool active) { + LVGL_LOCK(); + _sharing_active = active; + if (_btn_location) { + if (active) { + lv_obj_set_style_bg_color(_btn_location, Theme::successDark(), 0); + } else { + lv_obj_set_style_bg_color(_btn_location, Theme::btnSecondary(), 0); + } + } +} + void ChatScreen::show() { LVGL_LOCK(); lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); @@ -500,6 +531,7 @@ void ChatScreen::show() { lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { if (_btn_back) lv_group_add_obj(group, _btn_back); + if (_btn_location) lv_group_add_obj(group, _btn_location); if (_btn_call) lv_group_add_obj(group, _btn_call); if (_btn_send) lv_group_add_obj(group, _btn_send); @@ -516,6 +548,7 @@ void ChatScreen::hide() { lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { if (_btn_back) lv_group_remove_obj(_btn_back); + if (_btn_location) lv_group_remove_obj(_btn_location); if (_btn_call) lv_group_remove_obj(_btn_call); if (_btn_send) lv_group_remove_obj(_btn_send); } @@ -543,6 +576,55 @@ void ChatScreen::on_call_clicked(lv_event_t* event) { } } +void ChatScreen::on_location_clicked(lv_event_t* event) { + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + + // Show duration picker dialog + static const char* btns_sharing[] = {"15 min", "1 hour", "4 hours", ""}; + static const char* btns_sharing2[] = {"Midnight", "Indefinite", "Stop", ""}; + + if (screen->_sharing_active) { + // Show stop option prominently + static const char* btns_stop[] = {"Stop", "Cancel", ""}; + lv_obj_t* mbox = lv_msgbox_create(NULL, "Location Sharing", + "Currently sharing location.", btns_stop, false); + lv_obj_center(mbox); + lv_obj_add_event_cb(mbox, on_location_duration_selected, LV_EVENT_VALUE_CHANGED, screen); + // Tag with 100 to indicate stop dialog + lv_obj_set_user_data(mbox, (void*)100); + } else { + static const char* btns[] = {"15 min", "1 hour", "4 hours", ""}; + lv_obj_t* mbox = lv_msgbox_create(NULL, "Share Location", + "Share your location for:", btns, false); + lv_obj_center(mbox); + lv_obj_add_event_cb(mbox, on_location_duration_selected, LV_EVENT_VALUE_CHANGED, screen); + lv_obj_set_user_data(mbox, (void*)0); + } +} + +void ChatScreen::on_location_duration_selected(lv_event_t* event) { + lv_obj_t* mbox = lv_event_get_current_target(event); + ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); + uint16_t btn_id = lv_msgbox_get_active_btn(mbox); + int dialog_type = (int)(intptr_t)lv_obj_get_user_data(mbox); + + if (screen->_location_share_callback) { + if (dialog_type == 100) { + // Stop dialog: btn 0 = Stop, btn 1 = Cancel + if (btn_id == 0) { + screen->_location_share_callback(5); // 5 = stop + } + } else { + // Duration dialog: btn 0 = 15min, btn 1 = 1hr, btn 2 = 4hr + if (btn_id <= 2) { + screen->_location_share_callback(btn_id); + } + } + } + + lv_msgbox_close(mbox); +} + void ChatScreen::on_send_clicked(lv_event_t* event) { ChatScreen* screen = (ChatScreen*)lv_event_get_user_data(event); diff --git a/lib/tdeck_ui/UI/LXMF/ChatScreen.h b/lib/tdeck_ui/UI/LXMF/ChatScreen.h index 41d1c16e..b1fc95f8 100644 --- a/lib/tdeck_ui/UI/LXMF/ChatScreen.h +++ b/lib/tdeck_ui/UI/LXMF/ChatScreen.h @@ -62,6 +62,7 @@ public: using BackCallback = std::function; using SendMessageCallback = std::function; using CallCallback = std::function; + using LocationShareCallback = std::function; /** * Create chat screen @@ -118,6 +119,18 @@ public: */ void set_call_callback(CallCallback callback); + /** + * Set callback for location sharing + * @param callback Function called with duration index (0-4 for durations, 5 for stop) + */ + void set_location_share_callback(LocationShareCallback callback); + + /** + * Update location sharing button state + * @param active True if currently sharing location with this peer + */ + void set_sharing_state(bool active); + /** * Show the screen */ @@ -143,6 +156,7 @@ private: lv_obj_t* _btn_send; lv_obj_t* _btn_back; lv_obj_t* _btn_call; + lv_obj_t* _btn_location; RNS::Bytes _peer_hash; ::LXMF::MessageStore* _message_store; @@ -154,6 +168,8 @@ private: BackCallback _back_callback; SendMessageCallback _send_message_callback; CallCallback _call_callback; + LocationShareCallback _location_share_callback; + bool _sharing_active; // UI construction void create_header(); @@ -164,6 +180,8 @@ private: // Event handlers static void on_back_clicked(lv_event_t* event); static void on_call_clicked(lv_event_t* event); + static void on_location_clicked(lv_event_t* event); + static void on_location_duration_selected(lv_event_t* event); static void on_send_clicked(lv_event_t* event); static void on_message_long_pressed(lv_event_t* event); static void on_copy_dialog_action(lv_event_t* event); diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp index 27ef06d4..4c0c5200 100644 --- a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp @@ -158,12 +158,12 @@ void ConversationListScreen::create_bottom_nav() { lv_obj_set_flex_flow(_bottom_nav, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(_bottom_nav, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - // Bottom navigation buttons: Messages, Announces, Status, Settings - const char* icons[] = {LV_SYMBOL_ENVELOPE, LV_SYMBOL_BELL, LV_SYMBOL_BARS, LV_SYMBOL_SETTINGS}; + // Bottom navigation buttons: Messages, Announces, Status, Map, Settings + const char* icons[] = {LV_SYMBOL_ENVELOPE, LV_SYMBOL_BELL, LV_SYMBOL_BARS, LV_SYMBOL_GPS, LV_SYMBOL_SETTINGS}; - for (int i = 0; i < 4; i++) { + for (int i = 0; i < 5; i++) { lv_obj_t* btn = lv_btn_create(_bottom_nav); - lv_obj_set_size(btn, 65, 28); + lv_obj_set_size(btn, 52, 28); lv_obj_set_user_data(btn, (void*)(intptr_t)i); lv_obj_set_style_bg_color(btn, Theme::surfaceInput(), 0); lv_obj_set_style_bg_color(btn, lv_color_hex(0x3a3a3a), LV_STATE_PRESSED); @@ -361,6 +361,10 @@ void ConversationListScreen::set_status_callback(StatusCallback callback) { _status_callback = callback; } +void ConversationListScreen::set_map_callback(MapCallback callback) { + _map_callback = callback; +} + void ConversationListScreen::show() { LVGL_LOCK(); lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); @@ -617,7 +621,12 @@ void ConversationListScreen::on_bottom_nav_clicked(lv_event_t* event) { screen->_status_callback(); } break; - case 3: // Settings + case 3: // Map + if (screen->_map_callback) { + screen->_map_callback(); + } + break; + case 4: // Settings if (screen->_settings_callback) { screen->_settings_callback(); } diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h index 2606bce3..15b447c4 100644 --- a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h @@ -67,6 +67,7 @@ public: using SettingsCallback = std::function; using AnnouncesCallback = std::function; using StatusCallback = std::function; + using MapCallback = std::function; /** * Create conversation list screen @@ -133,6 +134,12 @@ public: */ void set_status_callback(StatusCallback callback); + /** + * Set callback for map button + * @param callback Function to call when map button is pressed + */ + void set_map_callback(MapCallback callback); + /** * Show the screen */ @@ -205,6 +212,7 @@ private: SettingsCallback _settings_callback; AnnouncesCallback _announces_callback; StatusCallback _status_callback; + MapCallback _map_callback; // UI construction void create_header(); diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.cpp b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp new file mode 100644 index 00000000..5fe6f3ad --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp @@ -0,0 +1,442 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#include "MapScreen.h" + +#ifdef ARDUINO + +#include "Theme.h" +#include "TileMath.h" +#include "Log.h" +#include "../LVGL/LVGLInit.h" +#include "../LVGL/LVGLLock.h" +#include + +using namespace RNS; + +namespace UI { +namespace LXMF { + +MapScreen::MapScreen(lv_obj_t* parent) + : _screen(nullptr), _header(nullptr), _viewport(nullptr), + _gps_marker(nullptr), _zoom_label(nullptr), + _peer_marker_count(0), _gps(nullptr), + _center_lat(0.0), _center_lon(0.0), _zoom(14), + _follow_gps(true), _has_gps_fix(false), _loaded_zoom(-1) { + + memset(_tile_imgs, 0, sizeof(_tile_imgs)); + memset(_peer_markers, 0, sizeof(_peer_markers)); + memset(_peer_labels, 0, sizeof(_peer_labels)); + memset(_loaded_tile_x, -1, sizeof(_loaded_tile_x)); + memset(_loaded_tile_y, -1, sizeof(_loaded_tile_y)); + + LVGL_LOCK(); + + if (parent) { + _screen = lv_obj_create(parent); + } else { + _screen = lv_obj_create(lv_scr_act()); + } + + lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100)); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); + lv_obj_set_style_bg_opa(_screen, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(_screen, 0, 0); + lv_obj_set_style_border_width(_screen, 0, 0); + lv_obj_set_style_radius(_screen, 0, 0); + + create_header(); + create_viewport(); + + hide(); + + TRACE("MapScreen created"); +} + +MapScreen::~MapScreen() { + LVGL_LOCK(); + if (_screen) { + lv_obj_del(_screen); + } +} + +void MapScreen::create_header() { + _header = lv_obj_create(_screen); + lv_obj_set_size(_header, LV_PCT(100), 36); + lv_obj_align(_header, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(_header, Theme::surfaceHeader(), 0); + lv_obj_set_style_border_width(_header, 0, 0); + lv_obj_set_style_radius(_header, 0, 0); + lv_obj_set_style_pad_all(_header, 0, 0); + + // Back button + lv_obj_t* btn_back = lv_btn_create(_header); + lv_obj_set_size(btn_back, 50, 28); + lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_set_style_bg_color(btn_back, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(btn_back, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(btn_back, on_back_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_back = lv_label_create(btn_back); + lv_label_set_text(label_back, LV_SYMBOL_LEFT); + lv_obj_center(label_back); + lv_obj_set_style_text_color(label_back, Theme::textSecondary(), 0); + + // Zoom label + _zoom_label = lv_label_create(_header); + lv_label_set_text(_zoom_label, "z:14"); + lv_obj_align(_zoom_label, LV_ALIGN_LEFT_MID, 62, 0); + lv_obj_set_style_text_color(_zoom_label, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(_zoom_label, &lv_font_montserrat_14, 0); + + // Zoom in button + lv_obj_t* btn_zin = lv_btn_create(_header); + lv_obj_set_size(btn_zin, 40, 28); + lv_obj_align(btn_zin, LV_ALIGN_RIGHT_MID, -48, 0); + lv_obj_set_style_bg_color(btn_zin, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(btn_zin, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(btn_zin, on_zoom_in_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_zin = lv_label_create(btn_zin); + lv_label_set_text(label_zin, "+"); + lv_obj_center(label_zin); + lv_obj_set_style_text_color(label_zin, Theme::textPrimary(), 0); + + // Zoom out button + lv_obj_t* btn_zout = lv_btn_create(_header); + lv_obj_set_size(btn_zout, 40, 28); + lv_obj_align(btn_zout, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_set_style_bg_color(btn_zout, Theme::btnSecondary(), 0); + lv_obj_set_style_bg_color(btn_zout, Theme::btnSecondaryPressed(), LV_STATE_PRESSED); + lv_obj_add_event_cb(btn_zout, on_zoom_out_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* label_zout = lv_label_create(btn_zout); + lv_label_set_text(label_zout, "-"); + lv_obj_center(label_zout); + lv_obj_set_style_text_color(label_zout, Theme::textPrimary(), 0); +} + +void MapScreen::create_viewport() { + _viewport = lv_obj_create(_screen); + lv_obj_set_size(_viewport, VIEWPORT_W, VIEWPORT_H); + lv_obj_align(_viewport, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(_viewport, lv_color_hex(0x1a1a2e), 0); // Dark blue for missing tiles + lv_obj_set_style_border_width(_viewport, 0, 0); + lv_obj_set_style_radius(_viewport, 0, 0); + lv_obj_set_style_pad_all(_viewport, 0, 0); + lv_obj_clear_flag(_viewport, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_clip_corner(_viewport, true, 0); + + // Create 4 tile image objects (2x2 grid) + for (int i = 0; i < 4; i++) { + _tile_imgs[i] = lv_img_create(_viewport); + lv_obj_set_size(_tile_imgs[i], TILE_SIZE, TILE_SIZE); + // Position will be set in update_tiles() + } + + // GPS marker: colored circle on top of tiles + _gps_marker = lv_obj_create(_viewport); + lv_obj_set_size(_gps_marker, 12, 12); + lv_obj_set_style_radius(_gps_marker, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(_gps_marker, Theme::primary(), 0); + lv_obj_set_style_bg_opa(_gps_marker, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_gps_marker, 2, 0); + lv_obj_set_style_border_color(_gps_marker, lv_color_white(), 0); + lv_obj_set_style_pad_all(_gps_marker, 0, 0); + lv_obj_add_flag(_gps_marker, LV_OBJ_FLAG_HIDDEN); + + // Register key event on viewport for pan/zoom + lv_obj_add_flag(_viewport, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(_viewport, on_key_event, LV_EVENT_KEY, this); +} + +void MapScreen::show() { + LVGL_LOCK(); + lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_screen); + + // Register viewport in focus group for key events + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + lv_group_add_obj(group, _viewport); + lv_group_focus_obj(_viewport); + } + + // If we have GPS fix and follow mode, center on GPS + if (_follow_gps && _gps && _gps->location.isValid()) { + _center_lat = _gps->location.lat(); + _center_lon = _gps->location.lng(); + _has_gps_fix = true; + } + + update_tiles(); + position_gps_marker(); + position_peer_markers(); +} + +void MapScreen::hide() { + LVGL_LOCK(); + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + lv_group_remove_obj(_viewport); + } + lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); +} + +void MapScreen::update_gps_position() { + if (!_gps) return; + + if (_gps->location.isValid()) { + _has_gps_fix = true; + if (_follow_gps) { + _center_lat = _gps->location.lat(); + _center_lon = _gps->location.lng(); + update_tiles(); + } + position_gps_marker(); + } +} + +void MapScreen::update_peer_locations(const PeerLocation* locations, size_t count) { + LVGL_LOCK(); + + // Clean up old markers + for (int i = 0; i < _peer_marker_count; i++) { + if (_peer_markers[i]) lv_obj_del(_peer_markers[i]); + if (_peer_labels[i]) lv_obj_del(_peer_labels[i]); + _peer_markers[i] = nullptr; + _peer_labels[i] = nullptr; + } + + _peer_marker_count = (count > MAX_PEER_MARKERS) ? MAX_PEER_MARKERS : (int)count; + + for (int i = 0; i < _peer_marker_count; i++) { + // Create marker dot + _peer_markers[i] = lv_obj_create(_viewport); + lv_obj_set_size(_peer_markers[i], 10, 10); + lv_obj_set_style_radius(_peer_markers[i], LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(_peer_markers[i], Theme::success(), 0); + lv_obj_set_style_bg_opa(_peer_markers[i], LV_OPA_COVER, 0); + lv_obj_set_style_border_width(_peer_markers[i], 1, 0); + lv_obj_set_style_border_color(_peer_markers[i], lv_color_white(), 0); + lv_obj_set_style_pad_all(_peer_markers[i], 0, 0); + + // Create name label + _peer_labels[i] = lv_label_create(_viewport); + char name[12]; + snprintf(name, sizeof(name), "%.6s", locations[i].peer_hash.toHex().c_str()); + lv_label_set_text(_peer_labels[i], name); + lv_obj_set_style_text_color(_peer_labels[i], Theme::textPrimary(), 0); + lv_obj_set_style_text_font(_peer_labels[i], &lv_font_montserrat_12, 0); + } + + position_peer_markers(); +} + +void MapScreen::update_tiles() { + // Convert center lat/lon to global pixel coordinates + double center_px, center_py; + TileMath::latlon_to_pixel(_center_lat, _center_lon, _zoom, center_px, center_py); + + // Top-left corner of viewport in global pixels + double vp_left = center_px - VIEWPORT_W / 2.0; + double vp_top = center_py - VIEWPORT_H / 2.0; + + // Which tile contains the top-left corner + int base_tile_x = (int)floor(vp_left / TILE_SIZE); + int base_tile_y = (int)floor(vp_top / TILE_SIZE); + + // Pixel offset of base tile relative to viewport + int offset_x = (int)(base_tile_x * TILE_SIZE - vp_left); + int offset_y = (int)(base_tile_y * TILE_SIZE - vp_top); + + // Position the 2x2 tile grid + // Slot layout: [0]=top-left, [1]=top-right, [2]=bottom-left, [3]=bottom-right + int tile_coords[4][2] = { + {base_tile_x, base_tile_y}, + {base_tile_x + 1, base_tile_y}, + {base_tile_x, base_tile_y + 1}, + {base_tile_x + 1, base_tile_y + 1} + }; + + for (int i = 0; i < 4; i++) { + int col = i % 2; + int row = i / 2; + int px = offset_x + col * TILE_SIZE; + int py = offset_y + row * TILE_SIZE; + lv_obj_set_pos(_tile_imgs[i], px, py); + + // Only reload if tile changed + if (_loaded_zoom != _zoom || + _loaded_tile_x[i] != tile_coords[i][0] || + _loaded_tile_y[i] != tile_coords[i][1]) { + load_tile(i, tile_coords[i][0], tile_coords[i][1], _zoom); + _loaded_tile_x[i] = tile_coords[i][0]; + _loaded_tile_y[i] = tile_coords[i][1]; + } + } + _loaded_zoom = _zoom; +} + +void MapScreen::position_gps_marker() { + if (!_gps || !_gps->location.isValid()) { + lv_obj_add_flag(_gps_marker, LV_OBJ_FLAG_HIDDEN); + return; + } + + double gps_px, gps_py; + TileMath::latlon_to_pixel(_gps->location.lat(), _gps->location.lng(), _zoom, gps_px, gps_py); + + double center_px, center_py; + TileMath::latlon_to_pixel(_center_lat, _center_lon, _zoom, center_px, center_py); + + // Position relative to viewport center + int screen_x = (int)(gps_px - center_px) + VIEWPORT_W / 2 - 6; // -6 for marker center + int screen_y = (int)(gps_py - center_py) + VIEWPORT_H / 2 - 6; + + // Only show if within viewport + if (screen_x >= -12 && screen_x <= VIEWPORT_W && + screen_y >= -12 && screen_y <= VIEWPORT_H) { + lv_obj_set_pos(_gps_marker, screen_x, screen_y); + lv_obj_clear_flag(_gps_marker, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(_gps_marker); + } else { + lv_obj_add_flag(_gps_marker, LV_OBJ_FLAG_HIDDEN); + } +} + +void MapScreen::position_peer_markers() { + // This will be called with stored peer location data + // For now peer positions are set via update_peer_locations + // which creates the markers. Here we just reposition based on + // current center/zoom if the map has moved. + + // Peer positions are stored externally; this method repositions existing markers. + // Actual position update requires the PeerLocation data which is stored + // in TelemetryManager (will be wired in Phase 5). +} + +void MapScreen::update_zoom_label() { + char buf[8]; + snprintf(buf, sizeof(buf), "z:%d", _zoom); + lv_label_set_text(_zoom_label, buf); +} + +void MapScreen::pan(int dx, int dy) { + _follow_gps = false; // Manual pan disables follow mode + + double center_px, center_py; + TileMath::latlon_to_pixel(_center_lat, _center_lon, _zoom, center_px, center_py); + + center_px += dx; + center_py += dy; + + TileMath::pixel_to_latlon(center_px, center_py, _zoom, _center_lat, _center_lon); + + update_tiles(); + position_gps_marker(); + position_peer_markers(); +} + +void MapScreen::load_tile(int slot, int tile_x, int tile_y, int z) { + // Build tile path: "S:tiles/{z}/{x}/{y}.png" + char path[64]; + snprintf(path, sizeof(path), "S:tiles/%d/%d/%d.png", z, tile_x, tile_y); + + lv_img_set_src(_tile_imgs[slot], path); +} + +void MapScreen::on_back_clicked(lv_event_t* event) { + MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); + if (screen->_back_callback) { + screen->_back_callback(); + } +} + +void MapScreen::on_zoom_in_clicked(lv_event_t* event) { + MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); + if (screen->_zoom < 19) { + screen->_zoom++; + screen->update_zoom_label(); + // Invalidate loaded tiles to force reload + screen->_loaded_zoom = -1; + screen->update_tiles(); + screen->position_gps_marker(); + screen->position_peer_markers(); + } +} + +void MapScreen::on_zoom_out_clicked(lv_event_t* event) { + MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); + if (screen->_zoom > 1) { + screen->_zoom--; + screen->update_zoom_label(); + screen->_loaded_zoom = -1; + screen->update_tiles(); + screen->position_gps_marker(); + screen->position_peer_markers(); + } +} + +void MapScreen::on_key_event(lv_event_t* event) { + MapScreen* screen = (MapScreen*)lv_event_get_user_data(event); + uint32_t key = lv_event_get_key(event); + + const int PAN_STEP = 32; // pixels per key press + + switch (key) { + case LV_KEY_UP: + screen->pan(0, -PAN_STEP); + break; + case LV_KEY_DOWN: + screen->pan(0, PAN_STEP); + break; + case LV_KEY_LEFT: + screen->pan(-PAN_STEP, 0); + break; + case LV_KEY_RIGHT: + screen->pan(PAN_STEP, 0); + break; + case '+': + case '=': + if (screen->_zoom < 19) { + screen->_zoom++; + screen->update_zoom_label(); + screen->_loaded_zoom = -1; + screen->update_tiles(); + screen->position_gps_marker(); + screen->position_peer_markers(); + } + break; + case '-': + if (screen->_zoom > 1) { + screen->_zoom--; + screen->update_zoom_label(); + screen->_loaded_zoom = -1; + screen->update_tiles(); + screen->position_gps_marker(); + screen->position_peer_markers(); + } + break; + case 'c': + case 'C': + // Re-center on GPS + screen->_follow_gps = true; + if (screen->_gps && screen->_gps->location.isValid()) { + screen->_center_lat = screen->_gps->location.lat(); + screen->_center_lon = screen->_gps->location.lng(); + screen->update_tiles(); + screen->position_gps_marker(); + screen->position_peer_markers(); + } + break; + default: + break; + } +} + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.h b/lib/tdeck_ui/UI/LXMF/MapScreen.h new file mode 100644 index 00000000..588088ce --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.h @@ -0,0 +1,121 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_MAPSCREEN_H +#define UI_LXMF_MAPSCREEN_H + +#ifdef ARDUINO +#include +#include +#include +#include "Bytes.h" + +class TinyGPSPlus; + +namespace UI { +namespace LXMF { + +/** + * Map Screen + * + * Displays offline OSM tiles from SD card with GPS position overlay + * and peer location markers from telemetry data. + * + * Layout (320x240): + * +-------------------------------------+ + * | <- Back z:14 [+] [-] | 36px header + * +-------------------------------------+ + * | | + * | 2x2 tile grid | + * | (512x512 clipped to | 204px viewport + * | 320x204 viewport) | + * | [GPS dot] | + * +-------------------------------------+ + * + * Tiles loaded from: S:tiles/{z}/{x}/{y}.png + */ +class MapScreen { +public: + struct PeerLocation { + RNS::Bytes peer_hash; + double lat, lon; + uint32_t timestamp; + }; + + using BackCallback = std::function; + + MapScreen(lv_obj_t* parent = nullptr); + ~MapScreen(); + + void set_back_callback(BackCallback callback) { _back_callback = callback; } + void set_gps(TinyGPSPlus* gps) { _gps = gps; } + + void show(); + void hide(); + lv_obj_t* get_object() { return _screen; } + + /** Call from update() when GPS data changes */ + void update_gps_position(); + + /** Update peer markers from telemetry data */ + void update_peer_locations(const PeerLocation* locations, size_t count); + +private: + lv_obj_t* _screen; + lv_obj_t* _header; + lv_obj_t* _viewport; + lv_obj_t* _tile_imgs[4]; // 2x2 grid + lv_obj_t* _gps_marker; + lv_obj_t* _zoom_label; + + // Peer marker objects (up to 32) + static const int MAX_PEER_MARKERS = 32; + lv_obj_t* _peer_markers[MAX_PEER_MARKERS]; + lv_obj_t* _peer_labels[MAX_PEER_MARKERS]; + int _peer_marker_count; + + TinyGPSPlus* _gps; + BackCallback _back_callback; + + // Map state + double _center_lat; + double _center_lon; + int _zoom; + bool _follow_gps; + bool _has_gps_fix; + + // Currently loaded tile coordinates (to avoid redundant reloads) + int _loaded_tile_x[4]; + int _loaded_tile_y[4]; + int _loaded_zoom; + + // Viewport dimensions + static const int VIEWPORT_W = 320; + static const int VIEWPORT_H = 204; + static const int TILE_SIZE = 256; + + void create_header(); + void create_viewport(); + void update_tiles(); + void position_gps_marker(); + void position_peer_markers(); + void update_zoom_label(); + + // Pan by pixel delta + void pan(int dx, int dy); + + // Load a tile image into one of the 4 slots + void load_tile(int slot, int tile_x, int tile_y, int z); + + // Event handlers + static void on_back_clicked(lv_event_t* event); + static void on_zoom_in_clicked(lv_event_t* event); + static void on_zoom_out_clicked(lv_event_t* event); + static void on_key_event(lv_event_t* event); +}; + +} // namespace LXMF +} // namespace UI + +#endif // ARDUINO +#endif // UI_LXMF_MAPSCREEN_H diff --git a/lib/tdeck_ui/UI/LXMF/TileMath.h b/lib/tdeck_ui/UI/LXMF/TileMath.h new file mode 100644 index 00000000..6374d0d2 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/TileMath.h @@ -0,0 +1,82 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_TILEMATH_H +#define UI_LXMF_TILEMATH_H + +#include +#include + +namespace UI { +namespace LXMF { + +/** + * Web Mercator tile coordinate math. + * Standard OSM/Slippy map tile numbering (z/x/y). + */ +namespace TileMath { + + struct TileCoord { + int tile_x; + int tile_y; + int pixel_x; // 0-255, pixel offset within tile + int pixel_y; + }; + + /** + * Convert lat/lon to tile coordinates at a given zoom level. + * Returns the tile x/y and the pixel offset within that tile. + */ + inline TileCoord latlon_to_tile(double lat, double lon, int z) { + double n = (double)(1 << z); + double x = (lon + 180.0) / 360.0 * n; + double lat_rad = lat * M_PI / 180.0; + double y = (1.0 - log(tan(lat_rad) + 1.0 / cos(lat_rad)) / M_PI) / 2.0 * n; + + TileCoord tc; + tc.tile_x = (int)floor(x); + tc.tile_y = (int)floor(y); + tc.pixel_x = (int)((x - tc.tile_x) * 256.0); + tc.pixel_y = (int)((y - tc.tile_y) * 256.0); + return tc; + } + + /** + * Convert tile coordinates (top-left corner) back to lat/lon. + */ + inline void tile_to_latlon(int tile_x, int tile_y, int z, double& lat, double& lon) { + double n = (double)(1 << z); + lon = (double)tile_x / n * 360.0 - 180.0; + double lat_rad = atan(sinh(M_PI * (1.0 - 2.0 * (double)tile_y / n))); + lat = lat_rad * 180.0 / M_PI; + } + + /** + * Convert a global pixel coordinate to lat/lon at a given zoom level. + * Global pixel = tile * 256 + pixel_offset + */ + inline void pixel_to_latlon(double global_px, double global_py, int z, + double& lat, double& lon) { + double n = (double)(1 << z); + lon = global_px / (n * 256.0) * 360.0 - 180.0; + double lat_rad = atan(sinh(M_PI * (1.0 - 2.0 * global_py / (n * 256.0)))); + lat = lat_rad * 180.0 / M_PI; + } + + /** + * Convert lat/lon to global pixel coordinates at a given zoom level. + */ + inline void latlon_to_pixel(double lat, double lon, int z, + double& px, double& py) { + double n = (double)(1 << z); + px = (lon + 180.0) / 360.0 * n * 256.0; + double lat_rad = lat * M_PI / 180.0; + py = (1.0 - log(tan(lat_rad) + 1.0 / cos(lat_rad)) / M_PI) / 2.0 * n * 256.0; + } + +} // namespace TileMath + +} // namespace LXMF +} // namespace UI + +#endif // UI_LXMF_TILEMATH_H diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 56cb6a13..229805d2 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -14,6 +14,8 @@ #include "Packet.h" #include "Transport.h" #include "Destination.h" +#include +#include "Utilities/OS.h" using namespace RNS; @@ -52,8 +54,10 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::Me _settings_screen(nullptr), _propagation_nodes_screen(nullptr), _call_screen(nullptr), + _map_screen(nullptr), _propagation_manager(nullptr), _ble_interface(nullptr), + _gps(nullptr), _initialized(false), _call_state(CallState::IDLE), _lxst_audio(nullptr), @@ -85,6 +89,7 @@ UIManager::~UIManager() { if (_settings_screen) delete _settings_screen; if (_propagation_nodes_screen) delete _propagation_nodes_screen; if (_call_screen) delete _call_screen; + if (_map_screen) delete _map_screen; } bool UIManager::init() { @@ -105,6 +110,7 @@ bool UIManager::init() { _settings_screen = new SettingsScreen(); _propagation_nodes_screen = new PropagationNodesScreen(); _call_screen = new CallScreen(); + _map_screen = new MapScreen(); // Set up callbacks for conversation list screen _conversation_list_screen->set_conversation_selected_callback( @@ -255,6 +261,26 @@ bool UIManager::init() { [this]() { show_status(); } ); + // Set up callback for map button in conversation list + _conversation_list_screen->set_map_callback( + [this]() { show_map(); } + ); + + // Set up callback for map screen back button + _map_screen->set_back_callback( + [this]() { show_conversation_list(); } + ); + + // Set up location sharing callback for chat screen + _chat_screen->set_location_share_callback( + [this](int duration_index) { + on_location_share_requested(_current_peer_hash, duration_index); + } + ); + + // Load telemetry state from SPIFFS + _telemetry_manager.load(); + // Set identity hash and LXMF address on status screen _status_screen->set_identity_hash(_router.identity().hash()); _status_screen->set_lxmf_address(_router.delivery_destination().hash()); @@ -322,6 +348,18 @@ void UIManager::update() { if (_current_screen == SCREEN_STATUS && _status_screen) { _status_screen->refresh(); } + // Update map GPS position if visible + if (_current_screen == SCREEN_MAP && _map_screen) { + _map_screen->update_gps_position(); + update_map_peer_markers(); + } + + // Update telemetry: check expired sessions, send to peers + uint32_t time_now = (uint32_t)Utilities::OS::time(); + std::vector peers_to_send = _telemetry_manager.update(time_now); + for (const auto& peer : peers_to_send) { + send_telemetry(peer); + } } } @@ -338,6 +376,7 @@ void UIManager::show_conversation_list() { _settings_screen->hide(); _propagation_nodes_screen->hide(); if (_call_screen) _call_screen->hide(); + if (_map_screen) _map_screen->hide(); _current_screen = SCREEN_CONVERSATION_LIST; } @@ -351,6 +390,7 @@ void UIManager::show_chat(const Bytes& peer_hash) { _current_peer_hash = peer_hash; _chat_screen->load_conversation(peer_hash, _store); + _chat_screen->set_sharing_state(_telemetry_manager.is_sharing(peer_hash)); _chat_screen->show(); _conversation_list_screen->hide(); _compose_screen->hide(); @@ -359,6 +399,7 @@ void UIManager::show_chat(const Bytes& peer_hash) { _settings_screen->hide(); _propagation_nodes_screen->hide(); if (_call_screen) _call_screen->hide(); + if (_map_screen) _map_screen->hide(); _current_screen = SCREEN_CHAT; } @@ -375,6 +416,7 @@ void UIManager::show_compose() { _status_screen->hide(); _settings_screen->hide(); _propagation_nodes_screen->hide(); + if (_map_screen) _map_screen->hide(); _current_screen = SCREEN_COMPOSE; } @@ -391,6 +433,7 @@ void UIManager::show_announces() { _status_screen->hide(); _settings_screen->hide(); _propagation_nodes_screen->hide(); + if (_map_screen) _map_screen->hide(); _current_screen = SCREEN_ANNOUNCES; } @@ -451,10 +494,28 @@ void UIManager::show_status() { _announce_list_screen->hide(); _settings_screen->hide(); _propagation_nodes_screen->hide(); + if (_map_screen) _map_screen->hide(); _current_screen = SCREEN_STATUS; } +void UIManager::show_map() { + LVGL_LOCK(); + INFO("Showing map screen"); + + _map_screen->show(); + _conversation_list_screen->hide(); + _chat_screen->hide(); + _compose_screen->hide(); + _announce_list_screen->hide(); + _status_screen->hide(); + _settings_screen->hide(); + _propagation_nodes_screen->hide(); + if (_call_screen) _call_screen->hide(); + + _current_screen = SCREEN_MAP; +} + void UIManager::on_conversation_selected(const Bytes& peer_hash) { show_chat(peer_hash); } @@ -475,6 +536,7 @@ void UIManager::show_settings() { _announce_list_screen->hide(); _status_screen->hide(); _propagation_nodes_screen->hide(); + if (_map_screen) _map_screen->hide(); _current_screen = SCREEN_SETTINGS; } @@ -513,6 +575,7 @@ void UIManager::show_propagation_nodes() { _announce_list_screen->hide(); _status_screen->hide(); _settings_screen->hide(); + if (_map_screen) _map_screen->hide(); _current_screen = SCREEN_PROPAGATION_NODES; } @@ -535,9 +598,13 @@ void UIManager::set_ble_interface(Interface* iface) { } void UIManager::set_gps(TinyGPSPlus* gps) { + _gps = gps; if (_conversation_list_screen) { _conversation_list_screen->set_gps(gps); } + if (_map_screen) { + _map_screen->set_gps(gps); + } } void UIManager::on_back_to_conversation_list() { @@ -755,6 +822,29 @@ void UIManager::on_message_received(::LXMF::LXMessage& message) { } } + // Check for telemetry fields + const Bytes* telemetry_field = message.fields_get(Bytes({Telemetry::FIELD_TELEMETRY})); + if (telemetry_field) { + Telemetry::LocationData loc = Telemetry::decode_telemetry(*telemetry_field); + if (loc.valid) { + _telemetry_manager.on_location_received(message.source_hash(), loc); + if (_current_screen == SCREEN_MAP) { + update_map_peer_markers(); + } + } + } + + // Check for Columba cease signal + const Bytes* meta_field = message.fields_get(Bytes({Telemetry::FIELD_COLUMBA_META})); + if (meta_field) { + if (Telemetry::decode_columba_cease(*meta_field)) { + _telemetry_manager.on_cease_received(message.source_hash()); + if (_current_screen == SCREEN_MAP) { + update_map_peer_markers(); + } + } + } + // Update conversation list unread count // TODO: Track unread counts _conversation_list_screen->refresh(); @@ -814,9 +904,142 @@ void UIManager::refresh_current_screen() { break; case SCREEN_QR: break; + case SCREEN_MAP: + break; } } +// ── Telemetry / Location Sharing ── + +void UIManager::send_telemetry(const Bytes& peer_hash) { + if (!_gps || !_gps->location.isValid()) { + return; // No GPS fix, skip + } + + Telemetry::LocationData loc; + loc.lat = _gps->location.lat(); + loc.lon = _gps->location.lng(); + loc.altitude = _gps->altitude.isValid() ? _gps->altitude.meters() : 0.0; + loc.speed = _gps->speed.isValid() ? _gps->speed.mps() : 0.0; + loc.bearing = _gps->course.isValid() ? _gps->course.deg() : 0.0; + loc.accuracy = _gps->hdop.isValid() ? _gps->hdop.hdop() * 5.0 : 0.0; // HDOP to approx meters + loc.timestamp = (uint32_t)Utilities::OS::time(); + + Bytes telemetry_data = Telemetry::encode_telemetry(loc); + + // Find session to get end_time for Columba meta + uint32_t end_time = 0; + for (const auto& session : _telemetry_manager.get_sessions()) { + if (session.peer_hash == peer_hash) { + end_time = session.end_time; + break; + } + } + + // Create a telemetry-only LXMF message (empty content) + Destination source = _router.delivery_destination(); + Bytes content_bytes; + Bytes title; + + Identity dest_identity = Identity::recall(peer_hash); + Destination destination(Type::NONE); + if (dest_identity) { + destination = Destination(dest_identity, Type::Destination::OUT, + Type::Destination::SINGLE, "lxmf", "delivery"); + } + + ::LXMF::LXMessage message(destination, source, content_bytes, title); + if (!dest_identity) { + message.destination_hash(peer_hash); + } + + // Set telemetry field + message.fields_set(Bytes({Telemetry::FIELD_TELEMETRY}), telemetry_data); + + // Set Columba meta field + Bytes meta = Telemetry::encode_columba_meta(end_time, 0, false); + message.fields_set(Bytes({Telemetry::FIELD_COLUMBA_META}), meta); + + message.pack(); + _router.handle_outbound(message); + // Note: telemetry messages are NOT saved to MessageStore (ephemeral) + + DEBUG("Sent telemetry to peer"); +} + +void UIManager::send_cease(const Bytes& peer_hash) { + Destination source = _router.delivery_destination(); + Bytes content_bytes; + Bytes title; + + Identity dest_identity = Identity::recall(peer_hash); + Destination destination(Type::NONE); + if (dest_identity) { + destination = Destination(dest_identity, Type::Destination::OUT, + Type::Destination::SINGLE, "lxmf", "delivery"); + } + + ::LXMF::LXMessage message(destination, source, content_bytes, title); + if (!dest_identity) { + message.destination_hash(peer_hash); + } + + Bytes meta = Telemetry::encode_columba_meta(0, 0, true); + message.fields_set(Bytes({Telemetry::FIELD_COLUMBA_META}), meta); + + message.pack(); + _router.handle_outbound(message); + + INFO("Sent cease to peer"); +} + +void UIManager::on_location_share_requested(const Bytes& peer_hash, int duration_index) { + if (duration_index == 5) { + // Stop sharing + send_cease(peer_hash); + _telemetry_manager.stop_sharing(peer_hash); + _chat_screen->set_sharing_state(false); + return; + } + + Telemetry::ShareDuration duration; + switch (duration_index) { + case 0: duration = Telemetry::ShareDuration::MINUTES_15; break; + case 1: duration = Telemetry::ShareDuration::HOURS_1; break; + case 2: duration = Telemetry::ShareDuration::HOURS_4; break; + case 3: duration = Telemetry::ShareDuration::UNTIL_MIDNIGHT; break; + case 4: duration = Telemetry::ShareDuration::INDEFINITE; break; + default: return; + } + + _telemetry_manager.start_sharing(peer_hash, duration); + _chat_screen->set_sharing_state(true); + + // Send initial telemetry immediately + send_telemetry(peer_hash); +} + +void UIManager::update_map_peer_markers() { + if (!_map_screen) return; + + const auto& locs = _telemetry_manager.get_received_locations(); + if (locs.empty()) return; + + // Convert to MapScreen::PeerLocation array + std::vector peer_locs; + peer_locs.reserve(locs.size()); + for (const auto& loc : locs) { + MapScreen::PeerLocation pl; + pl.peer_hash = loc.peer_hash; + pl.lat = loc.lat; + pl.lon = loc.lon; + pl.timestamp = loc.timestamp; + peer_locs.push_back(pl); + } + + _map_screen->update_peer_locations(peer_locs.data(), peer_locs.size()); +} + // ── LXST Voice Call Implementation ── // NVS breadcrumb for crash debugging (survives reboot, unlike USB CDC output) diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index da5d87ee..f1761687 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -17,6 +17,9 @@ #include "SettingsScreen.h" #include "PropagationNodesScreen.h" #include "CallScreen.h" +#include "MapScreen.h" +#include "../../Telemetry/TelemetryManager.h" +#include "../../Telemetry/TelemetryCodec.h" #include "LXMF/LXMRouter.h" #include "LXMF/PropagationNodeManager.h" #include "LXMF/MessageStore.h" @@ -109,6 +112,11 @@ public: */ void show_settings(); + /** + * Show map screen + */ + void show_map(); + /** * Show propagation nodes screen */ @@ -190,7 +198,8 @@ private: SCREEN_QR, SCREEN_SETTINGS, SCREEN_PROPAGATION_NODES, - SCREEN_CALL + SCREEN_CALL, + SCREEN_MAP }; RNS::Reticulum& _reticulum; @@ -210,9 +219,12 @@ private: SettingsScreen* _settings_screen; PropagationNodesScreen* _propagation_nodes_screen; CallScreen* _call_screen; + MapScreen* _map_screen; ::LXMF::PropagationNodeManager* _propagation_manager; RNS::Interface* _ble_interface; + TinyGPSPlus* _gps; + Telemetry::TelemetryManager _telemetry_manager; bool _initialized; @@ -237,6 +249,10 @@ private: // LXMF message handling void send_message(const RNS::Bytes& dest_hash, const String& content); + void send_telemetry(const RNS::Bytes& peer_hash); + void send_cease(const RNS::Bytes& peer_hash); + void on_location_share_requested(const RNS::Bytes& peer_hash, int duration_index); + void update_map_peer_markers(); // UI updates void refresh_current_screen(); diff --git a/lib/tdeck_ui/library.json b/lib/tdeck_ui/library.json index 24e0a3f3..1f5c8609 100644 --- a/lib/tdeck_ui/library.json +++ b/lib/tdeck_ui/library.json @@ -19,7 +19,8 @@ "+", "+", "+", - "+" + "+", + "+" ], "includeDir": "." }