Location services

This commit is contained in:
Ken Van Hoeylandt 2026-10-13 01:23:33 +02:00
parent c6e3366164
commit c45a3a5769
30 changed files with 2357 additions and 2 deletions

View File

@ -173,6 +173,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
add_subdirectory(Modules/gps-module) add_subdirectory(Modules/gps-module)
add_subdirectory(Modules/graphics-module) add_subdirectory(Modules/graphics-module)
add_subdirectory(Modules/http-module) add_subdirectory(Modules/http-module)
add_subdirectory(Modules/location-module)
add_subdirectory(Modules/mbedtls-module) add_subdirectory(Modules/mbedtls-module)
add_subdirectory(Modules/mesh-module) add_subdirectory(Modules/mesh-module)
add_subdirectory(Modules/mesh-meshcore-module) add_subdirectory(Modules/mesh-meshcore-module)

View File

@ -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}
)

View File

@ -0,0 +1,195 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### 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.

View File

@ -0,0 +1,116 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/error.h>
#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

View File

@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/module.h>
#ifdef __cplusplus
extern "C" {
#endif
extern struct Module location_module;
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <location/location.h>
#include <stdbool.h>
#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

View File

@ -0,0 +1,17 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <location/location.h>
#include <location/provider.h>
#include <minmea.h>
/** 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);

View File

@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <location/provider.h>
#include <stddef.h>
/** 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);

View File

@ -0,0 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <service/manifest.h>
extern const ServiceManifest location_service_manifest;

View File

@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <location/location.h>
#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);

View File

@ -0,0 +1,261 @@
// SPDX-License-Identifier: Apache-2.0
#include <location/gps_provider.h>
#include <gps/gps.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/concurrent/thread.h>
#include <tactility/delay.h>
#include <tactility/device.h>
#include <tactility/device_listener.h>
#include <tactility/log.h>
#include <tactility/time.h>
#include <atomic>
#include <cmath>
#include <ctime>
#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<bool> 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<double>(value.value) / value.scale;
const int degrees = static_cast<int>(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<int>(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<uint8_t>(gga->satellites_tracked);
}
return true;
}
const LocationProviderApi gps_location_provider = {
.id = "gps",
.fallback = false,
.create = create,
.destroy = destroy,
};

View File

@ -0,0 +1,369 @@
// SPDX-License-Identifier: Apache-2.0
#include <location/ip_provider.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/concurrent/thread.h>
#include <tactility/log.h>
#include <tactility/system_event.h>
#include <tactility/time.h>
#include <TactilityCpp/std/string.h>
#include <fcntl.h>
#include <netdb.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string_view>
#include <strings.h>
#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<bool> 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<size_t>(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<size_t>(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<size_t>(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<uint64_t>(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<uint64_t>(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<uint32_t>(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,
};

View File

@ -0,0 +1,385 @@
// SPDX-License-Identifier: Apache-2.0
#include <location/location.h>
#include <location/location_service.h>
#include <location/location_settings.h>
#include <location/provider.h>
#include <service/manager.h>
#include <service/manifest.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/log.h>
#include <tactility/time.h>
#include <cmath>
#include <cstring>
#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(&copy);
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(&copy);
}
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,
};

View File

@ -0,0 +1,66 @@
// SPDX-License-Identifier: Apache-2.0
#include <location/location_settings.h>
#include <service/paths.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h>
#include <tactility/paths.h>
#include <tactility/properties_file.h>
#include <cstring>
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);
}

View File

@ -0,0 +1,58 @@
// SPDX-License-Identifier: Apache-2.0
#include <location/gps_provider.h>
#include <location/ip_provider.h>
#include <location/location.h>
#include <location/location_service.h>
#include <location/module.h>
#include <service/manager.h>
#include <tactility/error.h>
#include <tactility/module.h>
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,
};
}

View File

@ -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
)

View File

@ -0,0 +1,64 @@
#include "doctest.h"
#include <location/gps_provider.h>
#include <cmath>
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));
}

View File

@ -0,0 +1,53 @@
#include "doctest.h"
#include <location/ip_provider.h>
#include <cstring>
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));
}

View File

@ -0,0 +1,48 @@
#define DOCTEST_CONFIG_IMPLEMENT
#include "doctest.h"
#include <tactility/check.h>
#include <tactility/dts.h>
#include <tactility/freertos/task.h>
#include <tactility/kernel_init.h>
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;
}

View File

@ -0,0 +1,221 @@
#include "doctest.h"
#include <location/location.h>
#include <location/location_service.h>
#include <location/provider.h>
#include <service/manager.h>
#include <cmath>
#include <cstdio>
#include <cstring>
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));
}

View File

@ -41,6 +41,7 @@ list(APPEND REQUIRES_LIST
gps-module gps-module
graphics-module graphics-module
http-module http-module
location-module
mbedtls-module mbedtls-module
mesh-module mesh-module
mesh-meshcore-module mesh-meshcore-module

View File

@ -58,6 +58,7 @@ namespace app {
namespace launcher { extern const ::AppManifest manifest; } namespace launcher { extern const ::AppManifest manifest; }
namespace ledstripsettings { extern const ::AppManifest manifest; } namespace ledstripsettings { extern const ::AppManifest manifest; }
namespace localesettings { extern const ::AppManifest manifest; } namespace localesettings { extern const ::AppManifest manifest; }
namespace locationsettings { extern const ::AppManifest manifest; }
namespace notes { extern const ::AppManifest manifest; } namespace notes { extern const ::AppManifest manifest; }
namespace power { extern const ::AppManifest manifest; } namespace power { extern const ::AppManifest manifest; }
namespace poweroff { 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::inputdialog::manifest);
app_manager_add(&app::launcher::manifest); app_manager_add(&app::launcher::manifest);
app_manager_add(&app::localesettings::manifest); app_manager_add(&app::localesettings::manifest);
app_manager_add(&app::locationsettings::manifest);
app_manager_add(&app::notes::manifest); app_manager_add(&app::notes::manifest);
if (device_exists_of_type(&POWER_SUPPLY_TYPE)) { if (device_exists_of_type(&POWER_SUPPLY_TYPE)) {
app_manager_add(&app::poweroff::manifest); app_manager_add(&app::poweroff::manifest);

View File

@ -8,6 +8,7 @@
#include <Tactility/service/ServiceRegistration.h> #include <Tactility/service/ServiceRegistration.h>
#include <Tactility/settings/RadioSettings.h> #include <Tactility/settings/RadioSettings.h>
#include <location/module.h>
#include <wifi/module.h> #include <wifi/module.h>
#if TT_FEATURE_MESH #if TT_FEATURE_MESH
#include <mesh/module.h> #include <mesh/module.h>
@ -49,6 +50,7 @@ void registerAndStartServices() {
addService(service::audio::manifest); addService(service::audio::manifest);
} }
check(module_ensure_started(&wifi_module) == ERROR_NONE); 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 // Before the mesh service starts, as it needs the region to turn on the radio
settings::radio::applyRegion(); settings::radio::applyRegion();
#if TT_FEATURE_MESH #if TT_FEATURE_MESH

View File

@ -29,6 +29,7 @@ constexpr IconEntry ICONS[] = {
{"tactility.keyboardsettings", LVGL_ICON_SHARED_KEYBOARD_ALT}, {"tactility.keyboardsettings", LVGL_ICON_SHARED_KEYBOARD_ALT},
{"tactility.ledstripsettings", LVGL_ICON_SHARED_LIGHTSTRIP}, {"tactility.ledstripsettings", LVGL_ICON_SHARED_LIGHTSTRIP},
{"tactility.localesettings", LVGL_ICON_SHARED_LANGUAGE}, {"tactility.localesettings", LVGL_ICON_SHARED_LANGUAGE},
{"tactility.locationsettings", LVGL_ICON_SHARED_LOCATION_ON},
{"tactility.meshsettings", LVGL_ICON_SHARED_HUB}, {"tactility.meshsettings", LVGL_ICON_SHARED_HUB},
{"tactility.power", LVGL_ICON_SHARED_POWER_SETTINGS_NEW}, {"tactility.power", LVGL_ICON_SHARED_POWER_SETTINGS_NEW},
{"tactility.timedatesettings", LVGL_ICON_SHARED_CALENDAR_MONTH}, {"tactility.timedatesettings", LVGL_ICON_SHARED_CALENDAR_MONTH},

View File

@ -0,0 +1,244 @@
#include <Tactility/lvgl/Style.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <location/location.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h>
#include <TactilityCpp/std/string.h>
#include <atomic>
#include <cmath>
#include <format>
#include <iterator>
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<bool> requestedEnabled = false;
::std::atomic<LocationPrecision> 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<LocationPrecision>(reinterpret_cast<intptr_t>(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<int32_t>(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<Context*>(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<Context*>(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<Context*>(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<Context*>(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<void*>(static_cast<intptr_t>(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<Context*>(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<void*>(appMain) },
.flags = 0,
.stack = {}
};
}

View File

@ -16,6 +16,8 @@
#include <app/scheduler.h> #include <app/scheduler.h>
#include <app/start.h> #include <app/start.h>
#include <location/location.h>
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h> #include <tactility/check.h>
@ -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<std::string> 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 {}; TaskEventGroup event_group {};
task_event_group_construct(&event_group); task_event_group_construct(&event_group);

View File

@ -51,6 +51,7 @@ target_link_libraries(TactilityTests PRIVATE
gps-module gps-module
graphics-module graphics-module
http-module http-module
location-module
mbedtls-module mbedtls-module
posix-symbols-module posix-symbols-module
pthread-module pthread-module

View File

@ -87,6 +87,16 @@ void thread_set_name(Thread* thread, const char* name);
*/ */
void thread_set_stack_size(Thread* thread, size_t stack_size); 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. * @brief Sets the CPU core affinity for the thread.
* @param[in] thread The thread instance. * @param[in] thread The thread instance.

View File

@ -28,6 +28,9 @@ struct Thread {
ThreadPriority priority = THREAD_PRIORITY_NORMAL; ThreadPriority priority = THREAD_PRIORITY_NORMAL;
Mutex mutex = { 0 }; Mutex mutex = { 0 };
configSTACK_DEPTH_TYPE stackDepth = 4096; configSTACK_DEPTH_TYPE stackDepth = 4096;
bool stackExternal = false;
// Whether the task was created with xTaskCreatePinnedToCoreWithCaps()
bool createdWithCaps = false;
portBASE_TYPE affinity = -1; portBASE_TYPE affinity = -1;
Thread() { Thread() {
@ -77,10 +80,17 @@ static void thread_main_body(void* context) {
vTaskSetThreadLocalStoragePointer(nullptr, LOCAL_STORAGE_SELF_POINTER_INDEX, nullptr); 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->lock();
thread->taskHandle = nullptr; thread->taskHandle = nullptr;
thread->unlock(); thread->unlock();
#ifdef ESP_PLATFORM
if (createdWithCaps) {
vTaskDeleteWithCaps(nullptr);
}
#endif
vTaskDelete(nullptr); vTaskDelete(nullptr);
} }
@ -135,6 +145,13 @@ void thread_set_stack_size(Thread* thread, size_t stack_size) {
thread->unlock(); 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) { void thread_set_affinity(Thread* thread, portBASE_TYPE affinity) {
thread->lock(); thread->lock();
check(thread->state == THREAD_STATE_STOPPED); check(thread->state == THREAD_STATE_STOPPED);
@ -187,10 +204,31 @@ error_t thread_start(Thread* thread) {
thread->lock(); thread->lock();
auto priority = static_cast<UBaseType_t>(thread->priority); auto priority = static_cast<UBaseType_t>(thread->priority);
portBASE_TYPE affinity = thread->affinity; portBASE_TYPE affinity = thread->affinity;
bool stackExternal = thread->stackExternal;
thread->createdWithCaps = false;
thread->unlock(); thread->unlock();
BaseType_t result; BaseType_t result = pdFAIL;
if (affinity != -1) { #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 #ifdef ESP_PLATFORM
result = xTaskCreatePinnedToCore( result = xTaskCreatePinnedToCore(
thread_main_body, thread_main_body,

View File

@ -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/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/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/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-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-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) 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 BinFontModuleTests)
add_dependencies(build-tests LodePngModuleTests) add_dependencies(build-tests LodePngModuleTests)
add_dependencies(build-tests LvglModuleTests) add_dependencies(build-tests LvglModuleTests)
add_dependencies(build-tests LocationModuleTests)
add_dependencies(build-tests MeshModuleTests) add_dependencies(build-tests MeshModuleTests)
add_dependencies(build-tests MeshMeshCoreModuleTests) add_dependencies(build-tests MeshMeshCoreModuleTests)
add_dependencies(build-tests MeshMeshtasticModuleTests) add_dependencies(build-tests MeshMeshtasticModuleTests)