From c45a3a576988437ca00ee9b499f4ef8665b7dcab Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 13 Oct 2026 01:23:33 +0200 Subject: [PATCH] Location services --- CMakeLists.txt | 1 + Modules/location-module/CMakeLists.txt | 24 ++ Modules/location-module/LICENSE-Apache-2.0.md | 195 +++++++++ .../include/location/location.h | 116 ++++++ .../location-module/include/location/module.h | 14 + .../include/location/provider.h | 69 ++++ .../private/location/gps_provider.h | 17 + .../private/location/ip_provider.h | 32 ++ .../private/location/location_service.h | 6 + .../private/location/location_settings.h | 16 + .../location-module/source/gps_provider.cpp | 261 ++++++++++++ .../location-module/source/ip_provider.cpp | 369 +++++++++++++++++ .../source/location_service.cpp | 385 ++++++++++++++++++ .../source/location_settings.cpp | 66 +++ Modules/location-module/source/module.cpp | 58 +++ Modules/location-module/tests/CMakeLists.txt | 20 + .../tests/source/gps_provider_test.cpp | 64 +++ .../tests/source/ip_provider_test.cpp | 53 +++ Modules/location-module/tests/source/main.cpp | 48 +++ .../tests/source/service_test.cpp | 221 ++++++++++ Tactility/CMakeLists.txt | 1 + Tactility/Source/InitApps.cpp | 2 + Tactility/Source/InitServices.cpp | 2 + .../Source/app/launcher/SettingsMode.cpp | 1 + .../app/locationsettings/LocationSettings.cpp | 244 +++++++++++ Tactility/Source/app/setup/Setup.cpp | 19 + Tactility/Tests/CMakeLists.txt | 1 + .../include/tactility/concurrent/thread.h | 10 + TactilityKernel/source/concurrent/thread.cpp | 42 +- Tests/CMakeLists.txt | 2 + 30 files changed, 2357 insertions(+), 2 deletions(-) create mode 100644 Modules/location-module/CMakeLists.txt create mode 100644 Modules/location-module/LICENSE-Apache-2.0.md create mode 100644 Modules/location-module/include/location/location.h create mode 100644 Modules/location-module/include/location/module.h create mode 100644 Modules/location-module/include/location/provider.h create mode 100644 Modules/location-module/private/location/gps_provider.h create mode 100644 Modules/location-module/private/location/ip_provider.h create mode 100644 Modules/location-module/private/location/location_service.h create mode 100644 Modules/location-module/private/location/location_settings.h create mode 100644 Modules/location-module/source/gps_provider.cpp create mode 100644 Modules/location-module/source/ip_provider.cpp create mode 100644 Modules/location-module/source/location_service.cpp create mode 100644 Modules/location-module/source/location_settings.cpp create mode 100644 Modules/location-module/source/module.cpp create mode 100644 Modules/location-module/tests/CMakeLists.txt create mode 100644 Modules/location-module/tests/source/gps_provider_test.cpp create mode 100644 Modules/location-module/tests/source/ip_provider_test.cpp create mode 100644 Modules/location-module/tests/source/main.cpp create mode 100644 Modules/location-module/tests/source/service_test.cpp create mode 100644 Tactility/Source/app/locationsettings/LocationSettings.cpp 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)