diff --git a/CMakeLists.txt b/CMakeLists.txt
index ed5c0c568..563928daf 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -173,6 +173,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
add_subdirectory(Modules/gps-module)
add_subdirectory(Modules/graphics-module)
add_subdirectory(Modules/http-module)
+ add_subdirectory(Modules/location-module)
add_subdirectory(Modules/mbedtls-module)
add_subdirectory(Modules/mesh-module)
add_subdirectory(Modules/mesh-meshcore-module)
diff --git a/Modules/location-module/CMakeLists.txt b/Modules/location-module/CMakeLists.txt
new file mode 100644
index 000000000..16b39e520
--- /dev/null
+++ b/Modules/location-module/CMakeLists.txt
@@ -0,0 +1,24 @@
+cmake_minimum_required(VERSION 3.20)
+
+include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake")
+
+file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
+
+list(APPEND REQUIRES_LIST
+ TactilityKernel
+ TactilityKernelCpp
+ service-module
+ gps-module
+ minmea
+)
+
+if (DEFINED ENV{ESP_IDF_VERSION})
+ list(APPEND REQUIRES_LIST lwip)
+endif ()
+
+tactility_add_module(location-module
+ SRCS ${SOURCE_FILES}
+ PRIV_INCLUDE_DIRS private/
+ INCLUDE_DIRS include/
+ REQUIRES ${REQUIRES_LIST}
+)
diff --git a/Modules/location-module/LICENSE-Apache-2.0.md b/Modules/location-module/LICENSE-Apache-2.0.md
new file mode 100644
index 000000000..f5f4b8b5e
--- /dev/null
+++ b/Modules/location-module/LICENSE-Apache-2.0.md
@@ -0,0 +1,195 @@
+Apache License
+==============
+
+_Version 2.0, January 2004_
+_<>_
+
+### Terms and Conditions for use, reproduction, and distribution
+
+#### 1. Definitions
+
+“License” shall mean the terms and conditions for use, reproduction, and
+distribution as defined by Sections 1 through 9 of this document.
+
+“Licensor” shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+“Legal Entity” shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, “control” means **(i)** the power, direct or
+indirect, to cause the direction or management of such entity, whether by
+contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
+outstanding shares, or **(iii)** beneficial ownership of such entity.
+
+“You” (or “Your”) shall mean an individual or Legal Entity exercising
+permissions granted by this License.
+
+“Source” form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+“Object” form shall mean any form resulting from mechanical transformation or
+translation of a Source form, including but not limited to compiled object code,
+generated documentation, and conversions to other media types.
+
+“Work” shall mean the work of authorship, whether in Source or Object form, made
+available under the License, as indicated by a copyright notice that is included
+in or attached to the work (an example is provided in the Appendix below).
+
+“Derivative Works” shall mean any work, whether in Source or Object form, that
+is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative Works
+shall not include works that remain separable from, or merely link (or bind by
+name) to the interfaces of, the Work and Derivative Works thereof.
+
+“Contribution” shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative Works
+thereof, that is intentionally submitted to Licensor for inclusion in the Work
+by the copyright owner or by an individual or Legal Entity authorized to submit
+on behalf of the copyright owner. For the purposes of this definition,
+“submitted” means any form of electronic, verbal, or written communication sent
+to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems, and
+issue tracking systems that are managed by, or on behalf of, the Licensor for
+the purpose of discussing and improving the Work, but excluding communication
+that is conspicuously marked or otherwise designated in writing by the copyright
+owner as “Not a Contribution.”
+
+“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently
+incorporated within the Work.
+
+#### 2. Grant of Copyright License
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable copyright license to reproduce, prepare Derivative Works of,
+publicly display, publicly perform, sublicense, and distribute the Work and such
+Derivative Works in Source or Object form.
+
+#### 3. Grant of Patent License
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable (except as stated in this section) patent license to make, have
+made, use, offer to sell, sell, import, and otherwise transfer the Work, where
+such license applies only to those patent claims licensable by such Contributor
+that are necessarily infringed by their Contribution(s) alone or by combination
+of their Contribution(s) with the Work to which such Contribution(s) was
+submitted. If You institute patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that the Work or a
+Contribution incorporated within the Work constitutes direct or contributory
+patent infringement, then any patent licenses granted to You under this License
+for that Work shall terminate as of the date such litigation is filed.
+
+#### 4. Redistribution
+
+You may reproduce and distribute copies of the Work or Derivative Works thereof
+in any medium, with or without modifications, and in Source or Object form,
+provided that You meet the following conditions:
+
+* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
+this License; and
+* **(b)** You must cause any modified files to carry prominent notices stating that You
+changed the files; and
+* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
+all copyright, patent, trademark, and attribution notices from the Source form
+of the Work, excluding those notices that do not pertain to any part of the
+Derivative Works; and
+* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
+Derivative Works that You distribute must include a readable copy of the
+attribution notices contained within such NOTICE file, excluding those notices
+that do not pertain to any part of the Derivative Works, in at least one of the
+following places: within a NOTICE text file distributed as part of the
+Derivative Works; within the Source form or documentation, if provided along
+with the Derivative Works; or, within a display generated by the Derivative
+Works, if and wherever such third-party notices normally appear. The contents of
+the NOTICE file are for informational purposes only and do not modify the
+License. You may add Your own attribution notices within Derivative Works that
+You distribute, alongside or as an addendum to the NOTICE text from the Work,
+provided that such additional attribution notices cannot be construed as
+modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction, or
+distribution of Your modifications, or for any such Derivative Works as a whole,
+provided Your use, reproduction, and distribution of the Work otherwise complies
+with the conditions stated in this License.
+
+#### 5. Submission of Contributions
+
+Unless You explicitly state otherwise, any Contribution intentionally submitted
+for inclusion in the Work by You to the Licensor shall be under the terms and
+conditions of this License, without any additional terms or conditions.
+Notwithstanding the above, nothing herein shall supersede or modify the terms of
+any separate license agreement you may have executed with Licensor regarding
+such Contributions.
+
+#### 6. Trademarks
+
+This License does not grant permission to use the trade names, trademarks,
+service marks, or product names of the Licensor, except as required for
+reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+#### 7. Disclaimer of Warranty
+
+Unless required by applicable law or agreed to in writing, Licensor provides the
+Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
+including, without limitation, any warranties or conditions of TITLE,
+NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
+solely responsible for determining the appropriateness of using or
+redistributing the Work and assume any risks associated with Your exercise of
+permissions under this License.
+
+#### 8. Limitation of Liability
+
+In no event and under no legal theory, whether in tort (including negligence),
+contract, or otherwise, unless required by applicable law (such as deliberate
+and grossly negligent acts) or agreed to in writing, shall any Contributor be
+liable to You for damages, including any direct, indirect, special, incidental,
+or consequential damages of any character arising as a result of this License or
+out of the use or inability to use the Work (including but not limited to
+damages for loss of goodwill, work stoppage, computer failure or malfunction, or
+any and all other commercial damages or losses), even if such Contributor has
+been advised of the possibility of such damages.
+
+#### 9. Accepting Warranty or Additional Liability
+
+While redistributing the Work or Derivative Works thereof, You may choose to
+offer, and charge a fee for, acceptance of support, warranty, indemnity, or
+other liability obligations and/or rights consistent with this License. However,
+in accepting such obligations, You may act only on Your own behalf and on Your
+sole responsibility, not on behalf of any other Contributor, and only if You
+agree to indemnify, defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason of your
+accepting any such warranty or additional liability.
+
+_END OF TERMS AND CONDITIONS_
+
+### APPENDIX: How to apply the Apache License to your work
+
+To apply the Apache License to your work, attach the following boilerplate
+notice, with the fields enclosed by brackets `[]` replaced with your own
+identifying information. (Don't include the brackets!) The text should be
+enclosed in the appropriate comment syntax for the file format. We also
+recommend that a file or class name and description of purpose be included on
+the same “printed page” as the copyright notice for easier identification within
+third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
diff --git a/Modules/location-module/include/location/location.h b/Modules/location-module/include/location/location.h
new file mode 100644
index 000000000..8793d1afe
--- /dev/null
+++ b/Modules/location-module/include/location/location.h
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+#include
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Location service. Location providers (e.g. GPS) report fixes to the service.
+ * The most recent fix of a regular provider is used while it's less than 2 minutes old.
+ * Otherwise, the most recent fix of a fallback provider (e.g. IP geolocation) is used, if there is one.
+ * Providers only run while there is at least one subscription.
+ */
+
+#define LOCATION_PROVIDER_ID_MAX_LENGTH 15
+
+/** Horizontal accuracy in meters of a fix with LOCATION_PRECISION_APPROXIMATE */
+#define LOCATION_APPROXIMATE_ACCURACY_METERS 3000.0f
+
+enum LocationPrecision {
+ /** Providers only report fixes with a horizontal accuracy of LOCATION_APPROXIMATE_ACCURACY_METERS or worse */
+ LOCATION_PRECISION_APPROXIMATE,
+ /** Providers report fixes as accurate as they can */
+ LOCATION_PRECISION_PRECISE,
+};
+
+/**
+ * A location fix. Values the provider doesn't know are NAN for floating point fields and 0 for the others.
+ * Accuracies are the radius of 68% confidence, in the unit of the value they belong to.
+ */
+struct Location {
+ /** Degrees in WGS84, positive is north */
+ double latitude;
+ /** Degrees in WGS84, positive is east */
+ double longitude;
+ /** Meters */
+ float horizontal_accuracy;
+ /** Meters above mean sea level */
+ float altitude;
+ /** Meters above the WGS84 ellipsoid */
+ float ellipsoidal_altitude;
+ /** Meters */
+ float vertical_accuracy;
+ /** Speed over ground in meters per second */
+ float speed;
+ /** Meters per second */
+ float speed_accuracy;
+ /** Direction of travel in degrees clockwise from true north, in range [0, 360) */
+ float course;
+ /** Degrees */
+ float course_accuracy;
+ /** Number of satellites used for the fix */
+ uint8_t satellites;
+ /** Time of the fix according to the provider, in milliseconds since the Unix epoch */
+ int64_t time_ms;
+ /** Milliseconds since boot when the service received the fix */
+ uint64_t uptime_ms;
+ /** Id of the provider that reported the fix */
+ char provider[LOCATION_PROVIDER_ID_MAX_LENGTH + 1];
+};
+
+/**
+ * Allow or disallow the use of location services. The service only runs while allowed. The choice is persisted.
+ * Location services are disallowed by default.
+ */
+error_t location_set_enabled(bool enabled);
+
+bool location_is_enabled(void);
+
+/** Set the precision that providers should report. The choice is persisted. */
+error_t location_set_precision(enum LocationPrecision precision);
+
+enum LocationPrecision location_get_precision(void);
+
+/**
+ * Get the current fix. Without subscriptions, this is the last known fix.
+ * @param[out] location the fix
+ * @retval ERROR_NOT_FOUND when no fix was reported since the service started, or location services are disabled
+ */
+error_t location_get(struct Location* location);
+
+/**
+ * Caller-owned subscription: the service signals @a bit on the event group whenever a new current fix is reported.
+ * Read the fix with location_get().
+ * @warning Fields other than `bit` are for internal use only.
+ */
+struct LocationSubscription {
+ uint32_t bit;
+
+ struct {
+ struct TaskEventGroup* event_group;
+ struct LocationSubscription* next;
+ } internal;
+};
+
+/**
+ * Subscribing starts the providers when location services are enabled. They stop when the last subscription is removed.
+ * @param[in,out] sub subscription to register, must stay alive until unsubscribed
+ * @param[in] event_group caller-owned group to wait on, must outlive @a sub
+ * @retval ERROR_RESOURCE @a event_group has no free bits left
+ * @retval ERROR_INVALID_STATE @a sub is already registered
+ */
+error_t location_subscribe(struct LocationSubscription* sub, struct TaskEventGroup* event_group);
+
+/** @retval ERROR_NOT_FOUND when @a sub is not registered */
+error_t location_unsubscribe(struct LocationSubscription* sub);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/location-module/include/location/module.h b/Modules/location-module/include/location/module.h
new file mode 100644
index 000000000..458154739
--- /dev/null
+++ b/Modules/location-module/include/location/module.h
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+extern struct Module location_module;
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/location-module/include/location/provider.h b/Modules/location-module/include/location/provider.h
new file mode 100644
index 000000000..64ce1a8ba
--- /dev/null
+++ b/Modules/location-module/include/location/provider.h
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Interface between the location service and a location provider. Not part of the app SDK.
+ */
+
+/** The service, as seen by a provider */
+struct LocationHost;
+
+struct LocationProviderApi {
+ /** Unique id, at most LOCATION_PROVIDER_ID_MAX_LENGTH characters */
+ const char* id;
+
+ /**
+ * A fallback provider (e.g. IP geolocation) is only used while no other provider reported a fix in the last
+ * 2 minutes. It should only work when location_host_needs_fallback() returns true.
+ */
+ bool fallback;
+
+ /**
+ * Start the provider. Called when the service starts, or when the provider registers while the service runs.
+ * @return the provider state, NULL on failure
+ */
+ void* (*create)(struct LocationHost* host);
+
+ /** Stop the provider. It must not report anymore once this returns. */
+ void (*destroy)(void* provider);
+};
+
+/** Maximum number of registered providers */
+#define LOCATION_PROVIDER_MAX_COUNT 4
+
+/**
+ * Register a provider. Usually called from the provider module's start().
+ * @retval ERROR_ALREADY_EXISTS when a provider with the same id is registered
+ * @retval ERROR_RESOURCE when LOCATION_PROVIDER_MAX_COUNT providers are registered
+ */
+error_t location_provider_register(const struct LocationProviderApi* api);
+
+/** @retval ERROR_NOT_FOUND when @a api is not registered */
+error_t location_provider_unregister(const struct LocationProviderApi* api);
+
+/**
+ * Report a new fix. Can be called from any task.
+ * The service sets the `provider` and `uptime_ms` fields.
+ */
+void location_host_report(struct LocationHost* host, const struct Location* location);
+
+/**
+ * Reduce a fix to LOCATION_PRECISION_APPROXIMATE: the position snaps to a grid and only position and time remain.
+ * Providers that can report precise fixes apply this when location_get_precision() is LOCATION_PRECISION_APPROXIMATE.
+ */
+void location_make_approximate(struct Location* location);
+
+/** @return true when no provider that isn't a fallback reported a fix in the last 2 minutes */
+bool location_host_needs_fallback(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/location-module/private/location/gps_provider.h b/Modules/location-module/private/location/gps_provider.h
new file mode 100644
index 000000000..3c450f01c
--- /dev/null
+++ b/Modules/location-module/private/location/gps_provider.h
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+#include
+
+#include
+
+/** Uses the first started GPS device */
+extern const LocationProviderApi gps_location_provider;
+
+/**
+ * Convert the sentences of one GPS epoch into a fix.
+ * @param[in] gga the GGA sentence of the same epoch, NULL when there is none
+ * @return false when @a rmc has no valid fix
+ */
+bool gps_location_from_nmea(const minmea_sentence_rmc* rmc, const minmea_sentence_gga* gga, Location* location);
diff --git a/Modules/location-module/private/location/ip_provider.h b/Modules/location-module/private/location/ip_provider.h
new file mode 100644
index 000000000..75212ac78
--- /dev/null
+++ b/Modules/location-module/private/location/ip_provider.h
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#include
+
+/** Fallback provider that looks up the location of the public IP address with ip-api.com */
+extern const LocationProviderApi ip_location_provider;
+
+#define IP_API_MESSAGE_MAX_LENGTH 63
+
+struct IpApiResponse {
+ /** HTTP status code */
+ int status_code;
+ /** Requests left in the rate limit window, -1 when unknown */
+ int rate_remaining;
+ /** Seconds until the rate limit window resets, -1 when unknown */
+ int rate_reset_seconds;
+ bool success;
+ /** Reason of failure, empty on success */
+ char message[IP_API_MESSAGE_MAX_LENGTH + 1];
+ double latitude;
+ double longitude;
+};
+
+/**
+ * Parse an HTTP response of the newline separated ip-api endpoint, requested with fields "status,message,lat,lon"
+ * @param[in] response the NUL-terminated response, including the status line and headers
+ * @return false when the response is malformed
+ */
+bool ip_api_parse_response(const char* response, IpApiResponse* out);
diff --git a/Modules/location-module/private/location/location_service.h b/Modules/location-module/private/location/location_service.h
new file mode 100644
index 000000000..ed5f0cb4b
--- /dev/null
+++ b/Modules/location-module/private/location/location_service.h
@@ -0,0 +1,6 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+extern const ServiceManifest location_service_manifest;
diff --git a/Modules/location-module/private/location/location_settings.h b/Modules/location-module/private/location/location_settings.h
new file mode 100644
index 000000000..8a37a6d37
--- /dev/null
+++ b/Modules/location-module/private/location/location_settings.h
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#define LOCATION_SERVICE_ID "tactility.location"
+
+struct LocationSettings {
+ bool enabled;
+ enum LocationPrecision precision;
+};
+
+/** Loads the settings, or defaults when there are none */
+void location_settings_load(struct LocationSettings* settings);
+
+error_t location_settings_save(const struct LocationSettings* settings);
diff --git a/Modules/location-module/source/gps_provider.cpp b/Modules/location-module/source/gps_provider.cpp
new file mode 100644
index 000000000..1d7b9981f
--- /dev/null
+++ b/Modules/location-module/source/gps_provider.cpp
@@ -0,0 +1,261 @@
+// SPDX-License-Identifier: Apache-2.0
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#define TAG "location_gps"
+
+namespace {
+
+constexpr uint32_t AWAIT_TIMEOUT_MS = 250;
+constexpr uint32_t DEVICE_POLL_INTERVAL_MS = 1000;
+constexpr uint32_t THREAD_STOP_TIMEOUT_MS = 3000;
+constexpr float METERS_PER_SECOND_PER_KNOT = 0.514444f;
+// Typical user equivalent range error of consumer receivers, which turns dilution of precision into meters
+constexpr float RANGE_ERROR_METERS = 5.0f;
+
+/** The sentences received for one GPS epoch */
+struct Epoch {
+ minmea_time time;
+ bool hasRmc;
+ bool hasGga;
+ bool reported;
+ minmea_sentence_rmc rmc;
+ minmea_sentence_gga gga;
+};
+
+struct GpsProvider {
+ LocationHost* host = nullptr;
+ Thread* thread = nullptr;
+ std::atomic running = false;
+ // Guards device and subscription. The device listener can outlive a provider run, so this state is static.
+ Mutex mutex {};
+ Device* device = nullptr;
+ GpsSubscription subscription {};
+ Epoch epoch {};
+
+ GpsProvider() { mutex_construct(&mutex); }
+ ~GpsProvider() { mutex_destruct(&mutex); }
+};
+
+GpsProvider provider;
+
+double toDegrees(const minmea_float& value) {
+ const double raw = static_cast(value.value) / value.scale;
+ const int degrees = static_cast(raw / 100);
+ return degrees + (raw - degrees * 100) / 60.0;
+}
+
+// Days from 1970-01-01 to a date in the proleptic Gregorian calendar
+int64_t daysSinceEpoch(int year, int month, int day) {
+ year -= month <= 2;
+ const int64_t era = (year >= 0 ? year : year - 399) / 400;
+ const int yearOfEra = static_cast(year - era * 400);
+ const int dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
+ const int dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear;
+ return era * 146097 + dayOfEra - 719468;
+}
+
+bool isSameTime(const minmea_time& a, const minmea_time& b) {
+ return a.hours == b.hours && a.minutes == b.minutes && a.seconds == b.seconds && a.microseconds == b.microseconds;
+}
+
+void report(Epoch& epoch) {
+ Location location;
+ if (gps_location_from_nmea(&epoch.rmc, epoch.hasGga ? &epoch.gga : nullptr, &location)) {
+ if (location_get_precision() == LOCATION_PRECISION_APPROXIMATE) {
+ location_make_approximate(&location);
+ }
+ location_host_report(provider.host, &location);
+ }
+ epoch.reported = true;
+}
+
+void onSentence(const GpsEvent& event) {
+ auto& epoch = provider.epoch;
+ const minmea_time& time = event.type == GPS_EVENT_MESSAGE_RMC ? event.data.rmc.time : event.data.gga.time;
+ if (!isSameTime(epoch.time, time)) {
+ // Receivers that don't send GGA only get reported once the next epoch starts
+ if (epoch.hasRmc && !epoch.reported) {
+ report(epoch);
+ }
+ epoch = {};
+ epoch.time = time;
+ }
+
+ if (event.type == GPS_EVENT_MESSAGE_RMC) {
+ epoch.rmc = event.data.rmc;
+ epoch.hasRmc = true;
+ } else {
+ epoch.gga = event.data.gga;
+ epoch.hasGga = true;
+ }
+
+ if (epoch.hasRmc && epoch.hasGga && !epoch.reported) {
+ report(epoch);
+ }
+}
+
+// Unsubscribes before the driver stops, as the subscription can't be removed afterwards
+void onDeviceEvent(Device* device, DeviceEvent event, void*) {
+ if (event != DEVICE_EVENT_STOPPING) {
+ return;
+ }
+ mutex_lock(&provider.mutex);
+ if (device == provider.device) {
+ gps_event_unsubscribe(device, &provider.subscription);
+ provider.device = nullptr;
+ }
+ mutex_unlock(&provider.mutex);
+}
+
+/** Must be called with the mutex held */
+bool subscribe() {
+ Device* device;
+ if (device_get_first_active_by_type(&GPS_TYPE, &device) != ERROR_NONE) {
+ return false;
+ }
+ const error_t error = gps_event_subscribe(device, &provider.subscription);
+ device_put(device);
+ if (error != ERROR_NONE) {
+ LOG_E(TAG, "Failed to subscribe to %s", device->name);
+ return false;
+ }
+ LOG_I(TAG, "Using %s", device->name);
+ provider.device = device;
+ provider.epoch = {};
+ return true;
+}
+
+/** Must be called with the mutex held and a device subscribed */
+void receive() {
+ auto& sub = provider.subscription;
+ if (sub.sequence != sub.consumed_sequence) {
+ sub.consumed_sequence = sub.sequence;
+ } else if (gps_event_await(provider.device, &sub, millis_to_ticks(AWAIT_TIMEOUT_MS)) != ERROR_NONE) {
+ return;
+ }
+
+ switch (sub.event.type) {
+ case GPS_EVENT_MESSAGE_RMC:
+ case GPS_EVENT_MESSAGE_GGA:
+ onSentence(sub.event);
+ break;
+ case GPS_EVENT_UNSUBSCRIBED:
+ provider.device = nullptr;
+ break;
+ }
+}
+
+int32_t threadMain(void*) {
+ while (provider.running) {
+ mutex_lock(&provider.mutex);
+ const bool subscribed = provider.device != nullptr || subscribe();
+ if (subscribed) {
+ receive();
+ }
+ mutex_unlock(&provider.mutex);
+
+ if (!subscribed) {
+ delay_millis(DEVICE_POLL_INTERVAL_MS);
+ }
+ }
+
+ mutex_lock(&provider.mutex);
+ if (provider.device != nullptr) {
+ gps_event_unsubscribe(provider.device, &provider.subscription);
+ provider.device = nullptr;
+ }
+ mutex_unlock(&provider.mutex);
+ return 0;
+}
+
+void* create(LocationHost* host) {
+ provider.host = host;
+ provider.running = true;
+ provider.thread = thread_alloc_full("location_gps", 4096, threadMain, nullptr, -1);
+ if (provider.thread == nullptr) {
+ return nullptr;
+ }
+ // Settings are loaded before providers start, so the thread doesn't access flash
+ thread_set_stack_external(provider.thread, true);
+ device_listener_add(onDeviceEvent, nullptr);
+ if (thread_start(provider.thread) != ERROR_NONE) {
+ device_listener_remove(onDeviceEvent, nullptr);
+ thread_free(provider.thread);
+ provider.thread = nullptr;
+ return nullptr;
+ }
+ return &provider;
+}
+
+void destroy(void*) {
+ provider.running = false;
+ if (thread_join(provider.thread, millis_to_ticks(THREAD_STOP_TIMEOUT_MS), millis_to_ticks(10)) != ERROR_NONE) {
+ LOG_E(TAG, "Thread did not stop in time");
+ return;
+ }
+ device_listener_remove(onDeviceEvent, nullptr);
+ thread_free(provider.thread);
+ provider.thread = nullptr;
+}
+
+} // namespace
+
+bool gps_location_from_nmea(const minmea_sentence_rmc* rmc, const minmea_sentence_gga* gga, Location* location) {
+ if (!rmc->valid || rmc->latitude.scale == 0 || rmc->longitude.scale == 0) {
+ return false;
+ }
+
+ *location = Location {
+ .latitude = toDegrees(rmc->latitude),
+ .longitude = toDegrees(rmc->longitude),
+ .horizontal_accuracy = NAN,
+ .altitude = NAN,
+ .ellipsoidal_altitude = NAN,
+ .vertical_accuracy = NAN,
+ .speed = minmea_tofloat(&rmc->speed) * METERS_PER_SECOND_PER_KNOT,
+ .speed_accuracy = NAN,
+ .course = minmea_tofloat(&rmc->course),
+ .course_accuracy = NAN,
+ .satellites = 0,
+ .time_ms = 0,
+ .uptime_ms = 0,
+ .provider = {},
+ };
+
+ tm dateTime;
+ if (minmea_getdatetime(&dateTime, &rmc->date, &rmc->time) == 0) {
+ const int64_t seconds = daysSinceEpoch(dateTime.tm_year + 1900, dateTime.tm_mon + 1, dateTime.tm_mday) * 86400 +
+ dateTime.tm_hour * 3600 + dateTime.tm_min * 60 + dateTime.tm_sec;
+ location->time_ms = seconds * 1000 + rmc->time.microseconds / 1000;
+ }
+
+ if (gga != nullptr && gga->fix_quality > 0) {
+ location->horizontal_accuracy = minmea_tofloat(&gga->hdop) * RANGE_ERROR_METERS;
+ location->altitude = minmea_tofloat(&gga->altitude);
+ location->ellipsoidal_altitude = location->altitude + minmea_tofloat(&gga->height);
+ location->satellites = static_cast(gga->satellites_tracked);
+ }
+
+ return true;
+}
+
+const LocationProviderApi gps_location_provider = {
+ .id = "gps",
+ .fallback = false,
+ .create = create,
+ .destroy = destroy,
+};
diff --git a/Modules/location-module/source/ip_provider.cpp b/Modules/location-module/source/ip_provider.cpp
new file mode 100644
index 000000000..53630d3bc
--- /dev/null
+++ b/Modules/location-module/source/ip_provider.cpp
@@ -0,0 +1,369 @@
+// SPDX-License-Identifier: Apache-2.0
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define TAG "location_ip"
+
+namespace {
+
+// The free ip-api endpoint only supports plain HTTP
+constexpr auto* API_HOST = "ip-api.com";
+constexpr auto* API_PATH = "/line/?fields=status,message,lat,lon";
+constexpr uint64_t REFRESH_INTERVAL_MS = 30 * 60 * 1000;
+constexpr uint64_t RETRY_INTERVAL_MS = 60 * 1000;
+// Bounds the request rate when the network connects repeatedly
+constexpr uint64_t MIN_INTERVAL_MS = 10 * 1000;
+// Wake up interval to check whether the fallback became needed
+constexpr uint32_t CHECK_INTERVAL_MS = 10 * 1000;
+constexpr int SOCKET_TIMEOUT_SECONDS = 5;
+constexpr size_t RESPONSE_MAX_LENGTH = 1024;
+// DNS resolution has no timeout of its own
+constexpr uint32_t THREAD_STOP_TIMEOUT_MS = 30 * 1000;
+// IP geolocation is accurate to roughly city level
+constexpr float ACCURACY_METERS = 25000.0f;
+
+struct IpProvider {
+ LocationHost* host = nullptr;
+ Thread* thread = nullptr;
+ std::atomic running = false;
+ TaskEventGroup group {};
+ uint32_t stopBit = 0;
+ SystemEventSubscription networkSubscription {};
+};
+
+IpProvider provider;
+
+uint64_t getUptimeMs() {
+ return get_micros_since_boot() / 1000;
+}
+
+// The FreeRTOS POSIX port's tick signal can interrupt blocking socket calls, so they retry on EINTR
+
+bool connectWithTimeout(int fd, const sockaddr* address, socklen_t addressLength) {
+ const int flags = fcntl(fd, F_GETFL, 0);
+ fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+ int result = connect(fd, address, addressLength);
+ if (result < 0 && errno == EINPROGRESS) {
+ fd_set writeSet;
+ do {
+ FD_ZERO(&writeSet);
+ FD_SET(fd, &writeSet);
+ timeval timeout { .tv_sec = SOCKET_TIMEOUT_SECONDS, .tv_usec = 0 };
+ result = select(fd + 1, nullptr, &writeSet, nullptr, &timeout);
+ } while (result < 0 && errno == EINTR);
+ int error = 0;
+ socklen_t errorLength = sizeof(error);
+ if (result == 1 && getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &errorLength) == 0 && error == 0) {
+ result = 0;
+ } else {
+ result = -1;
+ }
+ }
+ fcntl(fd, F_SETFL, flags);
+ return result == 0;
+}
+
+bool sendAll(int fd, const char* data, size_t length) {
+ size_t sent = 0;
+ while (sent < length) {
+ const ssize_t result = send(fd, data + sent, length - sent, 0);
+ if (result < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return false;
+ }
+ sent += static_cast(result);
+ }
+ return true;
+}
+
+/** Receive until the server closes the connection */
+bool receiveAll(int fd, char* buffer, size_t bufferSize) {
+ size_t received = 0;
+ while (received < bufferSize - 1) {
+ const ssize_t result = recv(fd, buffer + received, bufferSize - 1 - received, 0);
+ if (result < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return false;
+ }
+ if (result == 0) {
+ break;
+ }
+ received += static_cast(result);
+ }
+ buffer[received] = '\0';
+ return received > 0;
+}
+
+bool httpGet(const char* host, const char* path, char* response, size_t responseSize) {
+ addrinfo hints {};
+ hints.ai_family = AF_INET;
+ hints.ai_socktype = SOCK_STREAM;
+ addrinfo* addresses = nullptr;
+ if (getaddrinfo(host, "80", &hints, &addresses) != 0 || addresses == nullptr) {
+ return false;
+ }
+
+ const int fd = socket(addresses->ai_family, addresses->ai_socktype, addresses->ai_protocol);
+ if (fd < 0) {
+ freeaddrinfo(addresses);
+ return false;
+ }
+
+ timeval timeout { .tv_sec = SOCKET_TIMEOUT_SECONDS, .tv_usec = 0 };
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
+
+ char request[160];
+ const int requestLength = snprintf(request, sizeof(request), "GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n", path, host);
+
+ const bool success = connectWithTimeout(fd, addresses->ai_addr, addresses->ai_addrlen) &&
+ sendAll(fd, request, static_cast(requestLength)) &&
+ receiveAll(fd, response, responseSize);
+
+ close(fd);
+ freeaddrinfo(addresses);
+ return success;
+}
+
+/** @return the delay until the next lookup */
+uint64_t lookup() {
+ tt::std::string response(RESPONSE_MAX_LENGTH, '\0');
+ if (!httpGet(API_HOST, API_PATH, response.data(), response.size())) {
+ LOG_W(TAG, "Request failed");
+ return RETRY_INTERVAL_MS;
+ }
+
+ IpApiResponse parsed;
+ if (!ip_api_parse_response(response.c_str(), &parsed)) {
+ LOG_W(TAG, "Malformed response");
+ return RETRY_INTERVAL_MS;
+ }
+
+ uint64_t delay = RETRY_INTERVAL_MS;
+ if (parsed.status_code != 200) {
+ LOG_W(TAG, "HTTP status %d", parsed.status_code);
+ } else if (!parsed.success) {
+ LOG_W(TAG, "Lookup failed: %s", parsed.message);
+ } else {
+ const Location location {
+ .latitude = parsed.latitude,
+ .longitude = parsed.longitude,
+ .horizontal_accuracy = ACCURACY_METERS,
+ .altitude = NAN,
+ .ellipsoidal_altitude = NAN,
+ .vertical_accuracy = NAN,
+ .speed = NAN,
+ .speed_accuracy = NAN,
+ .course = NAN,
+ .course_accuracy = NAN,
+ .satellites = 0,
+ .time_ms = 0,
+ .uptime_ms = 0,
+ .provider = {},
+ };
+ location_host_report(provider.host, &location);
+ LOG_D(TAG, "Lookup succeeded");
+ delay = REFRESH_INTERVAL_MS;
+ }
+
+ if (parsed.rate_remaining == 0 && parsed.rate_reset_seconds > 0) {
+ delay = std::max(delay, static_cast(parsed.rate_reset_seconds) * 1000);
+ }
+ return delay;
+}
+
+int32_t threadMain(void*) {
+ uint64_t lastAttemptMs = 0;
+ uint64_t nextAttemptMs = 0;
+ bool attempted = false;
+
+ while (provider.running) {
+ const uint64_t now = getUptimeMs();
+ const uint64_t waitMs = std::min(CHECK_INTERVAL_MS, nextAttemptMs > now ? nextAttemptMs - now : 0);
+ uint32_t flags = 0;
+ task_event_group_wait_any(&provider.group, &flags, millis_to_ticks(static_cast(waitMs)));
+ if (!provider.running) {
+ break;
+ }
+
+ // The public IP address can change with the network
+ if (system_event_poll(&provider.networkSubscription) == ERROR_NONE && attempted) {
+ nextAttemptMs = lastAttemptMs + MIN_INTERVAL_MS;
+ }
+
+ const uint64_t attemptMs = getUptimeMs();
+ if (attemptMs < nextAttemptMs || !location_host_needs_fallback()) {
+ continue;
+ }
+ attempted = true;
+ lastAttemptMs = attemptMs;
+ nextAttemptMs = attemptMs + lookup();
+ }
+ return 0;
+}
+
+void* create(LocationHost* host) {
+ provider.host = host;
+ provider.running = true;
+ task_event_group_construct(&provider.group);
+ if (task_event_group_claim_bit(&provider.group, &provider.stopBit) != ERROR_NONE) {
+ task_event_group_destruct(&provider.group);
+ return nullptr;
+ }
+ provider.networkSubscription = {};
+ provider.networkSubscription.event.type = KERNEL_EVENT_NETWORK_CONNECTED;
+ if (system_event_subscribe(&provider.networkSubscription, &provider.group) != ERROR_NONE) {
+ task_event_group_destruct(&provider.group);
+ return nullptr;
+ }
+
+ provider.thread = thread_alloc_full("location_ip", 4096, threadMain, nullptr, -1);
+ if (provider.thread != nullptr) {
+ // Settings are loaded before providers start, so the thread doesn't access flash
+ thread_set_stack_external(provider.thread, true);
+ }
+ if (provider.thread == nullptr || thread_start(provider.thread) != ERROR_NONE) {
+ if (provider.thread != nullptr) {
+ thread_free(provider.thread);
+ provider.thread = nullptr;
+ }
+ system_event_unsubscribe(&provider.networkSubscription);
+ task_event_group_destruct(&provider.group);
+ return nullptr;
+ }
+ return &provider;
+}
+
+void destroy(void*) {
+ provider.running = false;
+ task_event_group_signal(&provider.group, provider.stopBit);
+ if (thread_join(provider.thread, millis_to_ticks(THREAD_STOP_TIMEOUT_MS), millis_to_ticks(10)) != ERROR_NONE) {
+ LOG_E(TAG, "Thread did not stop in time");
+ return;
+ }
+ thread_free(provider.thread);
+ provider.thread = nullptr;
+ system_event_unsubscribe(&provider.networkSubscription);
+ task_event_group_destruct(&provider.group);
+}
+
+std::string_view nextLine(std::string_view& text) {
+ const size_t end = text.find('\n');
+ std::string_view line = text.substr(0, end);
+ text = end == std::string_view::npos ? std::string_view {} : text.substr(end + 1);
+ if (!line.empty() && line.back() == '\r') {
+ line.remove_suffix(1);
+ }
+ return line;
+}
+
+int parseHeaderNumber(std::string_view line, const char* name) {
+ const size_t nameLength = strlen(name);
+ if (line.size() <= nameLength || line[nameLength] != ':' || strncasecmp(line.data(), name, nameLength) != 0) {
+ return -1;
+ }
+ return atoi(tt::std::string(line.substr(nameLength + 1)).c_str());
+}
+
+} // namespace
+
+bool ip_api_parse_response(const char* response, IpApiResponse* out) {
+ *out = IpApiResponse {
+ .status_code = 0,
+ .rate_remaining = -1,
+ .rate_reset_seconds = -1,
+ .success = false,
+ .message = {},
+ .latitude = NAN,
+ .longitude = NAN,
+ };
+
+ std::string_view text = response;
+ const std::string_view statusLine = nextLine(text);
+ if (!statusLine.starts_with("HTTP/1.")) {
+ return false;
+ }
+ const size_t space = statusLine.find(' ');
+ if (space == std::string_view::npos) {
+ return false;
+ }
+ out->status_code = atoi(tt::std::string(statusLine.substr(space + 1)).c_str());
+
+ while (true) {
+ if (text.empty()) {
+ return false;
+ }
+ const std::string_view header = nextLine(text);
+ if (header.empty()) {
+ break;
+ }
+ const int remaining = parseHeaderNumber(header, "X-Rl");
+ if (remaining >= 0) {
+ out->rate_remaining = remaining;
+ }
+ const int resetSeconds = parseHeaderNumber(header, "X-Ttl");
+ if (resetSeconds >= 0) {
+ out->rate_reset_seconds = resetSeconds;
+ }
+ }
+
+ if (out->status_code != 200) {
+ return true;
+ }
+
+ // Fields are returned one per line, in the order of the API documentation
+ const std::string_view status = nextLine(text);
+ if (status == "success") {
+ const tt::std::string latitude(nextLine(text));
+ const tt::std::string longitude(nextLine(text));
+ char* latitudeEnd;
+ char* longitudeEnd;
+ out->latitude = strtod(latitude.c_str(), &latitudeEnd);
+ out->longitude = strtod(longitude.c_str(), &longitudeEnd);
+ if (latitude.empty() || *latitudeEnd != '\0' || longitude.empty() || *longitudeEnd != '\0') {
+ return false;
+ }
+ out->success = true;
+ } else if (status == "fail") {
+ const std::string_view message = nextLine(text).substr(0, IP_API_MESSAGE_MAX_LENGTH);
+ memcpy(out->message, message.data(), message.size());
+ out->message[message.size()] = '\0';
+ } else {
+ return false;
+ }
+ return true;
+}
+
+const LocationProviderApi ip_location_provider = {
+ .id = "ip",
+ .fallback = true,
+ .create = create,
+ .destroy = destroy,
+};
diff --git a/Modules/location-module/source/location_service.cpp b/Modules/location-module/source/location_service.cpp
new file mode 100644
index 000000000..7e5332409
--- /dev/null
+++ b/Modules/location-module/source/location_service.cpp
@@ -0,0 +1,385 @@
+// SPDX-License-Identifier: Apache-2.0
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#define TAG "location"
+
+struct LocationHost {
+ // Guarded by lifecycleMutex
+ const LocationProviderApi* api;
+ void* provider;
+
+ // Guarded by dataMutex
+ bool fallback;
+ bool hasLocation;
+ Location location;
+};
+
+namespace {
+
+/** A fix of a provider that isn't a fallback is preferred over fallback fixes for this long */
+constexpr uint64_t FRESH_MS = 120000;
+// About 2 km north to south
+constexpr double APPROXIMATE_GRID_DEGREES = 0.02;
+
+struct GlobalMutex {
+ Mutex handle {};
+ GlobalMutex() { mutex_construct(&handle); }
+ ~GlobalMutex() { mutex_destruct(&handle); }
+};
+
+// Serializes provider registration, service start/stop and (un)subscribing, which create and destroy providers
+GlobalMutex lifecycleMutex;
+LocationHost hosts[LOCATION_PROVIDER_MAX_COUNT] {};
+bool running = false;
+// Providers only exist while the service runs and has subscribers
+bool providersActive = false;
+
+// Not held while calling provider functions, so providers can report while being destroyed
+GlobalMutex dataMutex;
+LocationSubscription* subscriptions = nullptr;
+
+GlobalMutex settingsMutex;
+LocationSettings settings {};
+bool settingsLoaded = false;
+
+/** Must be called with settingsMutex held */
+void ensureSettingsLoaded() {
+ if (!settingsLoaded) {
+ location_settings_load(&settings);
+ settingsLoaded = true;
+ }
+}
+
+uint64_t getUptimeMs() {
+ return get_micros_since_boot() / 1000;
+}
+
+/** Must be called with dataMutex held */
+int getRank(const LocationHost& host, uint64_t now) {
+ if (host.fallback) {
+ return 1;
+ }
+ return now - host.location.uptime_ms < FRESH_MS ? 2 : 0;
+}
+
+/** Must be called with dataMutex held */
+const LocationHost* findBest() {
+ const uint64_t now = getUptimeMs();
+ const LocationHost* best = nullptr;
+ int bestRank = -1;
+ for (const auto& host : hosts) {
+ if (!host.hasLocation) {
+ continue;
+ }
+ const int rank = getRank(host, now);
+ if (rank > bestRank || (rank == bestRank && host.location.uptime_ms > best->location.uptime_ms)) {
+ best = &host;
+ bestRank = rank;
+ }
+ }
+ return best;
+}
+
+void clearLocation(LocationHost* host) {
+ mutex_lock(&dataMutex.handle);
+ host->hasLocation = false;
+ mutex_unlock(&dataMutex.handle);
+}
+
+void startProvider(LocationHost* host) {
+ host->provider = host->api->create(host);
+ if (host->provider == nullptr) {
+ LOG_E(TAG, "Failed to start provider %s", host->api->id);
+ }
+}
+
+void stopProvider(LocationHost* host) {
+ if (host->provider != nullptr) {
+ host->api->destroy(host->provider);
+ host->provider = nullptr;
+ }
+}
+
+/** Must be called with lifecycleMutex held */
+void updateProviders() {
+ mutex_lock(&dataMutex.handle);
+ const bool hasSubscribers = subscriptions != nullptr;
+ mutex_unlock(&dataMutex.handle);
+
+ const bool shouldBeActive = running && hasSubscribers;
+ if (shouldBeActive == providersActive) {
+ return;
+ }
+ providersActive = shouldBeActive;
+ for (auto& host : hosts) {
+ if (host.api == nullptr) {
+ continue;
+ }
+ if (shouldBeActive) {
+ startProvider(&host);
+ } else {
+ // Fixes are kept as the last known location
+ stopProvider(&host);
+ }
+ }
+}
+
+void* createService(const ServiceManifest*) {
+ return nullptr;
+}
+
+void destroyService(const ServiceManifest*, void*) {}
+
+error_t onStart(ServiceInstance*, void*) {
+ mutex_lock(&lifecycleMutex.handle);
+ running = true;
+ updateProviders();
+ mutex_unlock(&lifecycleMutex.handle);
+ return ERROR_NONE;
+}
+
+void onStop(ServiceInstance*, void*) {
+ mutex_lock(&lifecycleMutex.handle);
+ running = false;
+ updateProviders();
+ for (auto& host : hosts) {
+ clearLocation(&host);
+ }
+ mutex_unlock(&lifecycleMutex.handle);
+}
+
+} // namespace
+
+extern "C" {
+
+error_t location_set_enabled(bool enabled) {
+ mutex_lock(&settingsMutex.handle);
+ ensureSettingsLoaded();
+ settings.enabled = enabled;
+ const LocationSettings copy = settings;
+ mutex_unlock(&settingsMutex.handle);
+
+ const error_t saved = location_settings_save(©);
+ error_t result;
+ if (enabled) {
+ result = service_manager_start(LOCATION_SERVICE_ID);
+ if (result == ERROR_INVALID_STATE) {
+ result = ERROR_NONE;
+ }
+ } else {
+ result = service_manager_stop(LOCATION_SERVICE_ID);
+ if (result == ERROR_NOT_FOUND) {
+ result = ERROR_NONE;
+ }
+ }
+ return result != ERROR_NONE ? result : saved;
+}
+
+bool location_is_enabled(void) {
+ mutex_lock(&settingsMutex.handle);
+ ensureSettingsLoaded();
+ const bool enabled = settings.enabled;
+ mutex_unlock(&settingsMutex.handle);
+ return enabled;
+}
+
+error_t location_set_precision(LocationPrecision precision) {
+ mutex_lock(&settingsMutex.handle);
+ ensureSettingsLoaded();
+ settings.precision = precision;
+ const LocationSettings copy = settings;
+ mutex_unlock(&settingsMutex.handle);
+
+ if (precision == LOCATION_PRECISION_APPROXIMATE) {
+ // Fixes reported with precise precision are dropped until their providers report again
+ mutex_lock(&dataMutex.handle);
+ for (auto& host : hosts) {
+ if (host.hasLocation && !(host.location.horizontal_accuracy >= LOCATION_APPROXIMATE_ACCURACY_METERS)) {
+ host.hasLocation = false;
+ }
+ }
+ mutex_unlock(&dataMutex.handle);
+ }
+ return location_settings_save(©);
+}
+
+LocationPrecision location_get_precision(void) {
+ mutex_lock(&settingsMutex.handle);
+ ensureSettingsLoaded();
+ const LocationPrecision precision = settings.precision;
+ mutex_unlock(&settingsMutex.handle);
+ return precision;
+}
+
+void location_make_approximate(Location* location) {
+ location->latitude = std::round(location->latitude / APPROXIMATE_GRID_DEGREES) * APPROXIMATE_GRID_DEGREES;
+ location->longitude = std::round(location->longitude / APPROXIMATE_GRID_DEGREES) * APPROXIMATE_GRID_DEGREES;
+ if (!(location->horizontal_accuracy >= LOCATION_APPROXIMATE_ACCURACY_METERS)) {
+ location->horizontal_accuracy = LOCATION_APPROXIMATE_ACCURACY_METERS;
+ }
+ location->altitude = NAN;
+ location->ellipsoidal_altitude = NAN;
+ location->vertical_accuracy = NAN;
+ location->speed = NAN;
+ location->speed_accuracy = NAN;
+ location->course = NAN;
+ location->course_accuracy = NAN;
+ location->satellites = 0;
+}
+
+error_t location_provider_register(const LocationProviderApi* api) {
+ if (strlen(api->id) > LOCATION_PROVIDER_ID_MAX_LENGTH) {
+ return ERROR_INVALID_ARGUMENT;
+ }
+
+ mutex_lock(&lifecycleMutex.handle);
+ LocationHost* freeHost = nullptr;
+ for (auto& host : hosts) {
+ if (host.api == nullptr) {
+ if (freeHost == nullptr) {
+ freeHost = &host;
+ }
+ } else if (strcmp(host.api->id, api->id) == 0) {
+ mutex_unlock(&lifecycleMutex.handle);
+ return ERROR_ALREADY_EXISTS;
+ }
+ }
+ if (freeHost == nullptr) {
+ mutex_unlock(&lifecycleMutex.handle);
+ return ERROR_RESOURCE;
+ }
+ freeHost->api = api;
+ mutex_lock(&dataMutex.handle);
+ freeHost->fallback = api->fallback;
+ mutex_unlock(&dataMutex.handle);
+ if (providersActive) {
+ startProvider(freeHost);
+ }
+ mutex_unlock(&lifecycleMutex.handle);
+ return ERROR_NONE;
+}
+
+error_t location_provider_unregister(const LocationProviderApi* api) {
+ error_t result = ERROR_NOT_FOUND;
+ mutex_lock(&lifecycleMutex.handle);
+ for (auto& host : hosts) {
+ if (host.api == api) {
+ stopProvider(&host);
+ clearLocation(&host);
+ host.api = nullptr;
+ result = ERROR_NONE;
+ break;
+ }
+ }
+ mutex_unlock(&lifecycleMutex.handle);
+ return result;
+}
+
+void location_host_report(LocationHost* host, const Location* location) {
+ mutex_lock(&dataMutex.handle);
+ host->location = *location;
+ strcpy(host->location.provider, host->api->id);
+ host->location.uptime_ms = getUptimeMs();
+ host->hasLocation = true;
+ if (findBest() == host) {
+ for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) {
+ task_event_group_signal(sub->internal.event_group, sub->bit);
+ }
+ }
+ mutex_unlock(&dataMutex.handle);
+}
+
+bool location_host_needs_fallback(void) {
+ mutex_lock(&dataMutex.handle);
+ const uint64_t now = getUptimeMs();
+ bool needed = true;
+ for (const auto& host : hosts) {
+ if (host.hasLocation && getRank(host, now) == 2) {
+ needed = false;
+ break;
+ }
+ }
+ mutex_unlock(&dataMutex.handle);
+ return needed;
+}
+
+error_t location_get(Location* location) {
+ mutex_lock(&dataMutex.handle);
+ const auto* best = findBest();
+ if (best != nullptr) {
+ *location = best->location;
+ }
+ mutex_unlock(&dataMutex.handle);
+ return best != nullptr ? ERROR_NONE : ERROR_NOT_FOUND;
+}
+
+error_t location_subscribe(LocationSubscription* sub, TaskEventGroup* event_group) {
+ uint32_t bit;
+ const error_t claimed = task_event_group_claim_bit(event_group, &bit);
+ if (claimed != ERROR_NONE) {
+ return claimed;
+ }
+
+ mutex_lock(&lifecycleMutex.handle);
+ mutex_lock(&dataMutex.handle);
+ for (auto* existing = subscriptions; existing != nullptr; existing = existing->internal.next) {
+ if (existing == sub) {
+ mutex_unlock(&dataMutex.handle);
+ mutex_unlock(&lifecycleMutex.handle);
+ task_event_group_release_bit(event_group, bit);
+ return ERROR_INVALID_STATE;
+ }
+ }
+ sub->bit = bit;
+ sub->internal.event_group = event_group;
+ sub->internal.next = subscriptions;
+ subscriptions = sub;
+ mutex_unlock(&dataMutex.handle);
+ updateProviders();
+ mutex_unlock(&lifecycleMutex.handle);
+ return ERROR_NONE;
+}
+
+error_t location_unsubscribe(LocationSubscription* sub) {
+ error_t result = ERROR_NOT_FOUND;
+ mutex_lock(&lifecycleMutex.handle);
+ mutex_lock(&dataMutex.handle);
+ for (auto** link = &subscriptions; *link != nullptr; link = &(*link)->internal.next) {
+ if (*link == sub) {
+ *link = sub->internal.next;
+ result = ERROR_NONE;
+ break;
+ }
+ }
+ mutex_unlock(&dataMutex.handle);
+ updateProviders();
+ mutex_unlock(&lifecycleMutex.handle);
+ if (result == ERROR_NONE) {
+ task_event_group_release_bit(sub->internal.event_group, sub->bit);
+ }
+ return result;
+}
+
+}
+
+extern const ServiceManifest location_service_manifest = {
+ .id = LOCATION_SERVICE_ID,
+ .create_service = createService,
+ .destroy_service = destroyService,
+ .on_start = onStart,
+ .on_stop = onStop,
+};
diff --git a/Modules/location-module/source/location_settings.cpp b/Modules/location-module/source/location_settings.cpp
new file mode 100644
index 000000000..9d0eb7814
--- /dev/null
+++ b/Modules/location-module/source/location_settings.cpp
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: Apache-2.0
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+
+constexpr auto* TAG = "location_settings";
+constexpr auto* SETTINGS_FILE_NAME = "settings.properties";
+constexpr auto* KEY_ENABLED = "enabled";
+constexpr auto* KEY_PRECISION = "precision";
+constexpr auto* PRECISION_APPROXIMATE = "approximate";
+constexpr auto* PRECISION_PRECISE = "precise";
+
+void location_settings_load(LocationSettings* settings) {
+ *settings = LocationSettings {
+ .enabled = false,
+ .precision = LOCATION_PRECISION_PRECISE,
+ };
+
+ char path[FILE_MAX_PATH_STRING_LENGTH];
+ if (service_paths_get_user_data_path(LOCATION_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) {
+ return;
+ }
+
+ PropertiesFile* file = properties_file_open(path);
+ if (file == nullptr) {
+ return;
+ }
+
+ char value[32];
+ if (properties_file_get(file, KEY_ENABLED, value, sizeof(value)) == ERROR_NONE) {
+ settings->enabled = strcmp(value, "true") == 0;
+ }
+ if (properties_file_get(file, KEY_PRECISION, value, sizeof(value)) == ERROR_NONE) {
+ settings->precision = strcmp(value, PRECISION_APPROXIMATE) == 0 ? LOCATION_PRECISION_APPROXIMATE : LOCATION_PRECISION_PRECISE;
+ }
+ properties_file_close(file);
+}
+
+error_t location_settings_save(const LocationSettings* settings) {
+ char directory[FILE_MAX_PATH_STRING_LENGTH];
+ char path[FILE_MAX_PATH_STRING_LENGTH];
+ if (service_paths_get_user_data_directory(LOCATION_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE ||
+ service_paths_get_user_data_path(LOCATION_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) {
+ return ERROR_BUFFER_OVERFLOW;
+ }
+ const error_t made = directory_make(directory, true);
+ if (made != ERROR_NONE && made != ERROR_ALREADY_EXISTS) {
+ LOG_E(TAG, "Failed to create %s", directory);
+ return ERROR_RESOURCE;
+ }
+
+ PropertiesFile* file = properties_file_open(path);
+ if (file == nullptr) {
+ return ERROR_RESOURCE;
+ }
+ properties_file_set(file, KEY_ENABLED, settings->enabled ? "true" : "false");
+ properties_file_set(file, KEY_PRECISION, settings->precision == LOCATION_PRECISION_APPROXIMATE ? PRECISION_APPROXIMATE : PRECISION_PRECISE);
+ return properties_file_close(file);
+}
diff --git a/Modules/location-module/source/module.cpp b/Modules/location-module/source/module.cpp
new file mode 100644
index 000000000..5a03c5d78
--- /dev/null
+++ b/Modules/location-module/source/module.cpp
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: Apache-2.0
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+
+extern "C" {
+
+static error_t start() {
+ error_t error = location_provider_register(&gps_location_provider);
+ if (error != ERROR_NONE) {
+ return error;
+ }
+ error = location_provider_register(&ip_location_provider);
+ if (error != ERROR_NONE) {
+ location_provider_unregister(&gps_location_provider);
+ return error;
+ }
+ return service_manager_add(&location_service_manifest, location_is_enabled());
+}
+
+static error_t stop() {
+ const error_t error = service_manager_stop(location_service_manifest.id);
+ if (error != ERROR_NONE && error != ERROR_NOT_FOUND) {
+ return error;
+ }
+ location_provider_unregister(&ip_location_provider);
+ location_provider_unregister(&gps_location_provider);
+ return service_manager_remove(location_service_manifest.id);
+}
+
+static const ModuleSymbol location_module_symbols[] = {
+ DEFINE_MODULE_SYMBOL(location_set_enabled),
+ DEFINE_MODULE_SYMBOL(location_is_enabled),
+ DEFINE_MODULE_SYMBOL(location_set_precision),
+ DEFINE_MODULE_SYMBOL(location_get_precision),
+ DEFINE_MODULE_SYMBOL(location_get),
+ DEFINE_MODULE_SYMBOL(location_subscribe),
+ DEFINE_MODULE_SYMBOL(location_unsubscribe),
+ MODULE_SYMBOL_TERMINATOR
+};
+
+Module location_module = {
+ .name = "location",
+ .start = start,
+ .stop = stop,
+ .drivers = nullptr,
+ .symbols = location_module_symbols,
+ .internal = nullptr,
+};
+
+}
diff --git a/Modules/location-module/tests/CMakeLists.txt b/Modules/location-module/tests/CMakeLists.txt
new file mode 100644
index 000000000..3973f5a84
--- /dev/null
+++ b/Modules/location-module/tests/CMakeLists.txt
@@ -0,0 +1,20 @@
+project(LocationModuleTests)
+
+enable_language(C CXX ASM)
+
+file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
+add_executable(LocationModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
+
+target_include_directories(LocationModuleTests PRIVATE
+ ${DOCTESTINC}
+ ${PROJECT_SOURCE_DIR}/../private
+)
+
+add_test(NAME LocationModuleTests COMMAND LocationModuleTests)
+
+target_link_libraries(LocationModuleTests PUBLIC
+ TactilityKernel
+ platform-posix
+ service-module
+ location-module
+)
diff --git a/Modules/location-module/tests/source/gps_provider_test.cpp b/Modules/location-module/tests/source/gps_provider_test.cpp
new file mode 100644
index 000000000..ac3649756
--- /dev/null
+++ b/Modules/location-module/tests/source/gps_provider_test.cpp
@@ -0,0 +1,64 @@
+#include "doctest.h"
+
+#include
+
+#include
+
+namespace {
+
+constexpr auto* RMC = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
+constexpr auto* GGA = "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47";
+
+}
+
+TEST_CASE("gps_location_from_nmea converts RMC and GGA") {
+ minmea_sentence_rmc rmc;
+ minmea_sentence_gga gga;
+ REQUIRE(minmea_parse_rmc(&rmc, RMC));
+ REQUIRE(minmea_parse_gga(&gga, GGA));
+
+ Location location;
+ REQUIRE(gps_location_from_nmea(&rmc, &gga, &location));
+ CHECK(location.latitude == doctest::Approx(48.1173));
+ CHECK(location.longitude == doctest::Approx(11.516667));
+ CHECK(location.speed == doctest::Approx(11.5235).epsilon(0.001));
+ CHECK(location.course == doctest::Approx(84.4));
+ CHECK(location.horizontal_accuracy == doctest::Approx(4.5));
+ CHECK(location.altitude == doctest::Approx(545.4));
+ CHECK(location.ellipsoidal_altitude == doctest::Approx(592.3));
+ CHECK(std::isnan(location.vertical_accuracy));
+ CHECK(location.satellites == 8);
+ CHECK(location.time_ms == 764426119000LL);
+}
+
+TEST_CASE("gps_location_from_nmea without GGA has no altitude or accuracy") {
+ minmea_sentence_rmc rmc;
+ REQUIRE(minmea_parse_rmc(&rmc, RMC));
+
+ Location location;
+ REQUIRE(gps_location_from_nmea(&rmc, nullptr, &location));
+ CHECK(location.latitude == doctest::Approx(48.1173));
+ CHECK(std::isnan(location.altitude));
+ CHECK(std::isnan(location.horizontal_accuracy));
+ CHECK(location.satellites == 0);
+}
+
+TEST_CASE("gps_location_from_nmea handles the southern and western hemispheres") {
+ minmea_sentence_rmc rmc;
+ REQUIRE(minmea_parse_rmc(&rmc, "$GPRMC,123519,A,3352.128,S,15112.558,W,000.0,,230394,,*00"));
+
+ Location location;
+ REQUIRE(gps_location_from_nmea(&rmc, nullptr, &location));
+ CHECK(location.latitude == doctest::Approx(-33.8688));
+ CHECK(location.longitude == doctest::Approx(-151.209300));
+ CHECK(location.speed == doctest::Approx(0.0));
+ CHECK(std::isnan(location.course));
+}
+
+TEST_CASE("gps_location_from_nmea rejects a fix that is not valid") {
+ minmea_sentence_rmc rmc;
+ REQUIRE(minmea_parse_rmc(&rmc, "$GPRMC,123519,V,,,,,,,230394,,*00"));
+
+ Location location;
+ CHECK_FALSE(gps_location_from_nmea(&rmc, nullptr, &location));
+}
diff --git a/Modules/location-module/tests/source/ip_provider_test.cpp b/Modules/location-module/tests/source/ip_provider_test.cpp
new file mode 100644
index 000000000..93ce612a1
--- /dev/null
+++ b/Modules/location-module/tests/source/ip_provider_test.cpp
@@ -0,0 +1,53 @@
+#include "doctest.h"
+
+#include
+
+#include
+
+TEST_CASE("ip_api_parse_response parses a successful lookup") {
+ const char* response =
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/plain; charset=utf-8\r\n"
+ "X-Ttl: 60\r\n"
+ "X-Rl: 44\r\n"
+ "\r\n"
+ "success\n"
+ "39.03\n"
+ "-77.5\n";
+ IpApiResponse parsed;
+ REQUIRE(ip_api_parse_response(response, &parsed));
+ CHECK(parsed.status_code == 200);
+ CHECK(parsed.success);
+ CHECK(parsed.latitude == doctest::Approx(39.03));
+ CHECK(parsed.longitude == doctest::Approx(-77.5));
+ CHECK(parsed.rate_remaining == 44);
+ CHECK(parsed.rate_reset_seconds == 60);
+}
+
+TEST_CASE("ip_api_parse_response parses a failed lookup") {
+ const char* response = "HTTP/1.1 200 OK\r\nx-rl: 0\r\nx-ttl: 12\r\n\r\nfail\nprivate range\n";
+ IpApiResponse parsed;
+ REQUIRE(ip_api_parse_response(response, &parsed));
+ CHECK_FALSE(parsed.success);
+ CHECK(strcmp(parsed.message, "private range") == 0);
+ CHECK(parsed.rate_remaining == 0);
+ CHECK(parsed.rate_reset_seconds == 12);
+}
+
+TEST_CASE("ip_api_parse_response handles a throttled request") {
+ const char* response = "HTTP/1.1 429 Too Many Requests\r\nX-Rl: 0\r\nX-Ttl: 30\r\n\r\n";
+ IpApiResponse parsed;
+ REQUIRE(ip_api_parse_response(response, &parsed));
+ CHECK(parsed.status_code == 429);
+ CHECK_FALSE(parsed.success);
+ CHECK(parsed.rate_reset_seconds == 30);
+}
+
+TEST_CASE("ip_api_parse_response rejects malformed responses") {
+ IpApiResponse parsed;
+ CHECK_FALSE(ip_api_parse_response("", &parsed));
+ CHECK_FALSE(ip_api_parse_response("garbage", &parsed));
+ CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\nX-Rl: 1\r\n", &parsed));
+ CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\nabc\n1.0\n", &parsed));
+ CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nunknown\n", &parsed));
+}
diff --git a/Modules/location-module/tests/source/main.cpp b/Modules/location-module/tests/source/main.cpp
new file mode 100644
index 000000000..7adeb09aa
--- /dev/null
+++ b/Modules/location-module/tests/source/main.cpp
@@ -0,0 +1,48 @@
+#define DOCTEST_CONFIG_IMPLEMENT
+#include "doctest.h"
+#include
+
+#include
+#include
+#include
+
+
+typedef struct {
+ int argc;
+ char** argv;
+ int result;
+} TestTaskData;
+
+extern "C" struct Module platform_posix_module;
+
+void test_task(void* parameter) {
+ auto* data = (TestTaskData*)parameter;
+
+ doctest::Context context;
+ context.applyCommandLine(data->argc, data->argv);
+ context.setOption("no-breaks", true);
+
+ Module* dts_modules[] = { &platform_posix_module, nullptr };
+ DtsDevice dts_devices[] = { DTS_DEVICE_TERMINATOR };
+ check(kernel_init(dts_modules, dts_devices) == ERROR_NONE);
+
+ data->result = context.run();
+
+ vTaskEndScheduler();
+ vTaskDelete(nullptr);
+}
+
+int main(int argc, char** argv) {
+ TestTaskData data = {
+ .argc = argc,
+ .argv = argv,
+ .result = 0
+ };
+
+ if (xTaskCreate(test_task, "test_task", 16384, &data, 1, nullptr) != pdPASS) {
+ return 1;
+ }
+
+ vTaskStartScheduler();
+ return data.result;
+}
diff --git a/Modules/location-module/tests/source/service_test.cpp b/Modules/location-module/tests/source/service_test.cpp
new file mode 100644
index 000000000..275a64e53
--- /dev/null
+++ b/Modules/location-module/tests/source/service_test.cpp
@@ -0,0 +1,221 @@
+#include "doctest.h"
+
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+
+namespace {
+
+LocationHost* fakeHost = nullptr;
+int createCount = 0;
+int destroyCount = 0;
+
+void* fakeCreate(LocationHost* host) {
+ fakeHost = host;
+ createCount++;
+ return host;
+}
+
+void fakeDestroy(void*) {
+ fakeHost = nullptr;
+ destroyCount++;
+}
+
+const LocationProviderApi fakeProvider = {
+ .id = "fake",
+ .fallback = false,
+ .create = fakeCreate,
+ .destroy = fakeDestroy,
+};
+
+Location makeLocation(double latitude, double longitude) {
+ return Location {
+ .latitude = latitude,
+ .longitude = longitude,
+ .horizontal_accuracy = 3.0f,
+ .altitude = NAN,
+ .ellipsoidal_altitude = NAN,
+ .vertical_accuracy = NAN,
+ .speed = NAN,
+ .speed_accuracy = NAN,
+ .course = NAN,
+ .course_accuracy = NAN,
+ .satellites = 0,
+ .time_ms = 1000,
+ .uptime_ms = 0,
+ .provider = {},
+ };
+}
+
+}
+
+TEST_CASE("location service reports fixes of its providers") {
+ createCount = 0;
+ destroyCount = 0;
+ REQUIRE(location_provider_register(&fakeProvider) == ERROR_NONE);
+ CHECK(location_provider_register(&fakeProvider) == ERROR_ALREADY_EXISTS);
+
+ // Providers don't run without subscriptions
+ REQUIRE(service_manager_add(&location_service_manifest, true) == ERROR_NONE);
+ CHECK(createCount == 0);
+
+ Location location;
+ CHECK(location_get(&location) == ERROR_NOT_FOUND);
+
+ TaskEventGroup group;
+ task_event_group_construct(&group);
+ LocationSubscription sub {};
+ REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE);
+ CHECK(location_subscribe(&sub, &group) == ERROR_INVALID_STATE);
+ CHECK(createCount == 1);
+ REQUIRE(fakeHost != nullptr);
+
+ const Location reported = makeLocation(51.05, 3.72);
+ location_host_report(fakeHost, &reported);
+
+ uint32_t flags = 0;
+ CHECK(task_event_group_wait(&group, sub.bit, false, &flags, 0) == ERROR_NONE);
+ CHECK(flags == sub.bit);
+
+ REQUIRE(location_get(&location) == ERROR_NONE);
+ CHECK(location.latitude == doctest::Approx(51.05));
+ CHECK(location.longitude == doctest::Approx(3.72));
+ CHECK(location.horizontal_accuracy == doctest::Approx(3.0));
+ CHECK(location.time_ms == 1000);
+ CHECK(strcmp(location.provider, "fake") == 0);
+
+ // The last known fix remains available after the last subscription is removed
+ CHECK(location_unsubscribe(&sub) == ERROR_NONE);
+ CHECK(location_unsubscribe(&sub) == ERROR_NOT_FOUND);
+ CHECK(destroyCount == 1);
+ CHECK(location_get(&location) == ERROR_NONE);
+
+ REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE);
+ CHECK(createCount == 2);
+
+ REQUIRE(service_manager_stop(location_service_manifest.id) == ERROR_NONE);
+ CHECK(destroyCount == 2);
+ CHECK(location_get(&location) == ERROR_NOT_FOUND);
+
+ REQUIRE(service_manager_start(location_service_manifest.id) == ERROR_NONE);
+ CHECK(createCount == 3);
+ CHECK(location_provider_unregister(&fakeProvider) == ERROR_NONE);
+ CHECK(destroyCount == 3);
+ CHECK(location_provider_unregister(&fakeProvider) == ERROR_NOT_FOUND);
+
+ CHECK(location_unsubscribe(&sub) == ERROR_NONE);
+ task_event_group_destruct(&group);
+ REQUIRE(service_manager_stop(location_service_manifest.id) == ERROR_NONE);
+ CHECK(destroyCount == 3);
+ REQUIRE(service_manager_remove(location_service_manifest.id) == ERROR_NONE);
+}
+
+TEST_CASE("location provider registration is limited") {
+ LocationProviderApi providers[LOCATION_PROVIDER_MAX_COUNT + 1];
+ char ids[LOCATION_PROVIDER_MAX_COUNT + 1][8];
+ for (size_t i = 0; i <= LOCATION_PROVIDER_MAX_COUNT; i++) {
+ snprintf(ids[i], sizeof(ids[i]), "p%zu", i);
+ providers[i] = { .id = ids[i], .fallback = false, .create = fakeCreate, .destroy = fakeDestroy };
+ }
+ for (size_t i = 0; i < LOCATION_PROVIDER_MAX_COUNT; i++) {
+ REQUIRE(location_provider_register(&providers[i]) == ERROR_NONE);
+ }
+ CHECK(location_provider_register(&providers[LOCATION_PROVIDER_MAX_COUNT]) == ERROR_RESOURCE);
+ for (size_t i = 0; i < LOCATION_PROVIDER_MAX_COUNT; i++) {
+ CHECK(location_provider_unregister(&providers[i]) == ERROR_NONE);
+ }
+}
+
+namespace {
+
+LocationHost* fallbackHost = nullptr;
+
+void* fallbackCreate(LocationHost* host) {
+ fallbackHost = host;
+ return host;
+}
+
+void fallbackDestroy(void*) {
+ fallbackHost = nullptr;
+}
+
+const LocationProviderApi fakeFallbackProvider = {
+ .id = "fakefallback",
+ .fallback = true,
+ .create = fallbackCreate,
+ .destroy = fallbackDestroy,
+};
+
+}
+
+TEST_CASE("location service prefers fixes of providers that aren't a fallback") {
+ REQUIRE(location_provider_register(&fakeProvider) == ERROR_NONE);
+ REQUIRE(location_provider_register(&fakeFallbackProvider) == ERROR_NONE);
+ REQUIRE(service_manager_add(&location_service_manifest, true) == ERROR_NONE);
+
+ TaskEventGroup group;
+ task_event_group_construct(&group);
+ LocationSubscription sub {};
+ REQUIRE(location_subscribe(&sub, &group) == ERROR_NONE);
+ REQUIRE(fakeHost != nullptr);
+ REQUIRE(fallbackHost != nullptr);
+
+ CHECK(location_host_needs_fallback());
+ const Location fallback = makeLocation(10.0, 20.0);
+ location_host_report(fallbackHost, &fallback);
+ CHECK(task_event_group_wait(&group, sub.bit, false, nullptr, 0) == ERROR_NONE);
+
+ Location location;
+ REQUIRE(location_get(&location) == ERROR_NONE);
+ CHECK(strcmp(location.provider, "fakefallback") == 0);
+
+ const Location primary = makeLocation(51.05, 3.72);
+ location_host_report(fakeHost, &primary);
+ CHECK(task_event_group_wait(&group, sub.bit, false, nullptr, 0) == ERROR_NONE);
+ CHECK_FALSE(location_host_needs_fallback());
+ REQUIRE(location_get(&location) == ERROR_NONE);
+ CHECK(strcmp(location.provider, "fake") == 0);
+
+ // A fallback fix doesn't replace a fresh fix, so subscribers aren't signalled
+ location_host_report(fallbackHost, &fallback);
+ CHECK(task_event_group_wait(&group, sub.bit, false, nullptr, 0) == ERROR_TIMEOUT);
+ REQUIRE(location_get(&location) == ERROR_NONE);
+ CHECK(strcmp(location.provider, "fake") == 0);
+
+ // Without the primary provider, the fallback fix is used again
+ CHECK(location_provider_unregister(&fakeProvider) == ERROR_NONE);
+ CHECK(location_host_needs_fallback());
+ REQUIRE(location_get(&location) == ERROR_NONE);
+ CHECK(strcmp(location.provider, "fakefallback") == 0);
+
+ CHECK(location_unsubscribe(&sub) == ERROR_NONE);
+ task_event_group_destruct(&group);
+ REQUIRE(service_manager_stop(location_service_manifest.id) == ERROR_NONE);
+ CHECK(location_provider_unregister(&fakeFallbackProvider) == ERROR_NONE);
+ REQUIRE(service_manager_remove(location_service_manifest.id) == ERROR_NONE);
+}
+
+TEST_CASE("location_make_approximate keeps only a coarse position") {
+ Location location = makeLocation(51.054321, 3.721234);
+ location.altitude = 12.0f;
+ location.speed = 1.5f;
+ location.satellites = 7;
+ location_make_approximate(&location);
+ CHECK(location.latitude == doctest::Approx(51.06));
+ CHECK(location.longitude == doctest::Approx(3.72));
+ CHECK(location.horizontal_accuracy == doctest::Approx(LOCATION_APPROXIMATE_ACCURACY_METERS));
+ CHECK(std::isnan(location.altitude));
+ CHECK(std::isnan(location.speed));
+ CHECK(location.satellites == 0);
+ CHECK(location.time_ms == 1000);
+
+ location.horizontal_accuracy = 25000.0f;
+ location_make_approximate(&location);
+ CHECK(location.horizontal_accuracy == doctest::Approx(25000.0));
+}
diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt
index 763629105..12e124d2f 100644
--- a/Tactility/CMakeLists.txt
+++ b/Tactility/CMakeLists.txt
@@ -41,6 +41,7 @@ list(APPEND REQUIRES_LIST
gps-module
graphics-module
http-module
+ location-module
mbedtls-module
mesh-module
mesh-meshcore-module
diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp
index 1f18c5e7e..4fc24ba9d 100644
--- a/Tactility/Source/InitApps.cpp
+++ b/Tactility/Source/InitApps.cpp
@@ -58,6 +58,7 @@ namespace app {
namespace launcher { extern const ::AppManifest manifest; }
namespace ledstripsettings { extern const ::AppManifest manifest; }
namespace localesettings { extern const ::AppManifest manifest; }
+ namespace locationsettings { extern const ::AppManifest manifest; }
namespace notes { extern const ::AppManifest manifest; }
namespace power { extern const ::AppManifest manifest; }
namespace poweroff { extern const ::AppManifest manifest; }
@@ -130,6 +131,7 @@ static void registerInternalApps() {
app_manager_add(&app::inputdialog::manifest);
app_manager_add(&app::launcher::manifest);
app_manager_add(&app::localesettings::manifest);
+ app_manager_add(&app::locationsettings::manifest);
app_manager_add(&app::notes::manifest);
if (device_exists_of_type(&POWER_SUPPLY_TYPE)) {
app_manager_add(&app::poweroff::manifest);
diff --git a/Tactility/Source/InitServices.cpp b/Tactility/Source/InitServices.cpp
index b6525348a..2e90588b3 100644
--- a/Tactility/Source/InitServices.cpp
+++ b/Tactility/Source/InitServices.cpp
@@ -8,6 +8,7 @@
#include
#include
+#include
#include
#if TT_FEATURE_MESH
#include
@@ -49,6 +50,7 @@ void registerAndStartServices() {
addService(service::audio::manifest);
}
check(module_ensure_started(&wifi_module) == ERROR_NONE);
+ check(module_ensure_started(&location_module) == ERROR_NONE);
// Before the mesh service starts, as it needs the region to turn on the radio
settings::radio::applyRegion();
#if TT_FEATURE_MESH
diff --git a/Tactility/Source/app/launcher/SettingsMode.cpp b/Tactility/Source/app/launcher/SettingsMode.cpp
index 6b2ebcace..9def75e73 100644
--- a/Tactility/Source/app/launcher/SettingsMode.cpp
+++ b/Tactility/Source/app/launcher/SettingsMode.cpp
@@ -29,6 +29,7 @@ constexpr IconEntry ICONS[] = {
{"tactility.keyboardsettings", LVGL_ICON_SHARED_KEYBOARD_ALT},
{"tactility.ledstripsettings", LVGL_ICON_SHARED_LIGHTSTRIP},
{"tactility.localesettings", LVGL_ICON_SHARED_LANGUAGE},
+ {"tactility.locationsettings", LVGL_ICON_SHARED_LOCATION_ON},
{"tactility.meshsettings", LVGL_ICON_SHARED_HUB},
{"tactility.power", LVGL_ICON_SHARED_POWER_SETTINGS_NEW},
{"tactility.timedatesettings", LVGL_ICON_SHARED_CALENDAR_MONTH},
diff --git a/Tactility/Source/app/locationsettings/LocationSettings.cpp b/Tactility/Source/app/locationsettings/LocationSettings.cpp
new file mode 100644
index 000000000..07d99a810
--- /dev/null
+++ b/Tactility/Source/app/locationsettings/LocationSettings.cpp
@@ -0,0 +1,244 @@
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+
+namespace tt::app::locationsettings {
+
+extern const ::AppManifest manifest;
+
+namespace {
+
+struct PrecisionOption {
+ LocationPrecision precision;
+ const char* name;
+};
+
+constexpr PrecisionOption PRECISION_OPTIONS[] = {
+ { LOCATION_PRECISION_APPROXIMATE, "Approximate" },
+ { LOCATION_PRECISION_PRECISE, "Precise" },
+};
+
+struct Context {
+ uint32_t appInstanceId;
+ TaskEventGroup* eventGroup = nullptr;
+ uint32_t enableBit = 0;
+ uint32_t precisionBit = 0;
+ ::std::atomic requestedEnabled = false;
+ ::std::atomic requestedPrecision = LOCATION_PRECISION_PRECISE;
+ // Valid while the window is shown
+ lv_obj_t* enableSwitch = nullptr;
+ lv_obj_t* precisionChips = nullptr;
+ lv_obj_t* statusLabel = nullptr;
+};
+
+LocationPrecision getChipPrecision(lv_obj_t* chip) {
+ return static_cast(reinterpret_cast(lv_obj_get_user_data(chip)));
+}
+
+std::string getStatusText() {
+ if (!location_is_enabled()) {
+ return "Location services are off";
+ }
+ Location location;
+ if (location_get(&location) != ERROR_NONE) {
+ return "Waiting for location...";
+ }
+ std::string text;
+ auto output = ::std::back_inserter(text);
+ ::std::format_to(output, "{:.5f}, {:.5f}", location.latitude, location.longitude);
+ if (!::std::isnan(location.horizontal_accuracy)) {
+ ::std::format_to(output, " (±{:.0f} m)", location.horizontal_accuracy);
+ }
+ ::std::format_to(output, "\nSource: {}", location.provider);
+ return text;
+}
+
+void updateWidgets(Context* ctx) {
+ if (ctx->statusLabel == nullptr) {
+ return;
+ }
+ lv_obj_set_state(ctx->enableSwitch, LV_STATE_CHECKED, location_is_enabled());
+
+ const LocationPrecision precision = location_get_precision();
+ const uint32_t count = lv_obj_get_child_count(ctx->precisionChips);
+ for (uint32_t i = 0; i < count; i++) {
+ auto* chip = lv_obj_get_child(ctx->precisionChips, static_cast(i));
+ lv_obj_set_state(chip, LV_STATE_CHECKED, getChipPrecision(chip) == precision);
+ }
+
+ lv_label_set_text(ctx->statusLabel, getStatusText().c_str());
+}
+
+void onBackPressed(lv_event_t* event) {
+ auto* ctx = static_cast(lv_event_get_user_data(event));
+ app_event_emit_close(ctx->appInstanceId);
+}
+
+// Starting and stopping the service blocks, so it happens on the app's own task
+void onEnableSwitchChanged(lv_event_t* event) {
+ auto* ctx = static_cast(lv_event_get_user_data(event));
+ ctx->requestedEnabled = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED);
+ task_event_group_signal(ctx->eventGroup, ctx->enableBit);
+}
+
+void onPrecisionChipPressed(lv_event_t* event) {
+ auto* ctx = static_cast(lv_event_get_user_data(event));
+ ctx->requestedPrecision = getChipPrecision(lv_event_get_target_obj(event));
+ task_event_group_signal(ctx->eventGroup, ctx->precisionBit);
+}
+
+void createWidgets(lv_obj_t* parent, void* userData) {
+ auto* ctx = static_cast(userData);
+
+ lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
+ lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
+
+ auto* toolbar = lvgl_toolbar_create(parent, "Location");
+ lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
+ ctx->enableSwitch = lvgl_toolbar_add_switch_action(toolbar);
+ lv_obj_add_event_cb(ctx->enableSwitch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx);
+
+ auto* wrapper = lv_obj_create(parent);
+ lv_obj_set_width(wrapper, LV_PCT(100));
+ lv_obj_set_flex_grow(wrapper, 1);
+ lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
+ lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
+ lvgl::obj_set_style_bg_invisible(wrapper);
+
+ auto* precisionLabel = lv_label_create(wrapper);
+ lv_label_set_text(precisionLabel, "Precision");
+
+ // The chips are centered in the card while they fit, and scroll when they don't
+ auto* precisionCard = lvgl_card_create(wrapper);
+ lv_obj_set_size(precisionCard, LV_PCT(100), LV_SIZE_CONTENT);
+ lv_obj_set_flex_flow(precisionCard, LV_FLEX_FLOW_COLUMN);
+ lv_obj_set_flex_align(precisionCard, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
+ lv_obj_set_style_pad_left(precisionLabel, lv_obj_get_style_pad_left(precisionCard, LV_PART_MAIN), LV_STATE_DEFAULT);
+
+ ctx->precisionChips = lv_obj_create(precisionCard);
+ lv_obj_set_size(ctx->precisionChips, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
+ lv_obj_set_style_max_width(ctx->precisionChips, LV_PCT(100), LV_STATE_DEFAULT);
+ lv_obj_set_flex_flow(ctx->precisionChips, LV_FLEX_FLOW_ROW);
+ lv_obj_set_scroll_dir(ctx->precisionChips, LV_DIR_HOR);
+ lv_obj_set_style_bg_opa(ctx->precisionChips, LV_OPA_TRANSP, LV_STATE_DEFAULT);
+ lv_obj_set_style_border_width(ctx->precisionChips, 0, LV_STATE_DEFAULT);
+ // The chips' margins leave room for their focus rings and space them apart
+ lv_obj_set_style_pad_all(ctx->precisionChips, 0, LV_STATE_DEFAULT);
+ lv_obj_set_style_pad_column(ctx->precisionChips, 0, LV_STATE_DEFAULT);
+
+ for (const auto& option : PRECISION_OPTIONS) {
+ auto* chip = lvgl_chip_create(ctx->precisionChips);
+ lv_label_set_text(lv_label_create(chip), option.name);
+ lv_obj_set_user_data(chip, reinterpret_cast(static_cast(option.precision)));
+ lv_obj_add_event_cb(chip, onPrecisionChipPressed, LV_EVENT_SHORT_CLICKED, ctx);
+ }
+ lvgl_grid_navigation_add(ctx->precisionChips);
+
+ auto* precisionHint = lv_label_create(precisionCard);
+ lv_obj_set_width(precisionHint, LV_PCT(100));
+ lv_label_set_long_mode(precisionHint, LV_LABEL_LONG_WRAP);
+ lv_obj_set_style_text_font(precisionHint, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT);
+ lv_label_set_text(precisionHint, "Approximate location is accurate to a few kilometers. Location based on the IP address is never precise.");
+
+ auto* statusCard = lvgl_card_create(wrapper);
+ lv_obj_set_size(statusCard, LV_PCT(100), LV_SIZE_CONTENT);
+ ctx->statusLabel = lv_label_create(statusCard);
+ lv_obj_set_width(ctx->statusLabel, LV_PCT(100));
+ lv_label_set_long_mode(ctx->statusLabel, LV_LABEL_LONG_WRAP);
+
+ updateWidgets(ctx);
+}
+
+void destroyWidgets(void* userData) {
+ auto* ctx = static_cast(userData);
+ ctx->enableSwitch = nullptr;
+ ctx->precisionChips = nullptr;
+ ctx->statusLabel = nullptr;
+}
+
+int32_t appMain(int argc, char* argv[]) {
+ Context ctx {};
+ ctx.appInstanceId = app_scheduler_current_app_id();
+
+ TaskEventGroup eventGroup {};
+ task_event_group_construct(&eventGroup);
+ ctx.eventGroup = &eventGroup;
+ check(task_event_group_claim_bit(&eventGroup, &ctx.enableBit) == ERROR_NONE);
+ check(task_event_group_claim_bit(&eventGroup, &ctx.precisionBit) == ERROR_NONE);
+
+ AppEventSubscription appEvents {};
+ check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE);
+ LocationSubscription locationEvents {};
+ check(location_subscribe(&locationEvents, &eventGroup) == ERROR_NONE);
+
+ WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx);
+
+ bool shouldClose = false;
+ while (!shouldClose) {
+ uint32_t flags = 0;
+ task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY);
+
+ if (flags & ctx.enableBit) {
+ location_set_enabled(ctx.requestedEnabled);
+ }
+ if (flags & ctx.precisionBit) {
+ location_set_precision(ctx.requestedPrecision);
+ }
+
+ AppEvent event {};
+ while (app_event_poll(&appEvents, &event) == ERROR_NONE) {
+ if (event.type == APP_EVENT_CLOSE) {
+ shouldClose = true;
+ break;
+ }
+ }
+
+ lvgl_lock();
+ updateWidgets(&ctx);
+ lvgl_unlock();
+ }
+
+ window_manager_remove(window);
+ check(location_unsubscribe(&locationEvents) == ERROR_NONE);
+ check(app_event_unsubscribe(&appEvents) == ERROR_NONE);
+ task_event_group_destruct(&eventGroup);
+ return 0;
+}
+
+} // namespace
+
+extern const ::AppManifest manifest = {
+ .id = "tactility.locationsettings",
+ .name = "Location",
+ .category = APP_CATEGORY_SETTINGS,
+ .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) },
+ .flags = 0,
+ .stack = {}
+};
+
+}
diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp
index 293411911..4a60db4d7 100644
--- a/Tactility/Source/app/setup/Setup.cpp
+++ b/Tactility/Source/app/setup/Setup.cpp
@@ -16,6 +16,8 @@
#include
#include
+#include
+
#include
#include
@@ -259,6 +261,23 @@ int32_t appMain(int argc, char* argv[]) {
});
}
+ ctx.steps.push_back({
+ .title = "Location Services",
+ .description = "Location services let apps use your location. It comes from GPS, or from your IP address via ip-api.com when there is no GPS fix.",
+ .run = [&ctx] {
+ const std::vector items = { "Allow precise location", "Allow approximate location", "Don't allow" };
+ ctx.pendingStepDialogId = selectiondialog::start(ctx.appInstanceId, "Location", items);
+ },
+ .onResult = [](int32_t selected) {
+ if (selected == 0 || selected == 1) {
+ location_set_precision(selected == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE);
+ location_set_enabled(true);
+ } else if (selected == 2) {
+ location_set_enabled(false);
+ }
+ }
+ });
+
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
diff --git a/Tactility/Tests/CMakeLists.txt b/Tactility/Tests/CMakeLists.txt
index cf77978fa..de5387de8 100644
--- a/Tactility/Tests/CMakeLists.txt
+++ b/Tactility/Tests/CMakeLists.txt
@@ -51,6 +51,7 @@ target_link_libraries(TactilityTests PRIVATE
gps-module
graphics-module
http-module
+ location-module
mbedtls-module
posix-symbols-module
pthread-module
diff --git a/TactilityKernel/include/tactility/concurrent/thread.h b/TactilityKernel/include/tactility/concurrent/thread.h
index 230bf86dd..58402dcb8 100644
--- a/TactilityKernel/include/tactility/concurrent/thread.h
+++ b/TactilityKernel/include/tactility/concurrent/thread.h
@@ -87,6 +87,16 @@ void thread_set_name(Thread* thread, const char* name);
*/
void thread_set_stack_size(Thread* thread, size_t stack_size);
+/**
+ * @brief Sets whether the stack should be allocated in external memory (PSRAM) when available.
+ * The stack is allocated in internal memory when there is no external memory.
+ * @warning A thread with its stack in external memory must not access flash (e.g. write to the internal filesystem or NVS).
+ * @param[in] thread The thread instance.
+ * @param[in] external true to prefer external memory
+ * @note Can only be called when the thread is in the STOPPED state.
+ */
+void thread_set_stack_external(Thread* thread, bool external);
+
/**
* @brief Sets the CPU core affinity for the thread.
* @param[in] thread The thread instance.
diff --git a/TactilityKernel/source/concurrent/thread.cpp b/TactilityKernel/source/concurrent/thread.cpp
index 5a3295db3..522659f30 100644
--- a/TactilityKernel/source/concurrent/thread.cpp
+++ b/TactilityKernel/source/concurrent/thread.cpp
@@ -28,6 +28,9 @@ struct Thread {
ThreadPriority priority = THREAD_PRIORITY_NORMAL;
Mutex mutex = { 0 };
configSTACK_DEPTH_TYPE stackDepth = 4096;
+ bool stackExternal = false;
+ // Whether the task was created with xTaskCreatePinnedToCoreWithCaps()
+ bool createdWithCaps = false;
portBASE_TYPE affinity = -1;
Thread() {
@@ -77,10 +80,17 @@ static void thread_main_body(void* context) {
vTaskSetThreadLocalStoragePointer(nullptr, LOCAL_STORAGE_SELF_POINTER_INDEX, nullptr);
+ // The thread can be freed once taskHandle is cleared
+ const bool createdWithCaps = thread->createdWithCaps;
thread->lock();
thread->taskHandle = nullptr;
thread->unlock();
+#ifdef ESP_PLATFORM
+ if (createdWithCaps) {
+ vTaskDeleteWithCaps(nullptr);
+ }
+#endif
vTaskDelete(nullptr);
}
@@ -135,6 +145,13 @@ void thread_set_stack_size(Thread* thread, size_t stack_size) {
thread->unlock();
}
+void thread_set_stack_external(Thread* thread, bool external) {
+ thread->lock();
+ check(thread->state == THREAD_STATE_STOPPED);
+ thread->stackExternal = external;
+ thread->unlock();
+}
+
void thread_set_affinity(Thread* thread, portBASE_TYPE affinity) {
thread->lock();
check(thread->state == THREAD_STATE_STOPPED);
@@ -187,10 +204,31 @@ error_t thread_start(Thread* thread) {
thread->lock();
auto priority = static_cast(thread->priority);
portBASE_TYPE affinity = thread->affinity;
+ bool stackExternal = thread->stackExternal;
+ thread->createdWithCaps = false;
thread->unlock();
- BaseType_t result;
- if (affinity != -1) {
+ BaseType_t result = pdFAIL;
+#ifdef ESP_PLATFORM
+ if (stackExternal) {
+ // Set before the task can read it, as the task can run before the creation call returns
+ thread->createdWithCaps = true;
+ result = xTaskCreatePinnedToCoreWithCaps(
+ thread_main_body,
+ thread->name.c_str(),
+ thread->stackDepth,
+ thread,
+ priority,
+ &thread->taskHandle,
+ affinity != -1 ? affinity : tskNO_AFFINITY,
+ MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT
+ );
+ thread->createdWithCaps = (result == pdPASS);
+ }
+#endif
+ if (result == pdPASS) {
+ // Stack is in external memory
+ } else if (affinity != -1) {
#ifdef ESP_PLATFORM
result = xTaskCreatePinnedToCore(
thread_main_body,
diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt
index e743c44c8..75b4702a5 100644
--- a/Tests/CMakeLists.txt
+++ b/Tests/CMakeLists.txt
@@ -15,6 +15,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_B
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/binfont-module/tests ${CMAKE_CURRENT_BINARY_DIR}/binfont-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/lodepng-module/tests ${CMAKE_CURRENT_BINARY_DIR}/lodepng-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/lvgl-module/tests ${CMAKE_CURRENT_BINARY_DIR}/lvgl-module)
+add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/location-module/tests ${CMAKE_CURRENT_BINARY_DIR}/location-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/mesh-module/tests ${CMAKE_CURRENT_BINARY_DIR}/mesh-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/mesh-meshcore-module/tests ${CMAKE_CURRENT_BINARY_DIR}/mesh-meshcore-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/mesh-meshtastic-module/tests ${CMAKE_CURRENT_BINARY_DIR}/mesh-meshtastic-module)
@@ -32,6 +33,7 @@ add_dependencies(build-tests HttpModuleTests)
add_dependencies(build-tests BinFontModuleTests)
add_dependencies(build-tests LodePngModuleTests)
add_dependencies(build-tests LvglModuleTests)
+add_dependencies(build-tests LocationModuleTests)
add_dependencies(build-tests MeshModuleTests)
add_dependencies(build-tests MeshMeshCoreModuleTests)
add_dependencies(build-tests MeshMeshtasticModuleTests)