Files
cd6ac90f7e Add waypoint & geofence support with notifications for BaseUI and InkHUD (#10920)
* Implement GeofenceModule for waypoint crossing notifications and integrate with existing modules

* Waypoint Applet Initial Support on InkHUD

* undo tile change

* Update screen when Waypoint shows or dissapears

* Merge branch 'develop' into waypoint-geofence

* Geofence on InkHUD

* Update MapTile.h

* Update WaypointStore.cpp

* Notifications

* remove GF from waypoint screen

* Prevent Focus from closing the notifiaction banner

* Trunk fix

* cleanup

* undo merge conflix mistake

* Waypoint screen on BaseUI

* Focus preserve fix

* UI bugs

* Allow Inkhud to remove waypoint

* Respect Locked Waypoints

* Trunk fix

* Update WaypointStore.cpp

* Use 8-digit hex formatting for waypoint IDs.

0x%x was inconsistent with the repo's own convention (0x%08x for 32-bit IDs, used elsewhere in this file). Fixed here and in two other spots I found with the same issue (WaypointModule.cpp, GeofenceModule.cpp).

* Update ExternalNotificationModule.cpp

* Reject invalid surrogate codepoints in waypoint icon rendering

* Update WaypointModule.cpp

* Update WaypointStore.cpp

* Update WaypointStore.cpp

* Update WaypointStore.cpp

* trunk fix

* fix warnings

* power.h rename to Power.h

* Update Power.h

* Fix executable bit on bin/lint-ifdef-complexity.sh

Lost during a prior merge from develop (Windows checkout doesn't
preserve file mode), causing "execve failed: Permission denied" in
the Trunk Check Runner CI job. develop has this file at 100755;
restoring that here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update README.md

* Clean up waypoint and geofence integration

* Minimize waypoint and geofence implementation

* removed unnecessary gating

* Geofence alert

* trunk fix

* Update test_main.cpp

* Update WaypointStore.cpp

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:17:28 +00:00

277 lines
9.6 KiB
C++

#include "TestUtil.h"
#include "modules/GeofenceModule.h"
#include <unity.h>
// 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() {}
}