diff --git a/src/Power.cpp b/src/Power.cpp index 2cb73296b7..780f7ba74c 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -19,6 +19,7 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "Throttle.h" +#include "WaypointStore.h" #include "buzz/buzz.h" #include "configuration.h" #include "main.h" @@ -852,6 +853,9 @@ void Power::powerCommandsCheck() void Power::reboot() { notifyReboot.notifyObservers(NULL); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.saveToFlash(); +#endif #if defined(ARCH_ESP32) ESP.restart(); #elif defined(ARCH_NRF52) @@ -916,6 +920,9 @@ void Power::shutdown() #if HAS_SCREEN messageStore.saveToFlash(); #endif +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.saveToFlash(); +#endif #if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_STM32WL) #ifdef PIN_LED1 ledOff(PIN_LED1); diff --git a/src/WaypointStore.cpp b/src/WaypointStore.cpp new file mode 100644 index 0000000000..6fb180617d --- /dev/null +++ b/src/WaypointStore.cpp @@ -0,0 +1,403 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "Throttle.h" +#include "UptimeClock.h" +#include "WaypointStore.h" +#include "concurrency/LockGuard.h" +#include "gps/RTC.h" +#include +#include +#include + +namespace +{ + +constexpr uint8_t WAYPOINT_STORE_VERSION = 3; +constexpr const char *WAYPOINT_STORE_FILENAME = "/Waypoints_default.wpts"; + +#ifndef WAYPOINT_AUTOSAVE_INTERVAL_SEC +#define WAYPOINT_AUTOSAVE_INTERVAL_SEC (2 * 60 * 60) +#endif + +struct __attribute__((packed)) StoredWaypointRecord { + uint32_t creatorNodeNum; + uint32_t receivedTime; + uint8_t notificationPreferences; + uint16_t payloadLength; + uint8_t payload[meshtastic_Waypoint_size]; +}; + +bool decodeWaypointPayload(const uint8_t *payload, size_t payloadLength, meshtastic_Waypoint &wp) +{ + memset(&wp, 0, sizeof(wp)); + return pb_decode_from_bytes(payload, payloadLength, &meshtastic_Waypoint_msg, &wp); +} + +uint16_t encodeWaypointPayload(const meshtastic_Waypoint &wp, uint8_t *payload, size_t payloadCapacity) +{ + return (uint16_t)pb_encode_to_bytes(payload, payloadCapacity, &meshtastic_Waypoint_msg, &wp); +} + +static bool g_waypointStoreHasUnsavedChanges = false; +static uint32_t g_lastWaypointAutoSaveMs = 0; + +uint32_t autosaveIntervalMs() +{ + uint32_t sec = (uint32_t)WAYPOINT_AUTOSAVE_INTERVAL_SEC; + if (sec < 60) + sec = 60; + return sec * 1000UL; +} + +void markWaypointStoreUnsaved() +{ + g_waypointStoreHasUnsavedChanges = true; + if (g_lastWaypointAutoSaveMs == 0) + g_lastWaypointAutoSaveMs = Time::getMillis(); +} + +void persistWaypointStore() +{ + LOG_INFO("Autosaving WaypointStore to flash"); + waypointStore.saveToFlash(); +} + +} // namespace + +WaypointStore waypointStore; + +void WaypointStore::notifyChanged() +{ + notifyObservers(this); +} + +bool WaypointStore::isExpired(const meshtastic_Waypoint &wp, uint32_t now) +{ + if (wp.expire == 0) + return false; + + if (now == 0) + now = getTime(); + + return now != 0 && wp.expire <= now; +} + +bool WaypointStore::isExpired(const StoredWaypoint &entry, uint32_t now) +{ + return isExpired(entry.waypoint, now); +} + +uint8_t WaypointStore::notificationPreferencesFromWaypoint(const meshtastic_Waypoint &wp) +{ + uint8_t preferences = 0; + if (wp.notify_on_enter) + preferences |= WAYPOINT_NOTIFY_ENTER; + if (wp.notify_on_exit) + preferences |= WAYPOINT_NOTIFY_EXIT; + if (wp.notify_favorites_only) + preferences |= WAYPOINT_NOTIFY_FAVORITES_ONLY; + return preferences; +} + +uint8_t WaypointStore::mergeNotificationPreferences(bool locallyAuthored, bool hasExisting, uint8_t existingPreferences, + const meshtastic_Waypoint &incoming) +{ + if (locallyAuthored) + return notificationPreferencesFromWaypoint(incoming); + return hasExisting ? existingPreferences : 0; +} + +void WaypointStore::clearWireNotificationPreferences(meshtastic_Waypoint &wp) +{ + wp.notify_on_enter = false; + wp.notify_on_exit = false; + wp.notify_favorites_only = false; +} + +const StoredWaypoint *WaypointStore::findWaypoint(uint32_t id) const +{ + for (const StoredWaypoint &entry : waypoints) { + if (entry.waypoint.id == id) + return &entry; + } + return nullptr; +} + +bool WaypointStore::removeWaypointById(uint32_t id) +{ + for (auto it = waypoints.begin(); it != waypoints.end(); ++it) { + if (it->waypoint.id == id) { + waypoints.erase(it); + return true; + } + } + + return false; +} + +bool WaypointStore::removeWaypoint(uint32_t id) +{ + const bool removed = removeWaypointById(id); + if (!removed) + return false; + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + + return true; +} + +bool WaypointStore::setNotificationPreference(uint32_t id, WaypointNotificationPreference preference, bool enabled) +{ + for (StoredWaypoint &entry : waypoints) { + if (entry.waypoint.id != id) + continue; + + const uint8_t previous = entry.notificationPreferences; + if (enabled) + entry.notificationPreferences |= preference; + else + entry.notificationPreferences &= ~preference; + if (entry.notificationPreferences == previous) + return true; + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + return true; + } + + return false; +} + +void WaypointStore::addStoredWaypoint(const StoredWaypoint &entry) +{ + removeWaypointById(entry.waypoint.id); + + waypoints.push_front(entry); + while (waypoints.size() > WAYPOINT_HISTORY_LIMIT) + waypoints.pop_back(); +} + +bool WaypointStore::addFromPacket(const meshtastic_MeshPacket &packet, bool locallyAuthored, StoredWaypoint *stored) +{ + StoredWaypoint entry; + if (!decodeWaypointPayload(packet.decoded.payload.bytes, packet.decoded.payload.size, entry.waypoint)) + return false; + + const StoredWaypoint *existing = findWaypoint(entry.waypoint.id); + entry.notificationPreferences = mergeNotificationPreferences( + locallyAuthored, existing != nullptr, existing ? existing->notificationPreferences : 0, entry.waypoint); + clearWireNotificationPreferences(entry.waypoint); + entry.receivedTime = packet.rx_time ? packet.rx_time : getTime(); + entry.creatorNodeNum = getFrom(&packet); + + if (stored) + *stored = entry; + + if (isExpired(entry, entry.receivedTime)) { + // Respect the lock: only the node a waypoint is locked to may delete it on our device. + // An unauthorized deletion attempt is ignored entirely, rather than applied locally. + for (const auto &storedEntry : waypoints) { + if (storedEntry.waypoint.id != entry.waypoint.id) + continue; + if (storedEntry.waypoint.locked_to != 0 && storedEntry.waypoint.locked_to != entry.creatorNodeNum) + return true; // Packet handled, but the deletion is not honored + break; + } + + removeWaypoint(entry.waypoint.id); + return true; + } + + addStoredWaypoint(entry); + +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + + return true; +} + +bool WaypointStore::purgeExpired(uint32_t now) +{ + if (now == 0) + now = getTime(); + if (now == 0) + return false; + + bool changed = false; + for (auto it = waypoints.begin(); it != waypoints.end();) { + if (!isExpired(*it, now)) { + ++it; + continue; + } + + it = waypoints.erase(it); + changed = true; + } + + if (changed) { +#if ENABLE_WAYPOINT_PERSISTENCE + markWaypointStoreUnsaved(); +#endif + notifyChanged(); + } + + return changed; +} + +void WaypointStore::saveToFlash() +{ + purgeExpired(); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + if (!g_waypointStoreHasUnsavedChanges) + return; + + spiLock->lock(); + FSCom.mkdir("/"); + spiLock->unlock(); + + SafeFile f(WAYPOINT_STORE_FILENAME, false); + + spiLock->lock(); + const uint8_t version = WAYPOINT_STORE_VERSION; + size_t countFull = waypoints.size(); + if (countFull > WAYPOINT_HISTORY_LIMIT) + countFull = WAYPOINT_HISTORY_LIMIT; + if (countFull > UINT8_MAX) + countFull = UINT8_MAX; + const uint8_t count = (uint8_t)countFull; + + f.write(&version, 1); + f.write(&count, 1); + + for (uint8_t i = 0; i < count; ++i) { + StoredWaypointRecord rec = {}; + rec.creatorNodeNum = waypoints[i].creatorNodeNum; + rec.receivedTime = waypoints[i].receivedTime; + rec.notificationPreferences = waypoints[i].notificationPreferences; + rec.payloadLength = encodeWaypointPayload(waypoints[i].waypoint, rec.payload, sizeof(rec.payload)); + f.write(reinterpret_cast(&rec), sizeof(rec)); + } + spiLock->unlock(); + f.close(); +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif +} + +void WaypointStore::loadFromFlash() +{ + std::deque().swap(waypoints); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + { + concurrency::LockGuard guard(spiLock); + + if (FSCom.exists(WAYPOINT_STORE_FILENAME)) { + auto f = FSCom.open(WAYPOINT_STORE_FILENAME, FILE_O_READ); + if (f) { + uint8_t version = 0; + uint8_t count = 0; + f.readBytes(reinterpret_cast(&version), 1); + f.readBytes(reinterpret_cast(&count), 1); + + if (version != WAYPOINT_STORE_VERSION) { + LOG_WARN("WaypointStore version mismatch (%u)", version); + f.close(); + } else { + if (count > WAYPOINT_HISTORY_LIMIT) + count = WAYPOINT_HISTORY_LIMIT; + + for (uint8_t i = 0; i < count; ++i) { + StoredWaypoint entry; + StoredWaypointRecord rec = {}; + if (f.readBytes(reinterpret_cast(&rec), sizeof(rec)) != sizeof(rec)) + break; + if (rec.payloadLength == 0 || rec.payloadLength > sizeof(rec.payload)) { + LOG_WARN("WaypointStore skipping corrupt record %u", i); + continue; + } + if (!decodeWaypointPayload(rec.payload, rec.payloadLength, entry.waypoint)) + continue; + entry.receivedTime = rec.receivedTime; + entry.creatorNodeNum = rec.creatorNodeNum; + entry.notificationPreferences = rec.notificationPreferences; + + if (isExpired(entry.waypoint)) + continue; + waypoints.push_back(entry); + } + f.close(); + } + } + } + } +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif +} + +void WaypointStore::clearAllWaypoints() +{ + const bool hadWaypoints = !waypoints.empty(); + + std::deque().swap(waypoints); + +#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom) + SafeFile f(WAYPOINT_STORE_FILENAME, false); + { + concurrency::LockGuard guard(spiLock); + const uint8_t version = WAYPOINT_STORE_VERSION; + const uint8_t count = 0; + f.write(&version, 1); + f.write(&count, 1); + } + f.close(); +#endif + +#if ENABLE_WAYPOINT_PERSISTENCE + g_waypointStoreHasUnsavedChanges = false; + g_lastWaypointAutoSaveMs = Time::getMillis(); +#endif + + if (hadWaypoints) + notifyChanged(); +} + +#if ENABLE_WAYPOINT_PERSISTENCE +void waypointStoreAutosaveTick() +{ + if (!g_waypointStoreHasUnsavedChanges) { + if (g_lastWaypointAutoSaveMs == 0) + g_lastWaypointAutoSaveMs = Time::getMillis(); + return; + } + + if (g_lastWaypointAutoSaveMs == 0) { + g_lastWaypointAutoSaveMs = Time::getMillis(); + return; + } + + Throttle::execute(&g_lastWaypointAutoSaveMs, autosaveIntervalMs(), persistWaypointStore); +} +#endif + +#endif diff --git a/src/WaypointStore.h b/src/WaypointStore.h new file mode 100644 index 0000000000..1dbab78e6c --- /dev/null +++ b/src/WaypointStore.h @@ -0,0 +1,75 @@ +#pragma once + +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#ifndef ENABLE_WAYPOINT_PERSISTENCE +#define ENABLE_WAYPOINT_PERSISTENCE 1 +#endif + +#ifndef WAYPOINT_HISTORY_LIMIT +#define WAYPOINT_HISTORY_LIMIT 10 +#endif + +#include "Observer.h" +#include "mesh/MeshTypes.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +enum WaypointNotificationPreference : uint8_t { + WAYPOINT_NOTIFY_ENTER = 1 << 0, + WAYPOINT_NOTIFY_EXIT = 1 << 1, + WAYPOINT_NOTIFY_FAVORITES_ONLY = 1 << 2, +}; + +struct StoredWaypoint { + meshtastic_Waypoint waypoint = meshtastic_Waypoint_init_zero; + uint32_t receivedTime = 0; + NodeNum creatorNodeNum = 0; + uint8_t notificationPreferences = 0; + + bool notificationEnabled(WaypointNotificationPreference preference) const + { + return (notificationPreferences & preference) != 0; + } +}; + +class WaypointStore : public Observable +{ + public: + bool addFromPacket(const meshtastic_MeshPacket &packet, bool locallyAuthored, StoredWaypoint *stored = nullptr); + bool purgeExpired(uint32_t now = 0); + bool removeWaypoint(uint32_t id); + bool setNotificationPreference(uint32_t id, WaypointNotificationPreference preference, bool enabled); + + const std::deque &getWaypoints() const { return waypoints; } + const StoredWaypoint *findWaypoint(uint32_t id) const; + + void saveToFlash(); + void loadFromFlash(); + void clearAllWaypoints(); + + static bool isExpired(const meshtastic_Waypoint &wp, uint32_t now = 0); + static bool isExpired(const StoredWaypoint &entry, uint32_t now = 0); + static uint8_t notificationPreferencesFromWaypoint(const meshtastic_Waypoint &wp); + static uint8_t mergeNotificationPreferences(bool locallyAuthored, bool hasExisting, uint8_t existingPreferences, + const meshtastic_Waypoint &incoming); + static void clearWireNotificationPreferences(meshtastic_Waypoint &wp); + + private: + void addStoredWaypoint(const StoredWaypoint &entry); + bool removeWaypointById(uint32_t id); + void notifyChanged(); + + std::deque waypoints; +}; + +#if ENABLE_WAYPOINT_PERSISTENCE +void waypointStoreAutosaveTick(); +#endif + +extern WaypointStore waypointStore; + +#endif diff --git a/src/WaypointUtils.h b/src/WaypointUtils.h new file mode 100644 index 0000000000..ec78c214e2 --- /dev/null +++ b/src/WaypointUtils.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace WaypointUtils +{ + +inline std::string utf8FromCodepoint(uint32_t codepoint) +{ + if (codepoint == 0 || (codepoint >= 0xD800 && codepoint <= 0xDFFF) || codepoint > 0x10FFFF) + return ""; + + char buf[4]; + if (codepoint <= 0x7F) { + buf[0] = static_cast(codepoint); + return std::string(buf, 1); + } + if (codepoint <= 0x7FF) { + buf[0] = static_cast(0xC0 | (codepoint >> 6)); + buf[1] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 2); + } + if (codepoint <= 0xFFFF) { + buf[0] = static_cast(0xE0 | (codepoint >> 12)); + buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[2] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 3); + } + + buf[0] = static_cast(0xF0 | (codepoint >> 18)); + buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[3] = static_cast(0x80 | (codepoint & 0x3F)); + return std::string(buf, 4); +} + +} // namespace WaypointUtils diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 93e59e31e6..6423c1b00f 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -8,6 +8,7 @@ #include "mesh/MeshService.h" #include "mesh/NodeDB.h" #include "modules/NodeInfoModule.h" +#include "modules/WaypointModule.h" #include #include #include @@ -35,6 +36,10 @@ static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQual if (nodeDB) nodeDB->backfillHeardAt(); } +#if !MESHTASTIC_EXCLUDE_WAYPOINT && HAS_SCREEN + if (waypointModule && oldQuality != newQuality) + waypointModule->onDeviceTimeChanged(); +#endif } RTCQuality getRTCQuality() diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8cf3f8c926..97f8d72786 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1372,6 +1372,7 @@ void Screen::setFrames(FrameFocus focus) return; } + const FramesetInfo previousFramesetInfo = framesetInfo; uint8_t originalPosition = ui->getUiState()->currentFrame; uint8_t previousFrameCount = framesetInfo.frameCount; FramesetInfo fsi; // Location of specific frames, for applying focus parameter @@ -1624,8 +1625,15 @@ void Screen::setFrames(FrameFocus focus) break; case FOCUS_PRESERVE: - // No more adjustment - force stay on same index - if (previousFrameCount > fsi.frameCount) { + if (previousFramesetInfo.positions.waypoint == 255 && fsi.positions.waypoint != 255) { + const uint8_t target = originalPosition >= fsi.positions.waypoint ? originalPosition + 1 : originalPosition; + ui->switchToFrame(target); + } else if (previousFramesetInfo.positions.waypoint != 255 && fsi.positions.waypoint == 255) { + const uint8_t target = originalPosition > previousFramesetInfo.positions.waypoint + ? originalPosition - 1 + : std::min(originalPosition, fsi.frameCount - 1); + ui->switchToFrame(target); + } else if (previousFrameCount > fsi.frameCount) { ui->switchToFrame(originalPosition - 1); } else if (previousFrameCount < fsi.frameCount) { ui->switchToFrame(originalPosition + 1); @@ -2360,6 +2368,9 @@ int Screen::handleInputEvent(const InputEvent *event) menuHandler::nodeListMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.wifi) { menuHandler::wifiBaseMenu(); + } else if (framesetInfo.positions.waypoint != 255 && + this->ui->getUiState()->currentFrame == framesetInfo.positions.waypoint) { + menuHandler::waypointBaseMenu(); } } else if (event->inputEvent == INPUT_BROKER_BACK) { showFrame(FrameDirection::PREVIOUS); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index b7eb83a20e..6aec0ff654 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -28,11 +28,16 @@ #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/ExternalNotificationModule.h" +#include "modules/GeofenceModule.h" #include "modules/KeyVerificationModule.h" #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR #include "modules/Telemetry/EnvironmentTelemetry.h" #endif #include "modules/TraceRouteModule.h" +#include "modules/WaypointModule.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "WaypointStore.h" +#endif #include #include #include @@ -45,6 +50,10 @@ namespace graphics namespace { +#if !MESHTASTIC_EXCLUDE_WAYPOINT +uint32_t selectedGeofenceWaypointId = 0; +#endif + // Caller must ensure the provided options array outlives the banner callback. template BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOption (&options)[N], @@ -2400,6 +2409,158 @@ void menuHandler::removeFavoriteMenu() screen->showOverlayBanner(bannerOptions); } +void menuHandler::waypointBaseMenu() +{ + enum optionsNumbers { Back, GeofenceAlerts, RemoveWaypoint }; + static const char *optionsArray[] = {"Back", "Geofence Alerts", "Remove Waypoint"}; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Waypoint Action"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == GeofenceAlerts) { + menuQueue = GeofenceWaypointMenu; + screen->runNow(); + } else if (selected == RemoveWaypoint) { + menuQueue = RemoveWaypointMenu; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::geofenceWaypointMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + static const char *optionsArray[WAYPOINT_HISTORY_LIMIT + 1]; + static uint32_t waypointIds[WAYPOINT_HISTORY_LIMIT + 1]; + static std::string labelStorage[WAYPOINT_HISTORY_LIMIT + 1]; + + optionsArray[0] = "Back"; + int options = 1; + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (options > WAYPOINT_HISTORY_LIMIT || !GeofenceModule::hasGeofence(entry.waypoint)) + continue; + std::string name = sanitizeString(entry.waypoint.name); + if (name.empty()) + name = "Unnamed Geofence"; + labelStorage[options] = name.substr(0, 20); + optionsArray[options] = labelStorage[options].c_str(); + waypointIds[options] = entry.waypoint.id; + options++; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = options > 1 ? "Geofence Alerts" : "No Geofences"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = WaypointBaseMenu; + } else { + selectedGeofenceWaypointId = waypointIds[selected]; + menuQueue = GeofenceOptionsMenu; + } + screen->runNow(); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + +void menuHandler::geofenceOptionsMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) { + menuQueue = GeofenceWaypointMenu; + screen->runNow(); + return; + } + + static std::string labels[4]; + static const char *optionsArray[4]; + labels[0] = "Back"; + labels[1] = std::string("Enter Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_ENTER) ? "On" : "Off"); + labels[2] = std::string("Exit Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_EXIT) ? "On" : "Off"); + labels[3] = std::string("Favorites Only: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY) ? "On" : "Off"); + for (size_t i = 0; i < 4; ++i) + optionsArray[i] = labels[i].c_str(); + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Geofence Alerts"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 4; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = GeofenceWaypointMenu; + } else { + const StoredWaypoint *current = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (current) { + const WaypointNotificationPreference preference = + selected == 1 ? WAYPOINT_NOTIFY_ENTER + : (selected == 2 ? WAYPOINT_NOTIFY_EXIT : WAYPOINT_NOTIFY_FAVORITES_ONLY); + waypointStore.setNotificationPreference(selectedGeofenceWaypointId, preference, + !current->notificationEnabled(preference)); + } + menuQueue = GeofenceOptionsMenu; + } + screen->runNow(); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + +void menuHandler::removeWaypointMenu() +{ +#if MESHTASTIC_EXCLUDE_WAYPOINT + menuQueue = MenuNone; +#else + static const char *optionsArray[WAYPOINT_HISTORY_LIMIT + 1]; + static uint32_t waypointIds[WAYPOINT_HISTORY_LIMIT + 1]; + static std::string labelStorage[WAYPOINT_HISTORY_LIMIT + 1]; + + optionsArray[0] = "Back"; + int options = 1; + + for (const auto &entry : waypointStore.getWaypoints()) { + if (options > WAYPOINT_HISTORY_LIMIT) + break; + std::string name = sanitizeString(entry.waypoint.name); + if (name.empty()) + name = "Unnamed Waypoint"; + labelStorage[options] = name.substr(0, 20); + optionsArray[options] = labelStorage[options].c_str(); + waypointIds[options] = entry.waypoint.id; + options++; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Remove Waypoint"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + menuQueue = WaypointBaseMenu; + screen->runNow(); + return; + } + const uint32_t waypointId = waypointIds[selected]; + LOG_INFO("Removing waypoint 0x%08x", waypointId); + if (waypointModule) + waypointModule->broadcastDelete(waypointId); + else + waypointStore.removeWaypoint(waypointId); + screen->setFrames(graphics::Screen::FOCUS_DEFAULT); + }; + screen->showOverlayBanner(bannerOptions); +#endif +} + void menuHandler::traceRouteMenu() { screen->showNodePicker("Node to Trace", 30000, [](uint32_t nodenum) -> void { @@ -3037,6 +3198,18 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case RemoveFavorite: removeFavoriteMenu(); break; + case WaypointBaseMenu: + waypointBaseMenu(); + break; + case GeofenceWaypointMenu: + geofenceWaypointMenu(); + break; + case GeofenceOptionsMenu: + geofenceOptionsMenu(); + break; + case RemoveWaypointMenu: + removeWaypointMenu(); + break; case TraceRouteMenu: traceRouteMenu(); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 9650932232..7ffbfae1c9 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -37,6 +37,10 @@ class menuHandler NodePickerMenu, ManageNodeMenu, RemoveFavorite, + WaypointBaseMenu, + GeofenceWaypointMenu, + GeofenceOptionsMenu, + RemoveWaypointMenu, TestMenu, NumberTest, EnvironmentTelemetryMenu, @@ -108,6 +112,10 @@ class menuHandler static void manageNodeMenu(); static void addFavoriteMenu(); static void removeFavoriteMenu(); + static void waypointBaseMenu(); + static void geofenceWaypointMenu(); + static void geofenceOptionsMenu(); + static void removeWaypointMenu(); static void traceRouteMenu(); static void testMenu(); static void numberTest(); diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 24a5eeaf63..6f2f6ff650 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -566,9 +566,7 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon); float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian); float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing); - // Shrink size by 2px - int size = FONT_HEIGHT_SMALL - 5; - CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg); + drawRelativeCompassArrow(display, centerX, centerY, relativeBearingDeg); /* float angle = relativeBearing * DEG_TO_RAD; float halfSize = size / 2.0; @@ -613,6 +611,12 @@ void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int display->drawString(centerX, y, "?"); } +void drawRelativeCompassArrow(OLEDDisplay *display, int16_t centerX, int16_t centerY, float relativeBearingDeg) +{ + const int size = FONT_HEIGHT_SMALL - 5; + CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg); +} + // ============================= // Main Screen Functions // ============================= diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index d1e8bac1dd..3d14b6466f 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -45,6 +45,7 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 // Extras renderers void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeadingRadian, double userLat, double userLon); +void drawRelativeCompassArrow(OLEDDisplay *display, int16_t centerX, int16_t centerY, float relativeBearingDeg); // Screen frame functions void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h index 84891b1fec..0f48039588 100644 --- a/src/graphics/niche/InkHUD/Applet.h +++ b/src/graphics/niche/InkHUD/Applet.h @@ -129,6 +129,7 @@ class Applet : public GFX virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification virtual class MapApplet *asMapApplet() { return nullptr; } // Returns non-null only for MapApplet and its subclasses + virtual class WaypointListApplet *asWaypointListApplet() { return nullptr; } // Returns non-null only for WaypointListApplet static uint16_t getHeaderHeight(); // How tall the "standard" applet header is diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp index 5c8d78cb80..795447b749 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp @@ -2,6 +2,8 @@ #include "./MapApplet.h" #include "./MapTile.h" +#include "WaypointStore.h" +#include "WaypointUtils.h" #include #include @@ -21,11 +23,95 @@ static int tileTyAt(int tileIndex); static int tileMetadataZoomCount(); static int tileMetadataZoomAt(int index); -// Observe GPS position updates so the map redraws whenever a new location arrives. +namespace +{ + +bool waypointHasAnchor(const meshtastic_Waypoint &waypoint) +{ + return waypoint.has_latitude_i && waypoint.has_longitude_i; +} + +bool waypointHasMapGeometry(const meshtastic_Waypoint &waypoint) +{ + return waypointHasAnchor(waypoint) || waypoint.has_bounding_box; +} + +void includeMapPoint(float latNode, float lngNode, float lngCenter, float &northernmost, float &southernmost, float &easternmost, + float &westernmost) +{ + northernmost = max(northernmost, latNode); + southernmost = min(southernmost, latNode); + + const float degEastward = fmodf(((lngNode - lngCenter) + 360.0f), 360.0f); + const float degWestward = fabsf(fmodf(((lngNode - lngCenter) - 360.0f), 360.0f)); + if (degEastward < degWestward) + easternmost = max(easternmost, lngCenter + degEastward); + else + westernmost = min(westernmost, lngCenter - degWestward); +} + +} // namespace + +bool InkHUD::MapApplet::mapWaypointIconGlyph(uint32_t codepoint, std::string &glyph) +{ + if (!codepoint) + return false; + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(codepoint); + if (utf8.empty()) + return false; + + glyph = getFont().decodeUTF8(utf8); + return glyph.size() == 1 && glyph[0] != '\x1A' && glyph[0] != '\x7F'; +} + +static int16_t markerScreenX(float eastMeters, float metersToPx, uint16_t width) +{ + return (width * 0.5f) + (eastMeters * metersToPx); +} + +static int16_t markerScreenY(float northMeters, float metersToPx, uint16_t height) +{ + return (height * 0.5f) - (northMeters * metersToPx); +} + +uint8_t InkHUD::MapApplet::fallbackBadgeNumber(const WaypointMarker &entry) +{ + uint8_t badge = 0; + + for (auto it = waypointMarkers.rbegin(); it != waypointMarkers.rend(); ++it) { + if (!it->hasMarker) + continue; + + std::string glyph; + if (mapWaypointIconGlyph(it->icon, glyph)) + continue; + + if (it->id == entry.id) + return badge; + + if (badge < 9) + ++badge; + } + + return 0; +} + +void InkHUD::MapApplet::drawWaypointFallbackMarker(const WaypointMarker &entry, int16_t x, int16_t y) +{ + char badgeText[3]; + snprintf(badgeText, sizeof(badgeText), "%u", (unsigned)fallbackBadgeNumber(entry)); + + // Keep fallback digits centered so they read like map markers. + setFont(fontSmall); + printAt(x, y + 1, badgeText, CENTER, MIDDLE); +} + InkHUD::MapApplet::MapApplet() { if (gpsStatus) gpsStatusObserver.observe(&gpsStatus->onNewStatus); + waypointStoreObserver.observe(&waypointStore); } int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status) @@ -39,6 +125,17 @@ int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status) return 0; } +int InkHUD::MapApplet::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + + if (!isActive()) + return 0; + + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return 0; +} + // Zoom in one step from the current display zoom. void InkHUD::MapApplet::zoomIn() { @@ -72,6 +169,19 @@ void InkHUD::MapApplet::resetZoom() { s_zoomLocked = false; s_lockedZoom = -1; + focusedWaypointId = 0; +} + +bool InkHUD::MapApplet::focusWaypoint(uint32_t waypointId) +{ + const StoredWaypoint *entry = waypointStore.findWaypoint(waypointId); + if (!entry || WaypointStore::isExpired(*entry) || !waypointHasMapGeometry(entry->waypoint)) + return false; + + s_zoomLocked = false; + s_lockedZoom = -1; + focusedWaypointId = waypointId; + return true; } bool InkHUD::MapApplet::canZoomIn() const @@ -405,7 +515,7 @@ void InkHUD::MapApplet::onRender(bool full) chosenZoom = s_lockedZoom; float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad); chosenMetersToPx = 1.0f / mpp; - } else if ((markers.empty() || metersToPxFit <= 0.0f) && nzooms > 0) { + } else if (((markers.empty() && waypointMarkers.empty()) || metersToPxFit <= 0.0f) && nzooms > 0) { // No spread to fit (own node only, or single remote node at map center). Use highest zoom at native scale. chosenZoom = zooms[0]; float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad); @@ -490,6 +600,48 @@ void InkHUD::MapApplet::onRender(bool full) setTextColor(BLACK); } + // Draw waypoint markers after nodes so the boxed icons stay legible. + for (const WaypointMarker &m : waypointMarkers) { + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + const int16_t radiusPx = std::max(1, (int16_t)lroundf(m.geofenceRadiusMeters * metersToPx)); + const int16_t centerX = markerScreenX(m.eastMeters, metersToPx, width()); + const int16_t centerY = markerScreenY(m.northMeters, metersToPx, height()); + drawCircle(centerX, centerY, radiusPx, BLACK); + } + + if (m.hasBoundingBox) { + const int16_t westX = markerScreenX(m.boxWestMeters, metersToPx, width()); + const int16_t eastX = markerScreenX(m.boxEastMeters, metersToPx, width()); + const int16_t northY = markerScreenY(m.boxNorthMeters, metersToPx, height()); + const int16_t southY = markerScreenY(m.boxSouthMeters, metersToPx, height()); + const int16_t left = std::min(westX, eastX); + const int16_t right = std::max(westX, eastX); + const int16_t top = std::min(northY, southY); + const int16_t bottom = std::max(northY, southY); + drawRect(left, top, std::max(1, right - left + 1), std::max(1, bottom - top + 1), BLACK); + } + + if (!m.hasMarker) + continue; + + int16_t x = markerScreenX(m.eastMeters, metersToPx, width()); + int16_t y = markerScreenY(m.northMeters, metersToPx, height()); + constexpr int outlinePad = 1; + const int boxSize = fontSmall.lineHeight() + 2; + const int radius = max(2, boxSize / 6); + + fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE); + drawRoundRect(x - (boxSize / 2), y - (boxSize / 2), boxSize, boxSize, radius, BLACK); + + std::string glyph; + if (mapWaypointIconGlyph(m.icon, glyph)) { + setFont(fontSmall); + printAt(x, y + 1, glyph, CENTER, MIDDLE); + } else { + drawWaypointFallbackMarker(m, x, y); + } + } + // Dual map scale bars if (metersToPx <= 0.0f) return; @@ -588,6 +740,25 @@ void InkHUD::MapApplet::onRender(bool full) void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) { + if (focusedWaypointId != 0) { + const StoredWaypoint *entry = waypointStore.findWaypoint(focusedWaypointId); + if (entry && !WaypointStore::isExpired(*entry) && waypointHasMapGeometry(entry->waypoint)) { + *lat = waypointHasAnchor(entry->waypoint) + ? entry->waypoint.latitude_i * 1e-7f + : ((float)entry->waypoint.bounding_box.latitude_south_i + entry->waypoint.bounding_box.latitude_north_i) * + 0.5e-7f; + *lng = waypointHasAnchor(entry->waypoint) + ? entry->waypoint.longitude_i * 1e-7f + : ((float)entry->waypoint.bounding_box.longitude_west_i + entry->waypoint.bounding_box.longitude_east_i) * + 0.5e-7f; + latCenter = *lat; + lngCenter = *lng; + centerIsOurNode = false; + return; + } + focusedWaypointId = 0; + } + // If we have a valid position for our own node, use that as the anchor const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); meshtastic_PositionLite ourSelfPos; @@ -646,6 +817,34 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) positionCount++; } + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (!waypointHasMapGeometry(entry.waypoint)) + continue; + + const float latDeg = + waypointHasAnchor(entry.waypoint) + ? (entry.waypoint.latitude_i * 1e-7f) + : ((float)entry.waypoint.bounding_box.latitude_south_i + entry.waypoint.bounding_box.latitude_north_i) * + 0.5e-7f; + const float lngDeg = + waypointHasAnchor(entry.waypoint) + ? (entry.waypoint.longitude_i * 1e-7f) + : ((float)entry.waypoint.bounding_box.longitude_west_i + entry.waypoint.bounding_box.longitude_east_i) * + 0.5e-7f; + float latRad = latDeg * DEG_TO_RAD; + float lngRad = lngDeg * DEG_TO_RAD; + float x = cos(latRad) * cos(lngRad); + float y = cos(latRad) * sin(lngRad); + float z = sin(latRad); + + xAvg += x; + yAvg += y; + zAvg += z; + positionCount++; + } + // All NodeDB processed, find mean values if (positionCount == 0) return; @@ -759,15 +958,24 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng) float latNode = pos.latitude_i * 1e-7; float lngNode = pos.longitude_i * 1e-7; - northernmost = max(northernmost, latNode); - southernmost = min(southernmost, latNode); + includeMapPoint(latNode, lngNode, lngCenter, northernmost, southernmost, easternmost, westernmost); + } - float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees east from center to node - float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees west from center to node - if (degEastward < degWestward) - easternmost = max(easternmost, lngCenter + degEastward); - else - westernmost = min(westernmost, lngCenter - degWestward); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (waypointHasAnchor(entry.waypoint)) + includeMapPoint(entry.waypoint.latitude_i * 1e-7f, entry.waypoint.longitude_i * 1e-7f, lngCenter, northernmost, + southernmost, easternmost, westernmost); + + if (entry.waypoint.has_bounding_box) { + includeMapPoint(entry.waypoint.bounding_box.latitude_south_i * 1e-7f, + entry.waypoint.bounding_box.longitude_west_i * 1e-7f, lngCenter, northernmost, southernmost, + easternmost, westernmost); + includeMapPoint(entry.waypoint.bounding_box.latitude_north_i * 1e-7f, + entry.waypoint.bounding_box.longitude_east_i * 1e-7f, lngCenter, northernmost, southernmost, + easternmost, westernmost); + } } // Todo: check for issues with map spans >180 deg. MQTT only.. @@ -787,12 +995,48 @@ void InkHUD::MapApplet::getMapSize(uint32_t *widthMeters, uint32_t *heightMeters *widthMeters = 0; *heightMeters = 0; + if (focusedWaypointId != 0) { + for (const WaypointMarker &m : waypointMarkers) { + if (m.id != focusedWaypointId) + continue; + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + *widthMeters = m.geofenceRadiusMeters * 2; + *heightMeters = m.geofenceRadiusMeters * 2; + } + if (m.hasBoundingBox) { + *widthMeters = max(*widthMeters, (uint32_t)std::max(fabsf(m.boxWestMeters), fabsf(m.boxEastMeters)) * 2); + *heightMeters = max(*heightMeters, (uint32_t)std::max(fabsf(m.boxSouthMeters), fabsf(m.boxNorthMeters)) * 2); + } + *widthMeters *= 1.1; + *heightMeters *= 1.1; + return; + } + } + // Find the greatest distance horizontally and vertically from map center for (Marker m : markers) { *widthMeters = max(*widthMeters, (uint32_t)abs(m.eastMeters) * 2); *heightMeters = max(*heightMeters, (uint32_t)abs(m.northMeters) * 2); } + // Waypoints contribute to the fit-all bounding box just like nodes, including geofence extents. + for (const WaypointMarker &m : waypointMarkers) { + if (m.hasMarker) { + *widthMeters = max(*widthMeters, (uint32_t)fabsf(m.eastMeters) * 2); + *heightMeters = max(*heightMeters, (uint32_t)fabsf(m.northMeters) * 2); + } + + if (m.hasMarker && m.geofenceRadiusMeters > 0) { + *widthMeters = max(*widthMeters, (uint32_t)(fabsf(m.eastMeters) + m.geofenceRadiusMeters) * 2); + *heightMeters = max(*heightMeters, (uint32_t)(fabsf(m.northMeters) + m.geofenceRadiusMeters) * 2); + } + + if (m.hasBoundingBox) { + *widthMeters = max(*widthMeters, (uint32_t)std::max(fabsf(m.boxWestMeters), fabsf(m.boxEastMeters)) * 2); + *heightMeters = max(*heightMeters, (uint32_t)std::max(fabsf(m.boxSouthMeters), fabsf(m.boxNorthMeters)) * 2); + } + } + // Add padding *widthMeters *= 1.1; *heightMeters *= 1.1; @@ -928,6 +1172,13 @@ bool InkHUD::MapApplet::enoughMarkers() if (nodeDB->hasValidPosition(node) && shouldDrawNode(node)) return true; } + + // Any live waypoint with a marker or box is enough to justify showing the map. + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (!WaypointStore::isExpired(entry) && waypointHasMapGeometry(entry.waypoint)) + return true; + } + return false; } @@ -937,6 +1188,8 @@ void InkHUD::MapApplet::calculateAllMarkers() { // Clear old markers markers.clear(); + waypointMarkers.clear(); + waypointMarkers.reserve(waypointStore.getWaypoints().size()); // For each node in db for (uint32_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { @@ -965,6 +1218,37 @@ void InkHUD::MapApplet::calculateAllMarkers() markers.push_back(calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away)); } + + // Cache waypoint markers once per render pass to avoid repeated geo math below. + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (!waypointHasMapGeometry(entry.waypoint)) + continue; + + WaypointMarker marker; + marker.id = entry.waypoint.id; + marker.icon = entry.waypoint.icon; + marker.geofenceRadiusMeters = entry.waypoint.geofence_radius; + marker.hasMarker = waypointHasAnchor(entry.waypoint); + marker.hasBoundingBox = entry.waypoint.has_bounding_box; + if (marker.hasMarker) { + Marker base = calculateMarker(entry.waypoint.latitude_i * 1e-7, entry.waypoint.longitude_i * 1e-7, 0); + marker.eastMeters = base.eastMeters; + marker.northMeters = base.northMeters; + } + if (entry.waypoint.has_bounding_box) { + Marker southWest = calculateMarker(entry.waypoint.bounding_box.latitude_south_i * 1e-7, + entry.waypoint.bounding_box.longitude_west_i * 1e-7, 0); + Marker northEast = calculateMarker(entry.waypoint.bounding_box.latitude_north_i * 1e-7, + entry.waypoint.bounding_box.longitude_east_i * 1e-7, 0); + marker.boxWestMeters = southWest.eastMeters; + marker.boxSouthMeters = southWest.northMeters; + marker.boxEastMeters = northEast.eastMeters; + marker.boxNorthMeters = northEast.northMeters; + } + waypointMarkers.push_back(marker); + } } void InkHUD::MapApplet::calculateMapScale() diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h index b447273df9..fa90c0c5dd 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h +++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h @@ -16,7 +16,9 @@ The base applet doesn't handle any events; this is left to the derived applets. #include "configuration.h" #include +#include +#include "WaypointStore.h" #include "graphics/niche/InkHUD/Applet.h" #include "GPSStatus.h" @@ -41,6 +43,7 @@ class MapApplet : public Applet void zoomIn(); void zoomOut(); void resetZoom(); + bool focusWaypoint(uint32_t waypointId); bool isZoomLocked() const { return s_zoomLocked; } bool canZoomIn() const; bool canZoomOut() const; @@ -57,6 +60,9 @@ class MapApplet : public Applet int onGpsStatusUpdate(const meshtastic::Status *status); CallbackObserver gpsStatusObserver = CallbackObserver(this, &MapApplet::onGpsStatusUpdate); + int onWaypointStoreChanged(const WaypointStore *store); + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &MapApplet::onWaypointStoreChanged); static bool s_zoomLocked; static int s_lockedZoom; @@ -69,6 +75,24 @@ class MapApplet : public Applet uint8_t hopsAway = 0; // Determines marker size }; + struct WaypointMarker { + float eastMeters = 0; + float northMeters = 0; + uint32_t id = 0; + uint32_t icon = 0; + uint32_t geofenceRadiusMeters = 0; + bool hasMarker = false; + bool hasBoundingBox = false; + float boxWestMeters = 0; + float boxEastMeters = 0; + float boxSouthMeters = 0; + float boxNorthMeters = 0; + }; + + bool mapWaypointIconGlyph(uint32_t codepoint, std::string &glyph); + uint8_t fallbackBadgeNumber(const WaypointMarker &entry); + void drawWaypointFallbackMarker(const WaypointMarker &entry, int16_t x, int16_t y); + Marker calculateMarker(float lat, float lng, uint8_t hopsAway); void calculateAllMarkers(); void calculateMapScale(); // Conversion factor for meters to pixels @@ -81,10 +105,12 @@ class MapApplet : public Applet bool centerIsOurNode = false; // True if map is centered on our own position (GPS or phone) std::list markers; + std::vector waypointMarkers; uint32_t widthMeters = 0; // Map width: meters uint32_t heightMeters = 0; // Map height: meters + uint32_t focusedWaypointId = 0; }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp index 005327baea..de4e2ee85e 100644 --- a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp @@ -205,6 +205,8 @@ IconKind iconKindForAppletName(const char *name) return IconKind::CHANNEL; if (lower.find("position") != std::string::npos) return IconKind::POSITIONS; + if (lower.find("waypoint") != std::string::npos) + return IconKind::POSITIONS; if (lower.find("recent") != std::string::npos) return IconKind::RECENTS; if (lower.find("heard") != std::string::npos) diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index 3b7606e123..b2a5f5ee67 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -141,6 +141,12 @@ enum MenuAction { MAP_ZOOM_IN, MAP_ZOOM_OUT, MAP_ZOOM_RESET, + // Waypoints (WaypointListApplet) + REMOVE_WAYPOINT, + SELECT_GEOFENCE_WAYPOINT, + TOGGLE_GEOFENCE_ENTER, + TOGGLE_GEOFENCE_EXIT, + TOGGLE_GEOFENCE_FAVORITES_ONLY, }; } // namespace NicheGraphics::InkHUD diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 863c1e85d4..6f003d8b24 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -12,9 +12,12 @@ #include "airtime.h" #include "gps/RTC.h" #include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/Utils/FlashData.h" #include "main.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "modules/GeofenceModule.h" +#include "modules/WaypointModule.h" #include #include #if defined(ARCH_ESP32) && HAS_WIFI @@ -1157,6 +1160,35 @@ void InkHUD::MenuApplet::execute(MenuItem item) break; } + case REMOVE_WAYPOINT: { + // cursor - 2 because index 0 is "Back" and index 1 is the "Select to Remove" header + const size_t index = cursor - 2; + if (waypointModule && index < removeWaypointIds.size()) + waypointModule->broadcastDelete(removeWaypointIds.at(index)); + break; + } + + case SELECT_GEOFENCE_WAYPOINT: { + const size_t index = cursor - 2; + if (index < geofenceWaypointIds.size()) + selectedGeofenceWaypointId = geofenceWaypointIds.at(index); + break; + } + + case TOGGLE_GEOFENCE_ENTER: + case TOGGLE_GEOFENCE_EXIT: + case TOGGLE_GEOFENCE_FAVORITES_ONLY: { + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) + break; + const WaypointNotificationPreference preference = + item.action == TOGGLE_GEOFENCE_ENTER + ? WAYPOINT_NOTIFY_ENTER + : (item.action == TOGGLE_GEOFENCE_EXIT ? WAYPOINT_NOTIFY_EXIT : WAYPOINT_NOTIFY_FAVORITES_ONLY); + waypointStore.setNotificationPreference(selectedGeofenceWaypointId, preference, !entry->notificationEnabled(preference)); + break; + } + default: LOG_WARN("Action not implemented"); } @@ -1173,6 +1205,8 @@ void InkHUD::MenuApplet::showPage(MenuPage page) items.clear(); items.shrink_to_fit(); nodeConfigLabels.clear(); + removeWaypointIds.clear(); + geofenceWaypointIds.clear(); switch (page) { case ROOT: @@ -1196,6 +1230,20 @@ void InkHUD::MenuApplet::showPage(MenuPage page) } } + // Remove Waypoint - only when viewing the waypoint list applet + { + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (waypointListApplet && waypointListApplet->waypointCount() > 0) { + items.push_back(MenuItem("Remove Waypoint", MenuPage::REMOVE_WAYPOINT_LIST)); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (GeofenceModule::hasGeofence(entry.waypoint)) { + items.push_back(MenuItem("Geofence Alerts", MenuPage::GEOFENCE_WAYPOINT_LIST)); + break; + } + } + } + } + items.push_back(MenuItem("Options", MenuPage::OPTIONS)); // items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG)); @@ -1213,6 +1261,43 @@ void InkHUD::MenuApplet::showPage(MenuPage page) previousPage = MenuPage::SEND; break; + case REMOVE_WAYPOINT_LIST: + previousPage = MenuPage::ROOT; + populateRemoveWaypointPage(); // must be first + items.insert(items.begin(), MenuItem::Header("Select to Remove")); + items.insert(items.begin(), MenuItem("Back", previousPage)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case GEOFENCE_WAYPOINT_LIST: + previousPage = MenuPage::ROOT; + populateGeofenceWaypointPage(); + items.insert(items.begin(), MenuItem::Header("Select Geofence")); + items.insert(items.begin(), MenuItem("Back", previousPage)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + + case GEOFENCE_OPTIONS: { + previousPage = MenuPage::GEOFENCE_WAYPOINT_LIST; + items.push_back(MenuItem("Back", previousPage)); + const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId); + if (!entry) { + items.push_back(MenuItem::Header("Geofence unavailable")); + break; + } + const std::string enterLabel = + std::string("Enter Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_ENTER) ? "On" : "Off"); + const std::string exitLabel = + std::string("Exit Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_EXIT) ? "On" : "Off"); + const std::string favoritesLabel = + std::string("Favorites Only: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY) ? "On" : "Off"); + items.push_back(MenuItem(enterLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_ENTER, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem(exitLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_EXIT, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem(favoritesLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_FAVORITES_ONLY, MenuPage::GEOFENCE_OPTIONS)); + items.push_back(MenuItem("Exit", MenuPage::EXIT)); + break; + } + case OPTIONS: previousPage = MenuPage::ROOT; items.push_back(MenuItem("Back", previousPage)); @@ -2442,6 +2527,40 @@ void InkHUD::MenuApplet::populateRecipientPage() items.push_back(MenuItem("Exit", MenuPage::EXIT)); } +// Dynamically create MenuItem entries for each waypoint in the active WaypointListApplet +void InkHUD::MenuApplet::populateRemoveWaypointPage() +{ + assert(items.size() == 0); + + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (waypointListApplet) { + for (size_t i = 0; i < waypointListApplet->waypointCount(); i++) { + removeWaypointIds.push_back(waypointListApplet->waypointIdAt(i)); + items.push_back(MenuItem(waypointListApplet->waypointLabelAt(i).c_str(), MenuAction::REMOVE_WAYPOINT, + MenuPage::REMOVE_WAYPOINT_LIST)); + } + } +} + +void InkHUD::MenuApplet::populateGeofenceWaypointPage() +{ + assert(items.empty()); + + WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr; + if (!waypointListApplet) + return; + + for (size_t i = 0; i < waypointListApplet->waypointCount(); ++i) { + const uint32_t id = waypointListApplet->waypointIdAt(i); + const StoredWaypoint *entry = waypointStore.findWaypoint(id); + if (!entry || !GeofenceModule::hasGeofence(entry->waypoint)) + continue; + geofenceWaypointIds.push_back(id); + items.push_back(MenuItem(waypointListApplet->waypointLabelAt(i).c_str(), MenuAction::SELECT_GEOFENCE_WAYPOINT, + MenuPage::GEOFENCE_OPTIONS)); + } +} + void InkHUD::MenuApplet::drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text) { setFont(fontSmall); diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h index 2d7fbd398b..28289203df 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h @@ -55,6 +55,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds void populateDisplayTimeoutPage(); // Create menu items for config.display.screen_on_secs + void populateRemoveWaypointPage(); // Create menu items: one per waypoint in the active WaypointListApplet + void populateGeofenceWaypointPage(); void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text); // Draw input field for free text @@ -74,7 +76,10 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread uint16_t systemInfoPanelHeight = 0; // Need to know before we render uint16_t menuTextLimit = 200; - std::vector items; // MenuItems for the current page. Filled by ShowPage + std::vector items; // MenuItems for the current page. Filled by ShowPage + std::vector removeWaypointIds; // Parallel to items, for REMOVE_WAYPOINT page + std::vector geofenceWaypointIds; + uint32_t selectedGeofenceWaypointId = 0; std::vector nodeConfigLabels; // Persistent labels for Node Config pages uint8_t selectedChannelIndex = 0; // Currently selected LoRa channel (Node Config → Radio → Channel) bool channelPositionEnabled = false; diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h index 925aa70872..655a823d84 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuPage.h @@ -19,6 +19,9 @@ enum MenuPage : uint8_t { ROOT, // Initial menu page SEND, CANNEDMESSAGE_RECIPIENT, // Select destination for a canned message + REMOVE_WAYPOINT_LIST, // Pick a waypoint to delete (WaypointListApplet only) + GEOFENCE_WAYPOINT_LIST, + GEOFENCE_OPTIONS, OPTIONS, NODE_CONFIG, NODE_CONFIG_LORA, diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h b/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h index d8c4f83662..7e7fe80912 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/Notification.h @@ -19,7 +19,12 @@ namespace NicheGraphics::InkHUD class Notification { public: - enum Type : uint8_t { NOTIFICATION_MESSAGE_BROADCAST, NOTIFICATION_MESSAGE_DIRECT, NOTIFICATION_BATTERY } type; + enum Type : uint8_t { + NOTIFICATION_MESSAGE_BROADCAST, + NOTIFICATION_MESSAGE_DIRECT, + NOTIFICATION_BATTERY, + NOTIFICATION_GEOFENCE + } type; uint32_t timestamp; @@ -33,8 +38,12 @@ class Notification uint8_t channel; uint32_t sender; uint8_t batteryPercentage; + char geofenceNodeName[sizeof(meshtastic_User::long_name)]; + char geofenceName[sizeof(meshtastic_Waypoint::name)]; + uint32_t geofenceWaypointId; + bool geofenceEntered; }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp index e2090269ea..d4f900c9dc 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -4,7 +4,12 @@ #include "./Notification.h" #include "MessageStore.h" +#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" #include "graphics/niche/InkHUD/Persistence.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "modules/GeofenceModule.h" +#include +#endif #include "meshUtils.h" #include "modules/TextMessageModule.h" @@ -16,6 +21,27 @@ using namespace NicheGraphics; InkHUD::NotificationApplet::NotificationApplet() { textMessageObserver.observe(textMessageModule); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + if (geofenceModule) + geofenceObserver.observe(geofenceModule); +#endif +} + +void InkHUD::NotificationApplet::showNotification(const Notification &n) +{ + assert(isActive()); + + if (!settings->optionalFeatures.notifications) + return; + + dismiss(); + hasNotification = true; + currentNotification = n; + if (isApproved()) { + bringToForeground(); + inkhud->forceUpdate(); + } else + hasNotification = false; } // Collect meta-info about the text message, and ask for approval for the notification @@ -25,11 +51,6 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket // System applets are always active assert(isActive()); - // Abort if feature disabled - // This is a bit clumsy, but avoids complicated handling when the feature is enabled / disabled - if (!settings->optionalFeatures.notifications) - return 0; - // Abort if this is an outgoing message if (getFrom(p) == nodeDB->getNodeNum()) return 0; @@ -49,23 +70,35 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket n.sender = p->from; } - // Close an old notification, if shown - dismiss(); - - // Check if we should display the notification - // A foreground applet might already be displaying this info - hasNotification = true; - currentNotification = n; - if (isApproved()) { - bringToForeground(); - inkhud->forceUpdate(); - } else - hasNotification = false; // Clear the pending notification: it was rejected + showNotification(n); // Return zero: no issues here, carry on notifying other observers! return 0; } +#if !MESHTASTIC_EXCLUDE_WAYPOINT +int InkHUD::NotificationApplet::onGeofenceEvent(const GeofenceNotificationEvent *event) +{ + assert(isActive()); + + if (!event) + return 0; + + Notification n; + n.type = Notification::Type::NOTIFICATION_GEOFENCE; + n.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); + n.geofenceWaypointId = event->waypointId; + strncpy(n.geofenceName, event->geofenceName, sizeof(n.geofenceName) - 1); + n.geofenceName[sizeof(n.geofenceName) - 1] = '\0'; + strncpy(n.geofenceNodeName, event->nodeName, sizeof(n.geofenceNodeName) - 1); + n.geofenceNodeName[sizeof(n.geofenceNodeName) - 1] = '\0'; + n.geofenceEntered = event->entered; + + showNotification(n); + return 0; +} +#endif + void InkHUD::NotificationApplet::onRender(bool full) { // Clear the region beneath the tile @@ -145,7 +178,10 @@ void InkHUD::NotificationApplet::onBackground() void InkHUD::NotificationApplet::onButtonShortPress() { - dismiss(); + if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) + openGeofenceOnMap(); + else + dismiss(); } void InkHUD::NotificationApplet::onButtonLongPress() @@ -180,6 +216,25 @@ void InkHUD::NotificationApplet::onNavLeft() void InkHUD::NotificationApplet::onNavRight() { + if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) + openGeofenceOnMap(); + else + dismiss(); +} + +void InkHUD::NotificationApplet::openGeofenceOnMap() +{ + for (uint8_t i = 0; i < inkhud->userApplets.size(); ++i) { + Applet *applet = inkhud->userApplets.at(i); + MapApplet *map = applet ? applet->asMapApplet() : nullptr; + if (!map || !applet->isActive() || !map->focusWaypoint(currentNotification.geofenceWaypointId)) + continue; + + dismiss(); + inkhud->showApplet(i); + return; + } + dismiss(); } @@ -267,6 +322,12 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila } } + else if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) { + text += currentNotification.geofenceNodeName; + text += currentNotification.geofenceEntered ? " IN " : " OUT "; + text += currentNotification.geofenceName; + } + // Parse any non-ascii characters and return return parse(text); } diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h index d398a36f32..02f6a89139 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.h @@ -15,8 +15,10 @@ Feature should be optional; enable disable via on-screen menu #include "configuration.h" #include "concurrency/OSThread.h" - #include "graphics/niche/InkHUD/SystemApplet.h" +#if !MESHTASTIC_EXCLUDE_WAYPOINT +struct GeofenceNotificationEvent; +#endif namespace NicheGraphics::InkHUD { @@ -39,6 +41,9 @@ class NotificationApplet : public SystemApplet void onNavRight() override; int onReceiveTextMessage(const meshtastic_MeshPacket *p); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + int onGeofenceEvent(const GeofenceNotificationEvent *event); +#endif bool isApproved(); // Does a foreground applet make notification redundant? void dismiss(); // Close the Notification Popup @@ -47,13 +52,19 @@ class NotificationApplet : public SystemApplet // Get notified when a new text message arrives CallbackObserver textMessageObserver = CallbackObserver(this, &NotificationApplet::onReceiveTextMessage); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + CallbackObserver geofenceObserver = + CallbackObserver(this, &NotificationApplet::onGeofenceEvent); +#endif + void showNotification(const Notification &n); + void openGeofenceOnMap(); std::string getNotificationText(uint16_t widthAvailable); // Get text for notification, to suit screen width - bool hasNotification = false; // Only used for assert. Todo: remove? + bool hasNotification = false; Notification currentNotification = Notification(); // Set when something notification-worthy happens. Used by render() }; } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h index 5cb20ed3b2..a5b675bf00 100644 --- a/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h +++ b/src/graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h @@ -42,4 +42,4 @@ class PositionsApplet : public MapApplet, public SinglePortModule } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp new file mode 100644 index 0000000000..4023482b43 --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.cpp @@ -0,0 +1,572 @@ +#if defined(MESHTASTIC_INCLUDE_INKHUD) + +#include "./WaypointListApplet.h" + +#include "GeoCoord.h" +#include "NodeDB.h" +#include "RTC.h" +#include "WaypointStore.h" +#include "WaypointUtils.h" +#include "modules/WaypointModule.h" + +#include +#include +#include + +using namespace NicheGraphics; + +namespace +{ + +uint32_t fnv1aAppend(uint32_t hash, const char *text) +{ + while (*text) { + hash ^= (uint8_t)*text++; + hash *= 16777619u; + } + + return hash; +} + +} // namespace + +InkHUD::WaypointListApplet::WaypointListApplet() : concurrency::OSThread("WaypointListApplet") +{ + OSThread::disable(); +} + +void InkHUD::WaypointListApplet::onActivate() +{ + setInputsSubscribed(NAV_UP | NAV_DOWN, true); + scrollOffset = 0; + hasRenderHash = false; + waypointStoreObserver.observe(&waypointStore); + waypointStore.purgeExpired(); + updateRefreshTimer(); +} + +void InkHUD::WaypointListApplet::onDeactivate() +{ + waypointStoreObserver.unobserve(&waypointStore); + setInputsSubscribed(NAV_UP | NAV_DOWN, false); + OSThread::disable(); +} + +int32_t InkHUD::WaypointListApplet::runOnce() +{ + bool needsUpdate = false; + if (isActive()) + waypointStore.purgeExpired(); + + if (isActive() && !waypointStore.getWaypoints().empty()) { + const uint32_t renderHash = buildRenderHash(); + if (!hasRenderHash || renderHash != lastRenderHash) { + lastRenderHash = renderHash; + hasRenderHash = true; + needsUpdate = true; + } + + updateRefreshTimer(); + } + + if (isActive() && needsUpdate) + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + + return OSThread::interval; +} + +void InkHUD::WaypointListApplet::updateRefreshTimer() +{ + if (waypointStore.getWaypoints().empty()) { + OSThread::disable(); + return; + } + + OSThread::enabled = true; + OSThread::setIntervalFromNow(nextRefreshIntervalMs()); +} + +bool InkHUD::WaypointListApplet::hasDescription(const meshtastic_Waypoint &waypoint) +{ + return waypoint.description[0] != '\0'; +} + +uint8_t InkHUD::WaypointListApplet::rowHeight(const meshtastic_Waypoint &waypoint) +{ + const uint8_t lines = hasDescription(waypoint) ? 3 : 2; + return (fontSmall.lineHeight() * lines) + lines; +} + +uint8_t InkHUD::WaypointListApplet::visibleRows(uint8_t start) +{ + const auto &waypoints = waypointStore.getWaypoints(); + const int16_t contentTop = getHeaderHeight() + 2; + const uint16_t availableH = (height() > contentTop) ? (height() - contentTop) : 1; + if (waypoints.empty() || start >= waypoints.size()) + return 0; + + uint16_t usedH = 0; + uint8_t count = 0; + for (uint8_t i = start; i < waypoints.size(); ++i) { + const uint8_t nextH = rowHeight(waypoints.at(i).waypoint); + if (count > 0 && usedH + nextH > availableH) + break; + + usedH += nextH; + ++count; + + if (usedH >= availableH) + break; + } + + return count; +} + +uint8_t InkHUD::WaypointListApplet::maxScrollOffset() +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return 0; + + const int16_t contentTop = getHeaderHeight() + 2; + const uint16_t availableH = (height() > contentTop) ? (height() - contentTop) : 1; + uint16_t usedH = 0; + uint8_t start = (uint8_t)waypoints.size(); + + while (start > 0) { + const uint8_t nextH = rowHeight(waypoints.at(start - 1).waypoint); + if (usedH > 0 && usedH + nextH > availableH) + break; + + usedH += nextH; + --start; + + if (usedH >= availableH) + break; + } + + return start; +} + +bool InkHUD::WaypointListApplet::rowIndexAt(int16_t y, uint8_t &indexOut) +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return false; + + const uint8_t start = std::min(scrollOffset, (uint8_t)waypoints.size() - 1); + const uint8_t rows = visibleRows(start); + const uint8_t end = std::min((uint8_t)waypoints.size(), start + rows); + + // Walk the same row layout used by onRender, stopping at whichever row contains y + int16_t rowTop = getHeaderHeight() + 2; + for (uint8_t i = start; i < end; ++i) { + const uint8_t rowH = rowHeight(waypoints.at(i).waypoint); + if (y < rowTop + rowH) { + indexOut = i; + return true; + } + rowTop += rowH; + } + + return false; +} + +void InkHUD::WaypointListApplet::scrollBy(int delta) +{ + const int next = std::clamp((int)scrollOffset + delta, 0, maxScrollOffset()); + if (next == scrollOffset) + return; + + scrollOffset = (uint8_t)next; + requestUpdate(Drivers::EInk::UpdateTypes::FAST); +} + +void InkHUD::WaypointListApplet::onNavUp() +{ + scrollBy(-1); +} + +void InkHUD::WaypointListApplet::onNavDown() +{ + scrollBy(1); +} + +bool InkHUD::WaypointListApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress) +{ + (void)x; + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty() || y < getHeaderHeight()) + return false; + + // Long press a row to delete that waypoint (broadcasts the deletion to the mesh too) + if (longPress) { + uint8_t index = 0; + if (!rowIndexAt(y, index)) + return false; + + if (waypointModule) + waypointModule->broadcastDelete(waypoints.at(index).waypoint.id); + return true; + } + + const uint16_t midpoint = getHeaderHeight() + ((height() - getHeaderHeight()) / 2); + scrollBy(y < midpoint ? -1 : 1); + return true; +} + +int InkHUD::WaypointListApplet::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + if (!isActive()) + return 0; + + syncListState(); + requestUpdate(Drivers::EInk::UpdateTypes::FAST); + return 0; +} + +std::string InkHUD::WaypointListApplet::headerText() +{ + const auto &waypoints = waypointStore.getWaypoints(); + if (waypoints.empty()) + return "Waypoints"; + + const uint8_t rows = visibleRows(scrollOffset); + const uint8_t first = scrollOffset + 1; + const uint8_t last = std::min((uint8_t)waypoints.size(), scrollOffset + rows); + + char buf[32]; + snprintf(buf, sizeof(buf), "Waypoints %u-%u/%u", first, last, (unsigned)waypoints.size()); + return buf; +} + +std::string InkHUD::WaypointListApplet::waypointName(const meshtastic_Waypoint &waypoint) +{ + if (waypoint.name[0]) + return parse(waypoint.name); + + char buf[20]; + snprintf(buf, sizeof(buf), "Waypoint 0x%x", (unsigned)waypoint.id); + return buf; +} + +std::string InkHUD::WaypointListApplet::waypointDescription(const meshtastic_Waypoint &waypoint) +{ + if (!hasDescription(waypoint)) + return ""; + + return parse(waypoint.description); +} + +std::string InkHUD::WaypointListApplet::coordinateText(const meshtastic_Waypoint &waypoint, bool landscape) +{ + if (!waypoint.has_latitude_i || !waypoint.has_longitude_i) + return "--"; + + const uint8_t decimals = landscape ? (width() >= 220 ? 4 : 3) : (width() >= 140 ? 3 : 2); + const double lat = waypoint.latitude_i * 1e-7; + const double lon = waypoint.longitude_i * 1e-7; + + char buf[40]; + snprintf(buf, sizeof(buf), "%.*f,%.*f", decimals, lat, decimals, lon); + return buf; +} + +bool InkHUD::WaypointListApplet::tryGetOwnPosition(meshtastic_PositionLite &out) +{ + const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + return ourNode && nodeDB->copyNodePosition(ourNode->num, out) && (out.latitude_i != 0 || out.longitude_i != 0); +} + +std::string InkHUD::WaypointListApplet::distanceText(const meshtastic_Waypoint &waypoint) +{ + if (!waypoint.has_latitude_i || !waypoint.has_longitude_i) + return ""; + + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; + if (!tryGetOwnPosition(ownPos)) + return ""; + + const float meters = GeoCoord::latLongToMeter(waypoint.latitude_i * 1e-7, waypoint.longitude_i * 1e-7, + ownPos.latitude_i * 1e-7, ownPos.longitude_i * 1e-7); + if (meters < 0) + return ""; + + return localizeDistance((uint32_t)std::lround(meters)); +} + +std::string InkHUD::WaypointListApplet::expireText(uint32_t expireEpoch) +{ + if (expireEpoch == 0) + return "--"; + + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now == 0) + return ""; + if (expireEpoch <= now) + return "exp"; + + const uint32_t left = expireEpoch - now; + char buf[12]; + if (left < 3600) + snprintf(buf, sizeof(buf), "%lum", (unsigned long)((left + 59) / 60)); + else if (left < 86400) + snprintf(buf, sizeof(buf), "%luh", (unsigned long)((left + 3599) / 3600)); + else + snprintf(buf, sizeof(buf), "%lud", (unsigned long)((left + 86399) / 86400)); + return buf; +} + +uint32_t InkHUD::WaypointListApplet::nextExpiryUpdateMs(uint32_t secondsLeft) +{ + if (secondsLeft < 60) + return secondsLeft * 1000UL; + + const uint32_t step = (secondsLeft < 3600) ? 60UL : (secondsLeft < 86400 ? 3600UL : 86400UL); + return ((((secondsLeft - 1) % step) + 1) * 1000UL); +} + +uint32_t InkHUD::WaypointListApplet::nextRefreshIntervalMs() +{ + static constexpr uint32_t WAITING_STATE_REFRESH_MS = 1000UL; + static constexpr uint32_t GPS_DISTANCE_REFRESH_MS = 5000UL; + static constexpr uint32_t IDLE_REFRESH_MS = 60000UL; + + uint32_t intervalMs = UINT32_MAX; + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; + const bool haveOwnPos = tryGetOwnPosition(ownPos); + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &waypoint = entry.waypoint; + if (waypoint.expire != 0) { + if (now == 0) { + intervalMs = std::min(intervalMs, WAITING_STATE_REFRESH_MS); + } else if (waypoint.expire > now) { + intervalMs = std::min(intervalMs, nextExpiryUpdateMs(waypoint.expire - now)); + } + } + + if (waypoint.has_latitude_i && waypoint.has_longitude_i) { + if (!haveOwnPos) { + intervalMs = std::min(intervalMs, WAITING_STATE_REFRESH_MS); + } else if (!config.position.fixed_position) { + intervalMs = std::min(intervalMs, GPS_DISTANCE_REFRESH_MS); + } + } + } + + if (intervalMs == UINT32_MAX) + return IDLE_REFRESH_MS; + + return intervalMs; +} + +uint32_t InkHUD::WaypointListApplet::buildRenderHash() +{ + uint32_t hash = 2166136261u; + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &waypoint = entry.waypoint; + char idBuf[11]; + snprintf(idBuf, sizeof(idBuf), "%lu", (unsigned long)waypoint.id); + hash = fnv1aAppend(hash, idBuf); + hash = fnv1aAppend(hash, "|"); + + const std::string distance = distanceText(waypoint); + hash = fnv1aAppend(hash, distance.c_str()); + hash = fnv1aAppend(hash, "|"); + + const std::string expire = expireText(waypoint.expire); + hash = fnv1aAppend(hash, expire.c_str()); + hash = fnv1aAppend(hash, ";"); + } + + return hash; +} + +void InkHUD::WaypointListApplet::syncListState() +{ + scrollOffset = std::min(scrollOffset, maxScrollOffset()); + hasRenderHash = false; + // Re-arm the timer whenever visible waypoint state changes. + updateRefreshTimer(); +} + +bool InkHUD::WaypointListApplet::canRenderWaypointIcon(const meshtastic_Waypoint &waypoint, std::string *mapped) +{ + if (!waypoint.icon) + return false; + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(waypoint.icon); + if (utf8.empty()) + return false; + + const std::string glyph = getFont().decodeUTF8(utf8); + if (glyph.size() != 1 || glyph[0] == '\x1A' || glyph[0] == '\x7F') + return false; + + if (mapped) + *mapped = glyph; + return true; +} + +uint8_t InkHUD::WaypointListApplet::fallbackBadgeNumber(const meshtastic_Waypoint &waypoint) +{ + uint8_t badge = 0; + + for (auto it = waypointStore.getWaypoints().rbegin(); it != waypointStore.getWaypoints().rend(); ++it) { + const meshtastic_Waypoint &candidate = it->waypoint; + if (canRenderWaypointIcon(candidate)) + continue; + + if (candidate.id == waypoint.id) + return badge; + + if (badge < 9) + ++badge; + } + + return 0; +} + +bool InkHUD::WaypointListApplet::drawWaypointIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t centerY, + uint16_t boxSize) +{ + std::string mappedGlyph; + if (!canRenderWaypointIcon(waypoint, &mappedGlyph)) + return false; + + printAt(left + (boxSize / 2), centerY, mappedGlyph, CENTER, MIDDLE); + return true; +} + +void InkHUD::WaypointListApplet::drawFallbackIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t rowTop, + uint16_t boxWidth, uint16_t rowHeight) +{ + char badgeText[3]; + snprintf(badgeText, sizeof(badgeText), "%u", (unsigned)fallbackBadgeNumber(waypoint)); + + setFont(fontSmall); + + const int16_t cx = left + (boxWidth / 2); + const uint16_t badgeTextW = std::max(getTextWidth(badgeText), getTextWidth("0")); + const int16_t centerY = rowTop + (fontSmall.lineHeight() / 2) + 1; + const int16_t boxSize = std::max((int16_t)boxWidth, (int16_t)(badgeTextW + 5)); + const int16_t radius = std::max(2, boxSize / 6); + const int16_t boxLeft = cx - (boxSize / 2); + const int16_t boxTop = centerY - (boxSize / 2); + + // Match the boxed fallback marker style used on the map. + fillRoundRect(boxLeft, boxTop, boxSize, boxSize, radius, WHITE); + drawRoundRect(boxLeft, boxTop, boxSize, boxSize, radius, BLACK); + + setCrop(left - 1, rowTop, boxWidth + 3, rowHeight); + printAt(cx, centerY + 1, badgeText, CENTER, MIDDLE); + resetCrop(); +} + +void InkHUD::WaypointListApplet::onRender(bool full) +{ + (void)full; + + const bool landscape = width() > height(); + const auto &waypoints = waypointStore.getWaypoints(); + drawHeader(headerText()); + + if (waypoints.empty()) { + setFont(fontMedium); + printAt(X(0.5f), Y(0.5f), "No Waypoints", CENTER, MIDDLE); + return; + } + + setFont(fontSmall); + + const int16_t contentTop = getHeaderHeight() + 2; + const uint8_t start = std::min(scrollOffset, (uint8_t)waypoints.size() - 1); + const uint8_t rows = visibleRows(start); + const uint8_t end = std::min((uint8_t)waypoints.size(), start + rows); + const uint16_t iconW = fontSmall.lineHeight(); + const uint16_t gap = 2; + + auto ellipsizeToWidth = [this](std::string text, uint16_t maxWidth) { + constexpr const char *ellipsis = "..."; + const uint16_t ellipsisW = getTextWidth(ellipsis); + uint16_t textW = getTextWidth(text); + if (maxWidth == 0) + return std::string(); + if (textW <= maxWidth) + return text; + if (ellipsisW > maxWidth) + return std::string(); + while (!text.empty() && (textW + ellipsisW > maxWidth)) { + text.pop_back(); + textW = getTextWidth(text); + } + return text + ellipsis; + }; + + int16_t rowTop = contentTop; + for (uint8_t i = start; i < end; ++i) { + const meshtastic_Waypoint &waypoint = waypoints.at(i).waypoint; + const uint8_t rowH = rowHeight(waypoint); + const int16_t line1Y = rowTop + (fontSmall.lineHeight() / 2) + 1; + const int16_t line2Y = rowTop + fontSmall.lineHeight() + 1; + const int16_t metaY = + rowTop + (hasDescription(waypoint) ? ((fontSmall.lineHeight() * 2) + 2) : (fontSmall.lineHeight() + 1)); + + if (!drawWaypointIcon(waypoint, 1, line1Y, iconW - 1)) + drawFallbackIcon(waypoint, 0, rowTop, iconW, rowH); + + const std::string name = waypointName(waypoint); + const std::string description = waypointDescription(waypoint); + const std::string distance = distanceText(waypoint); + const std::string coord = coordinateText(waypoint, landscape); + const std::string expire = expireText(waypoint.expire); + + const int16_t nameLeft = iconW + gap; + int16_t nameRight = width() - 1; + if (!distance.empty()) { + printAt(nameRight, line1Y, distance, RIGHT, MIDDLE); + nameRight -= getTextWidth(distance) + gap; + } + + const uint16_t nameWidth = (nameRight >= nameLeft) ? ((nameRight - nameLeft) + 1) : 0; + const std::string shown = ellipsizeToWidth(name, nameWidth); + const uint16_t shownWidth = getTextWidth(shown); + setCrop(nameLeft, rowTop, nameWidth, fontSmall.lineHeight() + 2); + printThick(nameLeft + (shownWidth / 2), line1Y, shown, 2, 1); + resetCrop(); + + if (!description.empty()) { + const std::string descShown = ellipsizeToWidth(description, nameWidth); + setCrop(nameLeft, line2Y - 1, nameWidth, fontSmall.lineHeight() + 2); + printAt(nameLeft, line2Y, descShown, LEFT, TOP); + resetCrop(); + } + + int16_t metaRight = width() - 1; + if (!expire.empty()) { + printAt(metaRight, metaY, expire, RIGHT, TOP); + metaRight -= getTextWidth(expire) + gap; + } + + const uint16_t coordWidth = (metaRight >= nameLeft) ? ((metaRight - nameLeft) + 1) : 0; + const std::string coordShown = ellipsizeToWidth(coord, coordWidth); + setCrop(nameLeft, metaY - 1, coordWidth, fontSmall.lineHeight() + 2); + printAt(nameLeft, metaY, coordShown, LEFT, TOP); + resetCrop(); + + const int16_t separatorY = rowTop + rowH - 1; + if (separatorY < height() - 1 && i + 1 < end) { + for (int16_t x = 0; x < width(); x += 2) + drawPixel(x, separatorY, BLACK); + } + + rowTop += rowH; + } +} + +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h new file mode 100644 index 0000000000..73bfd26e3d --- /dev/null +++ b/src/graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h @@ -0,0 +1,78 @@ +#if defined(MESHTASTIC_INCLUDE_INKHUD) + +#pragma once + +#include "configuration.h" + +#include "Observer.h" +#include "WaypointStore.h" +#include "concurrency/OSThread.h" +#include "graphics/niche/InkHUD/Applet.h" +#include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "mesh/generated/meshtastic/mesh.pb.h" + +#include + +namespace NicheGraphics::InkHUD +{ + +class WaypointListApplet : public Applet, public concurrency::OSThread +{ + public: + WaypointListApplet(); + + void onActivate() override; + void onDeactivate() override; + void onRender(bool full) override; + void onNavUp() override; + void onNavDown() override; + bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override; + + WaypointListApplet *asWaypointListApplet() override { return this; } // Identify as WaypointListApplet without RTTI + + // Read-only access for MenuApplet's "Remove Waypoint" page + size_t waypointCount() const { return waypointStore.getWaypoints().size(); } + uint32_t waypointIdAt(size_t index) const { return waypointStore.getWaypoints().at(index).waypoint.id; } + std::string waypointLabelAt(size_t index) { return waypointName(waypointStore.getWaypoints().at(index).waypoint); } + + protected: + int32_t runOnce() override; + + private: + void updateRefreshTimer(); + uint8_t visibleRows(uint8_t start); + uint8_t rowHeight(const meshtastic_Waypoint &waypoint); + uint8_t maxScrollOffset(); + void scrollBy(int delta); + bool rowIndexAt(int16_t y, uint8_t &indexOut); // Which waypoint row is at this y, if any + bool tryGetOwnPosition(meshtastic_PositionLite &out); + uint32_t nextExpiryUpdateMs(uint32_t secondsLeft); + uint32_t nextRefreshIntervalMs(); + uint32_t buildRenderHash(); + void syncListState(); + + std::string headerText(); + std::string waypointName(const meshtastic_Waypoint &waypoint); + std::string waypointDescription(const meshtastic_Waypoint &waypoint); + std::string coordinateText(const meshtastic_Waypoint &waypoint, bool landscape); + std::string distanceText(const meshtastic_Waypoint &waypoint); + std::string expireText(uint32_t expireEpoch); + bool canRenderWaypointIcon(const meshtastic_Waypoint &waypoint, std::string *mapped = nullptr); + uint8_t fallbackBadgeNumber(const meshtastic_Waypoint &waypoint); + bool drawWaypointIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t centerY, uint16_t boxSize); + void drawFallbackIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t rowTop, uint16_t boxWidth, + uint16_t rowHeight); + bool hasDescription(const meshtastic_Waypoint &waypoint); + + int onWaypointStoreChanged(const WaypointStore *store); + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &WaypointListApplet::onWaypointStoreChanged); + + uint8_t scrollOffset = 0; + uint32_t lastRenderHash = 0; + bool hasRenderHash = false; +}; + +} // namespace NicheGraphics::InkHUD + +#endif diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index 1a5e438d93..505cb37384 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -4,6 +4,7 @@ #include "MessageStore.h" #include "PowerFSM.h" +#include "WaypointStore.h" #include "buzz.h" #include "gps/RTC.h" #include "modules/ExternalNotificationModule.h" @@ -468,6 +469,7 @@ int InkHUD::Events::beforeDeepSleep(void *unused) inkhud->persistence->saveSettings(); inkhud->persistence->saveLatestMessage(); + waypointStore.saveToFlash(); // LogoApplet::onShutdown attempted to heal the display by drawing a "shutting down" screen twice, // then prepared a final powered-off screen for us, which shows device shortname. @@ -516,6 +518,7 @@ int InkHUD::Events::beforeReboot(void *unused) } else { NicheGraphics::clearFlashData(); messageStore.clearAllMessages(); // also wipe the shared message store + waypointStore.clearAllWaypoints(); } // Note: no forceUpdate call here diff --git a/src/main.cpp b/src/main.cpp index 63f259b949..89ff9de383 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,7 @@ #include "Power.h" #include "SPILock.h" #include "Throttle.h" +#include "WaypointStore.h" #include "concurrency/OSThread.h" #include "concurrency/Periodic.h" #include "detect/ScanI2C.h" @@ -1095,6 +1096,10 @@ void setup() // Now that the mesh service is created, create any modules setupModules(); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.loadFromFlash(); +#endif + #if !MESHTASTIC_EXCLUDE_I2C // Inform modules about I2C devices ScanI2CCompleted(i2cScanner.get()); @@ -1541,6 +1546,12 @@ void loop() #endif #if (HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)) && ENABLE_MESSAGE_PERSISTENCE messageStoreAutosaveTick(); +#endif +#if !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.purgeExpired(); +#endif +#if !MESHTASTIC_EXCLUDE_WAYPOINT && ENABLE_WAYPOINT_PERSISTENCE + waypointStoreAutosaveTick(); #endif long delayMsec = mainController.runOrDelay(); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 01bf78bcc3..9239a1d1ca 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -20,6 +20,9 @@ #include "TransmitHistory.h" #include "TypeConversions.h" #include "UptimeClock.h" +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT +#include "WaypointStore.h" +#endif #include "error.h" #include "gps/RTC.h" #include "main.h" @@ -839,6 +842,9 @@ bool NodeDB::factoryReset(bool eraseBleBonds) #if HAS_SCREEN messageStore.clearAllMessages(); #endif +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT + waypointStore.clearAllWaypoints(); +#endif #if WARM_NODE_COUNT > 0 // On nRF52840 the warm tier lives in raw flash outside /prefs, so rmDir diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 420697689f..f0e22d485d 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -449,11 +449,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP (moduleConfig.external_notification.alert_message_buzzer && !is_muted))); if (genericShouldAlert || vibraShouldAlert || buzzerShouldAlert) { - nagCycleCutoff = millis() + (moduleConfig.external_notification.nag_timeout - ? (moduleConfig.external_notification.nag_timeout * 1000) - : moduleConfig.external_notification.output_ms); - LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); - isNagging = true; + armNagCycle(); } if (genericShouldAlert) { @@ -463,19 +459,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP if (vibraShouldAlert) { LOG_INFO("externalNotificationModule - Vibra alert"); -#ifdef HAS_DRV2605 - // Set DRV2605 waveform when vibration alert is triggered - drv.setWaveform(0, 16); // Long buzzer 100% - drv.setWaveform(1, 0); // Pause - drv.setWaveform(2, 16); - drv.setWaveform(3, 0); - drv.setWaveform(4, 16); - drv.setWaveform(5, 0); - drv.setWaveform(6, 16); - drv.setWaveform(7, 0); - drv.go(); -#endif - setExternalState(1, true); + triggerVibraOutput(); } if (buzzerShouldAlert) { @@ -484,15 +468,7 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP LOG_INFO("Buzzer suppressed: mode DIRECT_MSG_ONLY"); } else { // Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us - if (moduleConfig.external_notification.use_i2s_as_buzzer) { -#ifdef HAS_I2S - audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); -#endif - } else if (moduleConfig.external_notification.use_pwm) { - rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); - } else { - setExternalState(2, true); - } + triggerBuzzerOutput(); } } @@ -505,6 +481,80 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP return ProcessMessage::CONTINUE; // Let others look at this message also if they want } +void ExternalNotificationModule::triggerBuzzerOutput() +{ + if (moduleConfig.external_notification.use_i2s_as_buzzer) { +#ifdef HAS_I2S + audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); +#endif + } else if (moduleConfig.external_notification.use_pwm) { + rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); + } else { + setExternalState(2, true); + } +} + +void ExternalNotificationModule::triggerVibraOutput() +{ +#ifdef HAS_DRV2605 + drv.setWaveform(0, 16); + drv.setWaveform(1, 0); + drv.setWaveform(2, 16); + drv.setWaveform(3, 0); + drv.setWaveform(4, 16); + drv.setWaveform(5, 0); + drv.setWaveform(6, 16); + drv.setWaveform(7, 0); + drv.go(); +#endif + setExternalState(1, true); +} + +void ExternalNotificationModule::armNagCycle() +{ + const uint32_t durationMs = moduleConfig.external_notification.nag_timeout + ? moduleConfig.external_notification.nag_timeout * 1000UL + : moduleConfig.external_notification.output_ms; + nagCycleCutoff = millis() + durationMs; + LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); + isNagging = true; +} + +void ExternalNotificationModule::startNotification() +{ + if (!moduleConfig.external_notification.enabled || isSilenced) + return; + + // Waypoint and geofence events are neither direct messages nor bells. + const bool buzzerModeIsDirectOnly = (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY); + + const bool generic = moduleConfig.external_notification.alert_message; + const bool vibra = moduleConfig.external_notification.alert_message_vibra; + const bool buzzer = canBuzz() && moduleConfig.external_notification.alert_message_buzzer && !buzzerModeIsDirectOnly; + if (canBuzz() && moduleConfig.external_notification.alert_message_buzzer && buzzerModeIsDirectOnly) + LOG_INFO("Non-message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY"); + if (!generic && !vibra && !buzzer) + return; + + buzzerShouldAlert |= buzzer; + + armNagCycle(); + + if (generic) { + LOG_INFO("externalNotificationModule - Generic alert"); + setExternalState(0, true); + } + if (vibra) { + LOG_INFO("externalNotificationModule - Vibra alert"); + triggerVibraOutput(); + } + if (buzzer) { + LOG_INFO("externalNotificationModule - Buzzer alert"); + triggerBuzzerOutput(); + } + + setIntervalFromNow(0); // run once so the nag/stop lifecycle in runOnce() takes over +} /** * @brief An admin message arrived to AdminModule. We are asked whether we want to handle that. * diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index a5b9f68da7..75f831d04c 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -73,6 +73,9 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: void stopNow(); + // Fire the configured message outputs for a non-message event such as a geofence crossing. + void startNotification(); + void handleGetRingtone(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response); void handleSetRingtone(const char *from_msg); @@ -87,6 +90,11 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: virtual bool wantPacket(const meshtastic_MeshPacket *p) override; + // Drive the configured buzzer output (I2S, PWM ringtone, or plain GPIO). + void triggerBuzzerOutput(); + void triggerVibraOutput(); + void armNagCycle(); + bool isNagging = false; bool isSilenced = false; @@ -97,4 +105,4 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency: meshtastic_AdminMessage *response) override; }; -extern ExternalNotificationModule *externalNotificationModule; \ No newline at end of file +extern ExternalNotificationModule *externalNotificationModule; diff --git a/src/modules/GeofenceModule.cpp b/src/modules/GeofenceModule.cpp new file mode 100644 index 0000000000..ff7d2a5f7a --- /dev/null +++ b/src/modules/GeofenceModule.cpp @@ -0,0 +1,214 @@ +#include "GeofenceModule.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "WaypointStore.h" +#include "gps/GeoCoord.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" +#include + +#if HAS_SCREEN +#include "PowerFSM.h" +#include "graphics/Screen.h" +#include "main.h" // screen +#endif + +#include "modules/ExternalNotificationModule.h" + +GeofenceModule *geofenceModule; + +static constexpr size_t GEOFENCE_MAX_CROSSING = 256; + +GeofenceModule::GeofenceModule() +{ + crossingInside.reserve(GEOFENCE_MAX_CROSSING); + waypointStoreObserver.observe(&waypointStore); +} + +bool GeofenceModule::insideRadius(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters) +{ + if (radiusMeters == 0) + return false; + float meters = GeoCoord::latLongToMeter((double)ptLat_i * 1e-7, (double)ptLon_i * 1e-7, (double)ctrLat_i * 1e-7, + (double)ctrLon_i * 1e-7); + return meters <= (float)radiusMeters; +} + +bool GeofenceModule::insideBox(int32_t ptLat_i, int32_t ptLon_i, const meshtastic_BoundingBox &box) +{ + return ptLat_i >= box.latitude_south_i && ptLat_i <= box.latitude_north_i && ptLon_i >= box.longitude_west_i && + ptLon_i <= box.longitude_east_i; +} + +bool GeofenceModule::insideAny(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters, + bool hasBox, const meshtastic_BoundingBox &box) +{ + if (insideRadius(ptLat_i, ptLon_i, ctrLat_i, ctrLon_i, radiusMeters)) + return true; + if (hasBox && insideBox(ptLat_i, ptLon_i, box)) + return true; + return false; +} + +bool GeofenceModule::inside(const meshtastic_Waypoint &wp, int32_t ptLat_i, int32_t ptLon_i) +{ + return insideAny(ptLat_i, ptLon_i, wp.latitude_i, wp.longitude_i, wp.geofence_radius, wp.has_bounding_box, wp.bounding_box); +} + +bool GeofenceModule::hasGeofence(const meshtastic_Waypoint &wp) +{ + return wp.geofence_radius > 0 || wp.has_bounding_box; +} + +GeofenceModule::Crossing GeofenceModule::classify(bool firstSighting, bool wasInside, bool isInside, bool notifyOnEnter, + bool notifyOnExit) +{ + if (firstSighting) + return Crossing::None; // baseline only + if (wasInside == isInside) + return Crossing::None; // no transition + if (isInside) + return notifyOnEnter ? Crossing::Enter : Crossing::None; + return notifyOnExit ? Crossing::Exit : Crossing::None; +} + +GeofenceModule::CrossingState *GeofenceModule::findCrossingState(uint64_t key) +{ + for (auto &state : crossingInside) { + if (state.key == key) + return &state; + } + + return nullptr; +} + +bool GeofenceModule::shouldTrack(const meshtastic_Waypoint &wp, uint8_t notificationPreferences, uint32_t now) +{ + if (!hasGeofence(wp)) + return false; + if ((notificationPreferences & (WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_EXIT)) == 0) + return false; + // Only the circle is centred on the waypoint; the bounding box carries its own absolute + // corners, so a box-only geofence does not need a latitude/longitude pin. + if (wp.geofence_radius > 0 && !(wp.has_latitude_i && wp.has_longitude_i)) + return false; + // Expired/deleted? (now == 0 means we have no trustworthy clock, so treat it as still live.) + if (now != 0 && wp.expire != 0 && wp.expire <= now) + return false; + return true; +} + +int GeofenceModule::onWaypointStoreChanged(const WaypointStore *store) +{ + (void)store; + crossingInside.clear(); + return 0; +} + +void GeofenceModule::evaluatePosition(NodeNum node, const meshtastic_Position &p) +{ + if (waypointStore.getWaypoints().empty()) + return; + if (!p.has_latitude_i || !p.has_longitude_i) + return; + if (p.latitude_i == 0 && p.longitude_i == 0) + return; // treat the null island as "no fix" + if (node == nodeDB->getNodeNum()) + return; // judge other nodes' positions only (per design#114) + + const int32_t lat = p.latitude_i; + const int32_t lon = p.longitude_i; + const uint32_t now = getTime(); + bool favoriteResolved = false; + bool isFavorite = false; + + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + const meshtastic_Waypoint &wp = entry.waypoint; + if (!shouldTrack(wp, entry.notificationPreferences, now)) + continue; + + const bool isInside = inside(wp, lat, lon); + const uint64_t key = crossingKey(wp.id, node); + CrossingState *state = findCrossingState(key); + const bool hasTrackedState = (state != nullptr); + const Crossing crossing = + classify(!hasTrackedState, hasTrackedState ? state->inside : false, isInside, + entry.notificationEnabled(WAYPOINT_NOTIFY_ENTER), entry.notificationEnabled(WAYPOINT_NOTIFY_EXIT)); + + // Record/baseline the current state (bounded - drop new pairs once the map is full). + if (!hasTrackedState) { + if (crossingInside.size() < GEOFENCE_MAX_CROSSING) { + crossingInside.push_back(CrossingState{key, isInside}); + } else { + static bool warnedCrossingFull = false; + if (!warnedCrossingFull) { + LOG_WARN("Geofence crossing-state full (%u); new (waypoint,node) pairs will not alert until space frees", + (unsigned)GEOFENCE_MAX_CROSSING); + warnedCrossingFull = true; + } + } + } else { + state->inside = isInside; + } + + if (crossing == Crossing::None) + continue; + + if (entry.notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY)) { + if (!favoriteResolved) { + isFavorite = nodeDB->isFavorite(node); + favoriteResolved = true; + } + if (!isFavorite) + continue; + } + + notify(wp, node, crossing == Crossing::Enter); + } +} + +void GeofenceModule::notify(const meshtastic_Waypoint &wp, NodeNum node, bool entered) +{ + // Resolve a display name for the crossing node. + char who[40]; + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node); + if (info && info->long_name[0]) { + strncpy(who, info->long_name, sizeof(who) - 1); + who[sizeof(who) - 1] = '\0'; + } else if (info && info->short_name[0]) { + strncpy(who, info->short_name, sizeof(who) - 1); + who[sizeof(who) - 1] = '\0'; + } else { + snprintf(who, sizeof(who), "!%08x", (unsigned)node); + } + + LOG_INFO("Geofence: %s %s '%s'", who, entered ? "entered" : "left", wp.name); + +#if HAS_SCREEN + if (screen) + powerFSM.trigger(EVENT_RECEIVED_MSG); // wake the screen so the banner is seen +#endif + + GeofenceNotificationEvent event; + event.waypointId = wp.id; + strncpy(event.nodeName, who, sizeof(event.nodeName) - 1); + event.nodeName[sizeof(event.nodeName) - 1] = '\0'; + event.entered = entered; + strncpy(event.geofenceName, wp.name, sizeof(event.geofenceName) - 1); + event.geofenceName[sizeof(event.geofenceName) - 1] = '\0'; + notifyObservers(&event); + +#if HAS_SCREEN && !defined(MESHTASTIC_INCLUDE_INKHUD) + if (screen) { + char banner[120]; + snprintf(banner, sizeof(banner), "%s %s %s", who, entered ? "IN" : "OUT", wp.name); + screen->showSimpleBanner(banner, 5000); + } +#endif + + if (externalNotificationModule) + externalNotificationModule->startNotification(); +} + +#endif diff --git a/src/modules/GeofenceModule.h b/src/modules/GeofenceModule.h new file mode 100644 index 0000000000..8ef0ca0620 --- /dev/null +++ b/src/modules/GeofenceModule.h @@ -0,0 +1,68 @@ +#pragma once + +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_WAYPOINT + +#include "Observer.h" +#include "WaypointStore.h" +#include "mesh/MeshTypes.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +struct GeofenceNotificationEvent { + uint32_t waypointId = 0; + char nodeName[sizeof(meshtastic_User::long_name)] = {}; + bool entered = false; + char geofenceName[sizeof(meshtastic_Waypoint::name)] = {}; +}; + +// Tracks other nodes crossing waypoint geofences and emits enter/exit notifications on the waypoint creator's device. +class GeofenceModule : public Observable +{ + public: + GeofenceModule(); + + enum class Crossing { None, Enter, Exit }; + + static bool insideRadius(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters); + + static bool insideBox(int32_t ptLat_i, int32_t ptLon_i, const meshtastic_BoundingBox &box); + + static bool insideAny(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters, + bool hasBox, const meshtastic_BoundingBox &box); + + static bool inside(const meshtastic_Waypoint &wp, int32_t ptLat_i, int32_t ptLon_i); + + static bool hasGeofence(const meshtastic_Waypoint &wp); + + // Box-only geofences do not require a waypoint center because their corners are absolute. + static bool shouldTrack(const meshtastic_Waypoint &wp, uint8_t notificationPreferences, uint32_t now); + + // The first sighting establishes a baseline without notifying. + static Crossing classify(bool firstSighting, bool wasInside, bool isInside, bool notifyOnEnter, bool notifyOnExit); + + void evaluatePosition(NodeNum node, const meshtastic_Position &p); + + private: + struct CrossingState { + uint64_t key; + bool inside; + }; + + static uint64_t crossingKey(uint32_t waypointId, NodeNum node) { return ((uint64_t)waypointId << 32) | node; } + + CrossingState *findCrossingState(uint64_t key); + void notify(const meshtastic_Waypoint &wp, NodeNum node, bool entered); + int onWaypointStoreChanged(const WaypointStore *store); + + // Bounded (waypointId, nodeNum) state; new pairs are skipped until an old waypoint frees space. + std::vector crossingInside; + CallbackObserver waypointStoreObserver = + CallbackObserver(this, &GeofenceModule::onWaypointStoreChanged); +}; + +extern GeofenceModule *geofenceModule; + +#endif diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 34700ed07f..d05ac35a34 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -55,6 +55,7 @@ #include "modules/TraceRouteModule.h" #endif #if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "modules/GeofenceModule.h" #include "modules/WaypointModule.h" #endif #if ARCH_PORTDUINO @@ -159,6 +160,7 @@ void setupModules() #endif #if !MESHTASTIC_EXCLUDE_WAYPOINT waypointModule = new WaypointModule(); + geofenceModule = new GeofenceModule(); #endif #if !MESHTASTIC_EXCLUDE_TEXTMESSAGE textMessageModule = new TextMessageModule(); diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 7f28189060..416c65f6e4 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -2,6 +2,7 @@ #include "PositionModule.h" #include "Default.h" #include "GPS.h" +#include "GeofenceModule.h" #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" @@ -55,6 +56,7 @@ PositionModule::PositionModule() bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_Position *pptr) { auto p = *pptr; + const NodeNum sender = getFrom(&mp); const auto transport = mp.transport_mechanism; if (isFromUs(&mp) && !IS_ONE_OF(transport, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_INTERNAL, @@ -90,7 +92,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes // Log packet size and data fields LOG_TRACE("POSITION node=0x%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d " "time=%d", - getFrom(&mp), mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae, + sender, mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae, p.altitude_geoidal_separation, p.PDOP, p.HDOP, p.VDOP, p.sats_in_view, p.fix_quality, p.fix_type, p.timestamp, p.time); @@ -107,9 +109,14 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes trySetRtc(p, isLocal, force); } - nodeDB->updatePosition(getFrom(&mp), p); + nodeDB->updatePosition(sender, p); precision = getPositionPrecisionForChannel(mp.channel); +#if !MESHTASTIC_EXCLUDE_WAYPOINT + if (geofenceModule && !isLocal) + geofenceModule->evaluatePosition(sender, p); +#endif + return false; // Let others look at this message also if they want } diff --git a/src/modules/WaypointModule.cpp b/src/modules/WaypointModule.cpp index d9f3b30cd3..8f31294843 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -1,222 +1,409 @@ #include "WaypointModule.h" #include "NodeDB.h" #include "PowerFSM.h" +#include "WaypointUtils.h" #include "configuration.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/CompassRenderer.h" #include "meshUtils.h" +#include +#include +#include +#include -#if HAS_SCREEN +#if !MESHTASTIC_EXCLUDE_WAYPOINT +#include "ExternalNotificationModule.h" +#include "MeshService.h" +#include "WaypointStore.h" +#include "mesh/Router.h" +#include +#endif + +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT #include "gps/RTC.h" #include "graphics/Screen.h" #include "graphics/TimeFormatters.h" #include "graphics/draw/NodeListRenderer.h" +#include "graphics/draw/UIRenderer.h" #include "main.h" #endif WaypointModule *waypointModule; +#if HAS_SCREEN && !MESHTASTIC_EXCLUDE_WAYPOINT +namespace +{ + +constexpr int16_t WAYPOINT_ROW_GAP = 2; + +void drawFallbackWaypointIcon(OLEDDisplay *display, int16_t left, int16_t top, uint16_t boxSize) +{ + const int16_t cx = left + (boxSize / 2); + const int16_t circleY = top + std::max(2, boxSize / 3); + const int16_t r = std::max(1, boxSize / 4); + display->drawCircle(cx, circleY, r); + display->drawLine(cx, circleY + r, cx, top + boxSize - 2); + display->setPixel(cx - 1, top + boxSize - 2); + display->setPixel(cx + 1, top + boxSize - 2); +} + +void drawWaypointIcon(OLEDDisplay *display, const meshtastic_Waypoint &wp, int16_t left, int16_t top, uint16_t boxSize) +{ + if (!wp.icon) { + drawFallbackWaypointIcon(display, left, top, boxSize); + return; + } + + const std::string utf8 = WaypointUtils::utf8FromCodepoint(wp.icon); + if (utf8.empty()) { + drawFallbackWaypointIcon(display, left, top, boxSize); + return; + } + + graphics::UIRenderer::drawStringWithEmotes(display, left, top, utf8, FONT_HEIGHT_SMALL, 1, false); +} + +void formatWaypointDistance(char *out, size_t outSize, float meters) +{ + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { + const float feet = meters * METERS_TO_FEET; + snprintf(out, outSize, feet < (2 * MILES_TO_FEET) ? "%.0fft" : "%.1fmi", + feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET); + } else { + snprintf(out, outSize, meters < 2000 ? "%.0fm" : "%.1fkm", meters < 2000 ? meters : meters / 1000); + } +} + +void formatWaypointCoordinates(char *out, size_t outSize, const meshtastic_Waypoint &wp) +{ + if (!(wp.has_latitude_i && wp.has_longitude_i)) { + snprintf(out, outSize, "--"); + return; + } + + snprintf(out, outSize, "%.4f,%.4f", wp.latitude_i * 1e-7, wp.longitude_i * 1e-7); +} + +void formatWaypointExpire(char *out, size_t outSize, const meshtastic_Waypoint &wp) +{ + if (wp.expire == 0) { + out[0] = '\0'; + return; + } + + const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now == 0) { + out[0] = '\0'; + return; + } + if (wp.expire <= now) { + snprintf(out, outSize, "0m"); + return; + } + + const uint32_t left = wp.expire - now; + if (left < 3600) + snprintf(out, outSize, "%lum", (unsigned long)((left + 59) / 60)); + else if (left < 86400) + snprintf(out, outSize, "%luh", (unsigned long)((left + 3599) / 3600)); + else + snprintf(out, outSize, "%lud", (unsigned long)((left + 86399) / 86400)); +} + +std::string trimmedWaypointText(const char *text) +{ + if (!text) + return ""; + + std::string value(text); + const auto first = std::find_if(value.begin(), value.end(), [](unsigned char c) { return !std::isspace(c); }); + if (first == value.end()) + return ""; + + const auto last = std::find_if(value.rbegin(), value.rend(), [](unsigned char c) { return !std::isspace(c); }).base(); + return std::string(first, last); +} + +size_t collectDrawableWaypoints(const StoredWaypoint *entries[], size_t maxEntries) +{ + size_t count = 0; + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (WaypointStore::isExpired(entry)) + continue; + if (count >= maxEntries) + break; + entries[count] = &entry; + ++count; + } + + return count; +} + +void drawDottedHorizontalDivider(OLEDDisplay *display, int16_t xStart, int16_t xEnd, int16_t y) +{ + for (int16_t x = xStart; x <= xEnd; x += 2) { + display->setPixel(x, y); + } +} + +void notifyWaypointReceived(const StoredWaypoint &stored) +{ + if (screen) { + const std::string waypointName = trimmedWaypointText(stored.waypoint.name); + if (!waypointName.empty()) { + char banner[96]; + snprintf(banner, sizeof(banner), "New Waypoint\n%s", waypointName.c_str()); + screen->showSimpleBanner(banner, 3000); + } else { + screen->showSimpleBanner("New Waypoint", 3000); + } + } + + if (externalNotificationModule) + externalNotificationModule->startNotification(); +} + +} // namespace +#endif + ProcessMessage WaypointModule::handleReceived(const meshtastic_MeshPacket &mp) { #if defined(DEBUG_PORT) && !defined(DEBUG_MUTE) auto &p = mp.decoded; LOG_INFO("Received waypoint msg from=0x%08x, id=0x%08x, msg=%.*s", mp.from, mp.id, p.payload.size, p.payload.bytes); #endif - // We only store/display messages destined for us. - // Keep a copy of the most recent text message. - devicestate.rx_waypoint = mp; - devicestate.has_rx_waypoint = true; +#if MESHTASTIC_EXCLUDE_WAYPOINT + (void)mp; + return ProcessMessage::CONTINUE; +#else + StoredWaypoint stored; + if (!waypointStore.addFromPacket(mp, isFromUs(&mp), &stored)) + return ProcessMessage::CONTINUE; powerFSM.trigger(EVENT_RECEIVED_MSG); #if HAS_SCREEN + if (!isFromUs(&mp) && !WaypointStore::isExpired(stored)) + notifyWaypointReceived(stored); UIFrameEvent e; - - // New or updated waypoint: focus on this frame next time Screen::setFrames runs - if (shouldDraw()) { - requestFocus(); - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; - } - - // Deleting an old waypoint: remove the frame quietly, don't change frame position if possible - else - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; + // Refresh the waypoint frame list quietly; new waypoints alert via banner/sound but do not + // steal focus from the screen the user is already on. + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; notifyObservers(&e); #endif return ProcessMessage::CONTINUE; // Let others look at this message also if they want +#endif } +#if !MESHTASTIC_EXCLUDE_WAYPOINT +bool WaypointModule::broadcastDelete(uint32_t waypointId) +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + bool found = false; + for (const auto &entry : waypointStore.getWaypoints()) { + if (entry.waypoint.id == waypointId) { + wp = entry.waypoint; + found = true; + break; + } + } + if (!found) + return false; + + // Respect the waypoint's lock: we may remove a locked waypoint from our own device, but + // we're not the owner, so we have no authority to delete it mesh-wide. + const NodeNum localNodeNum = nodeDB ? nodeDB->getNodeNum() : 0; + if (wp.locked_to != 0 && wp.locked_to != localNodeNum) { + LOG_INFO("Waypoint 0x%08x is locked to 0x%08x; removing locally only", waypointId, wp.locked_to); + waypointStore.removeWaypoint(waypointId); + return true; + } + + // Already-expired = the mesh convention for "delete this waypoint". + wp.expire = 1; + + if (!service) + return false; + + meshtastic_MeshPacket *p = router ? router->allocForSending() : nullptr; + if (!p) + return false; + + p->decoded.portnum = meshtastic_PortNum_WAYPOINT_APP; + p->decoded.payload.size = + pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Waypoint_msg, &wp); + if (p->decoded.payload.size == 0) { + packetPool.release(p); + return false; + } + + service->sendToMesh(p, RX_SRC_USER); + + waypointStore.removeWaypoint(waypointId); + + return true; +} +#endif + #if HAS_SCREEN bool WaypointModule::shouldDraw() { #if !MESHTASTIC_EXCLUDE_WAYPOINT - if (!screen || !devicestate.has_rx_waypoint) + if (!screen || waypointStore.getWaypoints().empty()) return false; - meshtastic_Waypoint wp{}; // <- replaces memset - if (pb_decode_from_bytes(devicestate.rx_waypoint.decoded.payload.bytes, devicestate.rx_waypoint.decoded.payload.size, - &meshtastic_Waypoint_msg, &wp)) { - // getTime() counts from boot until the RTC is set, which reads every real expiry as future. - return waypointIsActive(wp.expire, getValidTime(RTCQualityFromNet)); + for (const StoredWaypoint &entry : waypointStore.getWaypoints()) { + if (!WaypointStore::isExpired(entry)) + return true; } - return false; // no LOG_ERROR, no flag writes + return false; #else return false; #endif } -/// Draw the last waypoint we received -void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +void WaypointModule::onDeviceTimeChanged() { +#if !MESHTASTIC_EXCLUDE_WAYPOINT if (!screen) return; + + // Refresh only; never steal focus. + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET_BACKGROUND; + notifyObservers(&e); +#endif +} + +/// Draw the newest non-expired waypoints we received +void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + (void)state; +#if MESHTASTIC_EXCLUDE_WAYPOINT + (void)display; + (void)x; + (void)y; + return; +#else + if (!screen) + return; + display->clear(); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - int line = 1; + const StoredWaypoint *entries[WAYPOINT_HISTORY_LIMIT]; + const size_t totalWaypoints = collectDrawableWaypoints(entries, WAYPOINT_HISTORY_LIMIT); + if (totalWaypoints == 0) + return; - // === Set Title - const char *titleStr = "Waypoint"; - - // === Header === + const char *titleStr = (totalWaypoints == 1) ? "Waypoint" : "Waypoints"; graphics::drawCommonHeader(display, x, y, titleStr); const int *textPos = graphics::getTextPositions(display); - // Decode the waypoint - const meshtastic_MeshPacket &mp = devicestate.rx_waypoint; - meshtastic_Waypoint wp{}; - if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Waypoint_msg, &wp)) { - devicestate.has_rx_waypoint = false; - return; - } - - // Sanitize before these reach the OLED renderer (defense-in-depth vs PB_VALIDATE_UTF8). - sanitizeUtf8(wp.name, sizeof(wp.name)); - sanitizeUtf8(wp.description, sizeof(wp.description)); - - // Get timestamp info. Will pass as a field to drawColumns - char lastStr[20]; - getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr)); - - // Will contain distance information, passed as a field to drawColumns - char distStr[20] = ""; - - // Get our node, to use our own position const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - - // Match compass sizing/placement to favorite node screen logic. - const int w = display->getWidth(); - int16_t compassRadius = 8; - int16_t compassX = x + w - compassRadius - 8; - int16_t compassY = y + display->getHeight() / 2; - - if (SCREEN_WIDTH > SCREEN_HEIGHT) { - const int16_t topY = textPos[1]; - const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); - const int16_t usableHeight = bottomY - topY - 5; - compassRadius = usableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - compassX = x + SCREEN_WIDTH - compassRadius - 8; - compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - } else { - // Waypoint content uses rows 1..4, so place the compass below that block. - const int yBelowContent = textPos[4] + FONT_HEIGHT_SMALL + 2; - const int margin = 4; -#if defined(USE_EINK) - const int iconSize = (graphics::currentResolution == graphics::ScreenResolution::High) ? 16 : 8; - const int navBarHeight = iconSize + 6; -#else - const int navBarHeight = 0; -#endif - const int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; - if (availableHeight > 0) { - compassRadius = availableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - if (compassRadius * 2 > SCREEN_WIDTH - 16) - compassRadius = (SCREEN_WIDTH - 16) / 2; - if (compassRadius < 8) - compassRadius = 8; - compassX = x + SCREEN_WIDTH / 2; - compassY = yBelowContent + availableHeight / 2; - } - } - const uint16_t compassDiam = compassRadius * 2; - const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode)); - const char *statusLine1 = nullptr; - const char *statusLine2 = nullptr; - - // Distance only needs our own position fix; compass/bearing additionally needs heading. - meshtastic_PositionLite ownPos; + meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero; const bool haveOwnPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ownPos); - if (hasOwnPositionFix && haveOwnPos) { - const meshtastic_PositionLite &op = ownPos; - const float d = - GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); - // Always show distance once we have an own-position fix, even without heading. - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - float feet = d * METERS_TO_FEET; - snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft" : "%.1fmi", - feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET); - } else { - snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm" : "%.1fkm", d < 2000 ? d : d / 1000); - } + const uint16_t iconWidth = FONT_HEIGHT_SMALL; + const uint16_t iconGap = 3; + const uint16_t nameX = iconWidth + iconGap; + const int16_t contentBottom = display->getHeight() - 1; + int16_t rowTop = textPos[1]; + for (size_t i = 0; i < totalWaypoints; ++i) { + const StoredWaypoint &entry = *entries[i]; + const meshtastic_Waypoint &wp = entry.waypoint; + + char safeName[sizeof(wp.name)]; + memcpy(safeName, wp.name, sizeof(safeName)); + safeName[sizeof(safeName) - 1] = '\0'; + sanitizeUtf8(safeName, sizeof(safeName)); + + char safeDescription[sizeof(wp.description)]; + memcpy(safeDescription, wp.description, sizeof(safeDescription)); + safeDescription[sizeof(safeDescription) - 1] = '\0'; + sanitizeUtf8(safeDescription, sizeof(safeDescription)); + + char distStr[20] = ""; + char coordStr[40]; + char expireStr[16]; + formatWaypointCoordinates(coordStr, sizeof(coordStr), wp); + formatWaypointExpire(expireStr, sizeof(expireStr), wp); + + const std::string description = trimmedWaypointText(safeDescription); + const bool hasDescription = !description.empty(); + const int16_t row1Y = rowTop; + const int16_t row2Y = row1Y + FONT_HEIGHT_SMALL + 1; + const int16_t rowMetaY = hasDescription ? (row2Y + FONT_HEIGHT_SMALL + 1) : row2Y; + const int16_t cardBottom = rowMetaY + FONT_HEIGHT_SMALL; + if (cardBottom > contentBottom) + break; + + bool showCompass = false; float myHeading = 0.0f; - const bool hasHeading = - graphics::CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading); - if (hasHeading) { - // Draw compass circle - display->drawCircle(compassX, compassY, compassRadius); - graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + float bearingToOther = 0.0f; + if (hasOwnPositionFix && haveOwnPos && wp.has_latitude_i && wp.has_longitude_i) { + const float d = GeoCoord::latLongToMeter(DegD(wp.latitude_i), DegD(wp.longitude_i), DegD(ownPos.latitude_i), + DegD(ownPos.longitude_i)); + formatWaypointDistance(distStr, sizeof(distStr), d); - // Compass bearing to waypoint - float bearingToOther = - GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(wp.latitude_i), DegD(wp.longitude_i)); - bearingToOther = graphics::CompassRenderer::adjustBearingForCompassMode(bearingToOther, myHeading); - graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); - - const float bearingToOtherDegrees = graphics::CompassRenderer::radiansToDegrees360(bearingToOther); - - // Distance to waypoint with relative bearing when heading is available. - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - float feet = d * METERS_TO_FEET; - snprintf(distStr, sizeof(distStr), feet < (2 * MILES_TO_FEET) ? "%.0fft %.0f°" : "%.1fmi %.0f°", - feet < (2 * MILES_TO_FEET) ? feet : feet / MILES_TO_FEET, bearingToOtherDegrees); - } else { - snprintf(distStr, sizeof(distStr), d < 2000 ? "%.0fm %.0f°" : "%.1fkm %.0f°", d < 2000 ? d : d / 1000, - bearingToOtherDegrees); + if (graphics::CompassRenderer::getHeadingRadians(DegD(ownPos.latitude_i), DegD(ownPos.longitude_i), myHeading)) { + showCompass = true; + bearingToOther = GeoCoord::bearing(DegD(ownPos.latitude_i), DegD(ownPos.longitude_i), DegD(wp.latitude_i), + DegD(wp.longitude_i)); + bearingToOther = graphics::CompassRenderer::adjustBearingForCompassMode(bearingToOther, myHeading); } - - } else { - statusLine1 = "No"; - statusLine2 = "Heading"; } - } else { - // No own fix yet, so compass/bearing data would be misleading. - statusLine1 = "No"; - statusLine2 = "Fix"; - } - if (statusLine1) { - display->drawCircle(compassX, compassY, compassRadius); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(compassX, compassY - FONT_HEIGHT_SMALL, statusLine1); - display->drawString(compassX, compassY, statusLine2); - } + const int16_t compactArrowCenterX = display->getWidth() - ((FONT_HEIGHT_SMALL > 10) ? 9 : 7); + const int16_t compactArrowCenterY = (hasDescription ? row2Y : row1Y) + (FONT_HEIGHT_SMALL / 2); + const int16_t compactContentRight = compactArrowCenterX - 8; + const char *distanceLabel = distStr[0] ? distStr : "--"; + const char *expireLabel = expireStr[0] ? expireStr : "--"; + const uint16_t metaWidth = + std::max(display->getStringWidth(distanceLabel), display->getStringWidth(expireLabel)) + 4; + const int16_t metaLeft = std::max(nameX + 16, compactContentRight - metaWidth); + const int16_t textRight = metaLeft - 4; + const uint16_t nameWidth = (textRight > nameX) ? (textRight - nameX) : 0; + const std::string shownName = graphics::UIRenderer::truncateStringWithEmotes(display, safeName, nameWidth); + const std::string shownDescription = + hasDescription ? graphics::UIRenderer::truncateStringWithEmotes(display, description, nameWidth) : std::string(); - display->setTextAlignment(TEXT_ALIGN_LEFT); // Something above me changes to a different alignment, forcing a fix here! - display->drawString(0, textPos[line++], lastStr); - display->drawString(0, textPos[line++], wp.name); - display->drawString(0, textPos[line++], wp.description); - if (distStr[0]) - display->drawString(0, textPos[line++], distStr); + drawWaypointIcon(display, wp, 0, row1Y, iconWidth); + graphics::UIRenderer::drawStringWithEmotes(display, nameX, row1Y, shownName, FONT_HEIGHT_SMALL, 1, false); + const int16_t underlineY = row1Y + FONT_HEIGHT_SMALL; + const int16_t underlineRight = + std::min(textRight, nameX + graphics::UIRenderer::measureStringWithEmotes(display, shownName) - 1); + if (underlineRight >= nameX) + display->drawLine(nameX, underlineY, underlineRight, underlineY); + + if (hasDescription) + graphics::UIRenderer::drawStringWithEmotes(display, nameX, row2Y, shownDescription, FONT_HEIGHT_SMALL, 1, false); + + if (showCompass) + graphics::NodeListRenderer::drawRelativeCompassArrow(display, compactArrowCenterX, compactArrowCenterY, + graphics::CompassRenderer::radiansToDegrees360(bearingToOther)); + + display->drawStringMaxWidth(nameX, rowMetaY, nameWidth, coordStr); + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(metaLeft + metaWidth - 1, row1Y, distanceLabel); + display->drawString(metaLeft + metaWidth - 1, rowMetaY, expireLabel); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const int16_t separatorY = cardBottom + 1; + const int16_t nextRowTop = separatorY + WAYPOINT_ROW_GAP; + if (i + 1 < totalWaypoints && nextRowTop + ((FONT_HEIGHT_SMALL * 2) + 1) <= contentBottom) { + drawDottedHorizontalDivider(display, 0, display->getWidth() - 1, separatorY); + rowTop = nextRowTop; + } else { + break; + } + } +#endif } #endif diff --git a/src/modules/WaypointModule.h b/src/modules/WaypointModule.h index 4c9c7b86b0..fc811c7139 100644 --- a/src/modules/WaypointModule.h +++ b/src/modules/WaypointModule.h @@ -14,6 +14,11 @@ class WaypointModule : public SinglePortModule, public Observable + +// These cover pure geofence helpers without device globals or a fake clock. +// Store and notification plumbing are not covered here. + +using Crossing = GeofenceModule::Crossing; + +// 0.001 deg of longitude at the equator is ~111 m; 0.001 deg = 10000 in degrees x 1e-7 units. +static const int32_t kLon111m = 10000; + +static void test_insideRadius_centreIsInside() +{ + TEST_ASSERT_TRUE(GeofenceModule::insideRadius(123456, 654321, 123456, 654321, 10)); +} + +static void test_insideRadius_withinRadius() +{ + // ~111 m east of the centre, radius 200 m -> inside. + TEST_ASSERT_TRUE(GeofenceModule::insideRadius(0, kLon111m, 0, 0, 200)); +} + +static void test_insideRadius_outsideRadius() +{ + // ~111 m east of the centre, radius 50 m -> outside. + TEST_ASSERT_FALSE(GeofenceModule::insideRadius(0, kLon111m, 0, 0, 50)); +} + +static void test_insideRadius_zeroRadiusNeverInside() +{ + // radius 0 means "no circle" -> never inside, even exactly at the centre. + TEST_ASSERT_FALSE(GeofenceModule::insideRadius(123456, 654321, 123456, 654321, 0)); +} + +static meshtastic_BoundingBox makeBox() +{ + meshtastic_BoundingBox box = meshtastic_BoundingBox_init_zero; + box.longitude_west_i = -1000; + box.latitude_south_i = -2000; + box.longitude_east_i = 1000; + box.latitude_north_i = 2000; + return box; +} + +static void test_insideBox_centreInside() +{ + TEST_ASSERT_TRUE(GeofenceModule::insideBox(0, 0, makeBox())); +} + +static void test_insideBox_edgesInclusive() +{ + meshtastic_BoundingBox box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::insideBox(box.latitude_north_i, box.longitude_east_i, box)); + TEST_ASSERT_TRUE(GeofenceModule::insideBox(box.latitude_south_i, box.longitude_west_i, box)); +} + +static void test_insideBox_outsideLat() +{ + TEST_ASSERT_FALSE(GeofenceModule::insideBox(2001, 0, makeBox())); +} + +static void test_insideBox_outsideLon() +{ + TEST_ASSERT_FALSE(GeofenceModule::insideBox(0, 1001, makeBox())); +} + +static void test_inside_circleOnly() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.latitude_i = 0; + wp.longitude_i = 0; + wp.geofence_radius = 200; + wp.has_bounding_box = false; + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 0, kLon111m)); + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 0, kLon111m * 4)); // ~444 m east +} + +static void test_inside_boxOnly() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 0; + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 0, 0)); + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 5000, 0)); +} + +static void test_inside_eitherShapeCounts() +{ + // Circle is tiny (point far from centre is outside it) but the box still contains the point. + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 1; // 1 m circle + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + TEST_ASSERT_TRUE(GeofenceModule::inside(wp, 1500, 0)); // outside circle, inside box +} + +static void test_inside_noGeofenceNeverInside() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 0; + wp.has_bounding_box = false; + TEST_ASSERT_FALSE(GeofenceModule::inside(wp, 0, 0)); + TEST_ASSERT_FALSE(GeofenceModule::hasGeofence(wp)); +} + +static meshtastic_Waypoint makeNotifyingWaypoint() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + return wp; +} + +static void test_shouldTrack_circleWithCentre() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_circleWithoutCentreRejected() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; // circle needs a centre + wp.has_latitude_i = false; + wp.has_longitude_i = false; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_boxOnlyWithoutCentreAccepted() +{ + // A box-only geofence carries absolute corners, so it needs no waypoint centre. + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 0; + wp.has_bounding_box = true; + wp.bounding_box = makeBox(); + wp.has_latitude_i = false; + wp.has_longitude_i = false; + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_noGeofenceRejected() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); // notify set, but no geofence shape + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); +} + +static void test_shouldTrack_noLocalPreferencesRejected() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + wp.notify_on_enter = true; + wp.notify_on_exit = true; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, 0, 0)); +} + +static void test_shouldTrack_expiredRejectedButLiveAccepted() +{ + meshtastic_Waypoint wp = makeNotifyingWaypoint(); + wp.geofence_radius = 100; + wp.has_latitude_i = true; + wp.has_longitude_i = true; + wp.expire = 1000; + TEST_ASSERT_FALSE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 2000)); // expire <= now -> expired + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 500)); // expire > now -> live + TEST_ASSERT_TRUE(GeofenceModule::shouldTrack(wp, WAYPOINT_NOTIFY_ENTER, 0)); // no clock -> treat as live +} + +static meshtastic_Waypoint makeWireNotificationWaypoint() +{ + meshtastic_Waypoint wp = meshtastic_Waypoint_init_zero; + wp.notify_on_enter = true; + wp.notify_on_exit = true; + wp.notify_favorites_only = true; + return wp; +} + +static void test_localWaypointInitializesPreferencesFromWireFields() +{ + const meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + const uint8_t preferences = WaypointStore::mergeNotificationPreferences(true, false, 0, wp); + TEST_ASSERT_EQUAL_UINT8(WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_EXIT | WAYPOINT_NOTIFY_FAVORITES_ONLY, preferences); +} + +static void test_newRemoteWaypointIgnoresWirePreferences() +{ + const meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + TEST_ASSERT_EQUAL_UINT8(0, WaypointStore::mergeNotificationPreferences(false, false, 0, wp)); +} + +static void test_remoteWaypointUpdatePreservesLocalPreferences() +{ + meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + wp.notify_on_enter = false; + const uint8_t existing = WAYPOINT_NOTIFY_ENTER | WAYPOINT_NOTIFY_FAVORITES_ONLY; + TEST_ASSERT_EQUAL_UINT8(existing, WaypointStore::mergeNotificationPreferences(false, true, existing, wp)); +} + +static void test_storedWaypoint_clearsWirePreferences() +{ + meshtastic_Waypoint wp = makeWireNotificationWaypoint(); + WaypointStore::clearWireNotificationPreferences(wp); + TEST_ASSERT_FALSE(wp.notify_on_enter); + TEST_ASSERT_FALSE(wp.notify_on_exit); + TEST_ASSERT_FALSE(wp.notify_favorites_only); +} + +static void test_first_sighting_no_notification() +{ + // First sighting only baselines, regardless of inside state or notify flags. + TEST_ASSERT_TRUE(GeofenceModule::classify(true, false, true, true, true) == Crossing::None); + TEST_ASSERT_TRUE(GeofenceModule::classify(true, false, false, true, true) == Crossing::None); +} + +static void test_classify_noTransitionNeverNotifies() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, true, true, true) == Crossing::None); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, false, true, true) == Crossing::None); +} + +static void test_classify_enterFiresOnlyWhenEnabled() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, true, true, false) == Crossing::Enter); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, false, true, false, false) == Crossing::None); +} + +static void test_classify_exitFiresOnlyWhenEnabled() +{ + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, false, false, true) == Crossing::Exit); + TEST_ASSERT_TRUE(GeofenceModule::classify(false, true, false, false, false) == Crossing::None); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_insideRadius_centreIsInside); + RUN_TEST(test_insideRadius_withinRadius); + RUN_TEST(test_insideRadius_outsideRadius); + RUN_TEST(test_insideRadius_zeroRadiusNeverInside); + RUN_TEST(test_insideBox_centreInside); + RUN_TEST(test_insideBox_edgesInclusive); + RUN_TEST(test_insideBox_outsideLat); + RUN_TEST(test_insideBox_outsideLon); + RUN_TEST(test_inside_circleOnly); + RUN_TEST(test_inside_boxOnly); + RUN_TEST(test_inside_eitherShapeCounts); + RUN_TEST(test_inside_noGeofenceNeverInside); + RUN_TEST(test_shouldTrack_circleWithCentre); + RUN_TEST(test_shouldTrack_circleWithoutCentreRejected); + RUN_TEST(test_shouldTrack_boxOnlyWithoutCentreAccepted); + RUN_TEST(test_shouldTrack_noGeofenceRejected); + RUN_TEST(test_shouldTrack_noLocalPreferencesRejected); + RUN_TEST(test_shouldTrack_expiredRejectedButLiveAccepted); + RUN_TEST(test_localWaypointInitializesPreferencesFromWireFields); + RUN_TEST(test_newRemoteWaypointIgnoresWirePreferences); + RUN_TEST(test_remoteWaypointUpdatePreservesLocalPreferences); + RUN_TEST(test_storedWaypoint_clearsWirePreferences); + RUN_TEST(test_first_sighting_no_notification); + RUN_TEST(test_classify_noTransitionNeverNotifies); + RUN_TEST(test_classify_enterFiresOnlyWhenEnabled); + RUN_TEST(test_classify_exitFiresOnlyWhenEnabled); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp index ebf47be9b2..b29c55341a 100644 --- a/test/test_utf8/test_main.cpp +++ b/test/test_utf8/test_main.cpp @@ -3,6 +3,7 @@ // would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup // would add portduino globals it otherwise never touches. Suite-level state cleanliness is still // checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. +#include "WaypointUtils.h" #include "meshUtils.h" #include #include @@ -168,6 +169,19 @@ void test_above_max_codepoint() TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); } +void test_waypoint_codepoint_encoding() +{ + TEST_ASSERT_EQUAL_STRING("A", WaypointUtils::utf8FromCodepoint('A').c_str()); + TEST_ASSERT_EQUAL_STRING("\xF0\x9F\x93\x8D", WaypointUtils::utf8FromCodepoint(0x1F4CD).c_str()); +} + +void test_waypoint_codepoint_rejects_invalid_scalars() +{ + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0).empty()); + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0xD800).empty()); + TEST_ASSERT_TRUE(WaypointUtils::utf8FromCodepoint(0x110000).empty()); +} + // --- clampLongName: local 24-byte cap over wider wire buffers --- void test_clamp_long_name_short_unchanged() @@ -236,6 +250,8 @@ void setup() RUN_TEST(test_zero_size); RUN_TEST(test_valid_max_codepoint); RUN_TEST(test_above_max_codepoint); + RUN_TEST(test_waypoint_codepoint_encoding); + RUN_TEST(test_waypoint_codepoint_rejects_invalid_scalars); // clampLongName RUN_TEST(test_clamp_long_name_short_unchanged); diff --git a/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h b/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h index fb0744bc3b..42e57f9a69 100644 --- a/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h +++ b/variants/esp32s3/heltec_vision_master_e213/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -88,6 +89,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h b/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h index a90500b150..60584ee58a 100644 --- a/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h +++ b/variants/esp32s3/heltec_vision_master_e290/nicheGraphics.h @@ -29,6 +29,7 @@ Different NicheGraphics UIs and different hardware variants will each have their #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -85,6 +86,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h b/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h index 9e84a541e1..d33db47f75 100644 --- a/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h +++ b/variants/esp32s3/heltec_wireless_paper/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -86,6 +87,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - diff --git a/variants/esp32s3/mini-epaper-s3/nicheGraphics.h b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h index 0cbd6a1922..a330869d8e 100644 --- a/variants/esp32s3/mini-epaper-s3/nicheGraphics.h +++ b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/InkHUD/SystemApplet.h" // Shared NicheGraphics components @@ -67,6 +68,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet, false, false); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, false, false); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/esp32s3/t5s3_epaper/nicheGraphics.h b/variants/esp32s3/t5s3_epaper/nicheGraphics.h index 032ea100ec..62c22f1ef9 100644 --- a/variants/esp32s3/t5s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/t5s3_epaper/nicheGraphics.h @@ -31,6 +31,7 @@ This is driven via the FastEPD library through the NicheGraphics ED047TC1 driver #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -83,6 +84,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // Not Active, not autoshown inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false); // Activated, not autoshown + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet, false, false); // Not Active, not autoshown inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false); // Activated, not autoshown inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // Not Active, not autoshown diff --git a/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h b/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h index faa9a41b95..06db0f0896 100644 --- a/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h @@ -17,6 +17,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 diff --git a/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h b/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h index 242e5ae495..da44eb47c5 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h +++ b/variants/nrf52840/ELECROW-ThinkNode-M1/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -77,6 +78,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h index 0a01b613e9..37d0171563 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -74,6 +75,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, false, 2); // Default on tile 2 inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false, 1); // Default on tile 1 + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false, 0); // Default on tile 0 inkhud->addApplet("Heard", new InkHUD::HeardApplet, true); // Background diff --git a/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h b/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h index ad17e74572..551f7f07b8 100644 --- a/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_node_t114-inkhud/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -75,6 +76,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, false, 2); // Default on tile 2 inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true, false, 1); // Default on tile 1 + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false, 0); // Default on tile 0 inkhud->addApplet("Heard", new InkHUD::HeardApplet, true); // Background diff --git a/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h b/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h index 187022ea71..02a59298ac 100644 --- a/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_pocket/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h b/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h index 0f41319167..c4e97827f1 100644 --- a/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h +++ b/variants/nrf52840/heltec_mesh_solar/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -68,6 +69,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h b/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h index 2a2967f5ec..f118651e67 100644 --- a/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h +++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/nicheGraphics.h @@ -17,6 +17,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -74,6 +75,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0 diff --git a/variants/nrf52840/t-echo-plus/nicheGraphics.h b/variants/nrf52840/t-echo-plus/nicheGraphics.h index 73067d7a75..1d408f40be 100644 --- a/variants/nrf52840/t-echo-plus/nicheGraphics.h +++ b/variants/nrf52840/t-echo-plus/nicheGraphics.h @@ -13,6 +13,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" #include "graphics/niche/InkHUD/InkHUD.h" #include "graphics/niche/Inputs/TwoButton.h" @@ -44,6 +45,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); diff --git a/variants/nrf52840/t-echo/nicheGraphics.h b/variants/nrf52840/t-echo/nicheGraphics.h index c0b24dea77..1f9873f054 100644 --- a/variants/nrf52840/t-echo/nicheGraphics.h +++ b/variants/nrf52840/t-echo/nicheGraphics.h @@ -16,6 +16,7 @@ #include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" #include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" #include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h" // Shared NicheGraphics components // -------------------------------- @@ -80,6 +81,7 @@ void setupNicheGraphics() inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0)); // - inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1)); // - inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Waypoints", new InkHUD::WaypointListApplet); // - inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet); // - inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet); // - inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, no autoshow, default on tile 0