Merge branch 'main' into location-service

This commit is contained in:
Ken Van Hoeylandt 2026-10-13 08:39:20 +02:00
commit 080aa088cf
73 changed files with 3015 additions and 1136 deletions

View File

@ -0,0 +1,11 @@
# Claude Builds
- Always build ESP32 firmware in `build-claude/` (gitignored), never in the user's `build/`:
```bash
cp sdkconfig <scratchpad>/sdkconfig.copy
idf.py -B build-claude -D SDKCONFIG=<scratchpad>/sdkconfig.copy build
```
- Never modify the `sdkconfig` in the project root, including through `python device.py`, which overwrites it.
Only do so when the user explicitly asks. That permission is valid for the current session only.

View File

@ -1,4 +1,5 @@
app-module
app-hub-module
audio-decoder-module
cjson-module
crypt-module

View File

@ -183,6 +183,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
add_subdirectory(Modules/service-module)
add_subdirectory(Modules/wifi-module)
add_subdirectory(Modules/app-module)
add_subdirectory(Modules/app-hub-module)
add_subdirectory(Modules/coreutils-module)
add_subdirectory(Modules/app-posix-module)
add_subdirectory(Modules/lvgl-window-manager-module)

View File

@ -51,6 +51,7 @@ language ea07
lightbulb e90f
lightstrip fff75
link e250
list_alt e0ee
lists e9b9
location_off e0c7
location_on f1db

View File

@ -11,6 +11,7 @@
#include <tactility/bindings/esp32_sdspi.h>
#include <tactility/bindings/esp32_pwm_ledc.h>
#include <tactility/bindings/esp32_usbdevice.h>
#include <tactility/bindings/gpio_hog.h>
#include <tactility/bindings/pwm_backlight.h>
#include <gps_generic/bindings.h>
#include <bindings/st7796.h>
@ -18,6 +19,7 @@
#include <bindings/tca8418.h>
#include <bindings/bq25896.h>
#include <bindings/drv2605.h>
#include <bindings/es8311.h>
#include <bindings/gpio_encoder.h>
#include <bindings/sx1262.h>
#include <bindings/xl9555.h>
@ -40,6 +42,17 @@
gpio-count = <49>;
};
// Speaker and microphone (ES8311)
i2s0 {
compatible = "espressif,esp32-i2s";
port = <I2S_NUM_0>;
pin-bclk = <&gpio0 11 GPIO_FLAG_NONE>;
pin-ws = <&gpio0 18 GPIO_FLAG_NONE>;
pin-data-out = <&gpio0 45 GPIO_FLAG_NONE>;
pin-data-in = <&gpio0 17 GPIO_FLAG_NONE>;
pin-mclk = <&gpio0 10 GPIO_FLAG_NONE>;
};
i2c0 {
compatible = "espressif,esp32-i2c-master";
port = <I2C_NUM_0>;
@ -98,6 +111,19 @@
compatible = "ti,drv2605";
reg = <0x5A>;
};
es8311 {
compatible = "everest,es8311";
reg = <0x18>;
i2s = <&i2s0>;
};
};
// Speaker amplifier enable (XL9555 IO1)
speaker_amp_enable {
compatible = "gpio-hog";
pin = <&xl9555 1 GPIO_FLAG_NONE>;
mode = <GPIO_HOG_MODE_OUTPUT_HIGH>;
};
// Keyboard backlight (LED_PWM), GPIO46. The keypress-triggered flash/decay effect from the
@ -184,18 +210,6 @@
};
};
// ES8311
// TODO: init via I2C to enable audio playback
i2s0 {
compatible = "espressif,esp32-i2s";
port = <I2S_NUM_0>;
pin-bclk = <&gpio0 11 GPIO_FLAG_NONE>;
pin-ws = <&gpio0 18 GPIO_FLAG_NONE>;
pin-data-out = <&gpio0 45 GPIO_FLAG_NONE>;
pin-data-in = <&gpio0 17 GPIO_FLAG_NONE>;
pin-mclk = <&gpio0 10 GPIO_FLAG_NONE>;
};
uart_internal: uart0 {
compatible = "espressif,esp32-uart";
port = <UART_NUM_0>;

View File

@ -6,6 +6,8 @@ dependencies:
- Drivers/tca8418-module
- Drivers/bq25896-module
- Drivers/drv2605-module
- Drivers/es8311-module
- Drivers/audio-stream-module
- Drivers/gpio-encoder-module
- Drivers/sx126x-module
- Drivers/xl9555-module

View File

@ -7,13 +7,13 @@
## Higher Priority
- Commandline app to control: bluetooth, mesh, power
- App Hub: create updater service with "installing/update" statusbar icon, create commandline app
- System dialog with service toggles: when no pointer device available, select close button by default
- Fix audio in T-Lora Pager
- When Wi-Fi connects, check for app updates
- App Hub should make it obvious in its list when apps have updates.
- Replace "Change" buttons to "list_alt"
- Create location service, which works with location providers. GPS is a location provider. IP is also a location provider (must have wifi connected)
- Use GPS time to set/update the current time: when enabled, at boot, when wifi is not enabled, start the gps device and wait until a lock is acquired, then get the time and sync it. Then stop the GPS device.
- lvgl-module's spinner relies on hard-coded spinner asset from Tactility main project, should be sized based on DPI
- Cursor is hard-coded, needs size based on DPI
- Improve Setup: Show progress of some kind
- Files app should have a page indicator like Launcher.
- Move Bluetooth code from kernel to Modules/bluetooth-module, and have includes in path bluetooth/ then make a bluetooth_command.cpp like wifi_command.cpp
- Inspect code for memory improvements: replace `std::*` with `tt::std::*`
- esp_lvgl_port settings has a large stack size (~9kB) to fix stackoverflow when LVGL events (e.g. button click) do actions like file operations do actions like file operations. Can we reduce the callstack?
- Improve posix support: use user home path and system /tmp folder.
@ -21,7 +21,6 @@
- Pick different font to improve monochrome screen readability (or change the weight).
- Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual module.
- Improve SPI kernel driver (implement read, write, transactions)
- TCA9534 keyboards should use interrupts
- External app loading: Check the version of Tactility and check ESP target hardware to check for compatibility
Check during installation process, but also when starting (SD card might have old app install from before Tactility OS update)
- Support direct installation of an `.app` file with `tactility.py install --app helloworld.app`
@ -37,17 +36,14 @@
Without exceptions, an app that calls exit() is never unloaded and the next app start waits the full timeout.
- netutils-module: ping, wget, ifconfig, nslookup, etc.
- Core2: support power off via software
- Improve Setup: Show "Step done" screen
- Make it clearer to end-users that an SD card is required to run Tactility
- Warn if file operations are done from prohibited tasks (e.g. lvgl task)
- stopAppFromToolbar() in lvgl/Lvgl.cpp stops the top-most app. Change it so the toolbar knows for which app id it is created, so it can rely on that.
- lvgl-module's spinner relies on hard-coded spinner asset from Tactility main project.
- `struct Driver` has an `.owner`, but it's not always set. Either validate on Module construct that it matches, or otherwise set it during module start. The problem: NULL parent currently means that driver is not removable. This clashes with setting it dynamically. Consider some kind of flag to determine removability.
- Consider moving certain drivers into separate modules: audio, bt, wifi, etc
- Consider using https://github.com/Graphify-Labs/graphify
- Consider implementing LVGL gridnav in apps https://lvgl.io/docs/open/9.3/details/auxiliary-modules/gridnav.html
- Refactor HttpServer into C code and move implementation to http-module
- Use GPS time to set/update the current time
- Make a URL handler. Use it for handling local files. Match file types with apps.
Create some kind of "intent" handler like on Android.
The intent can have an action (e.g. view), a URL and an optional bundle.
@ -59,6 +55,7 @@
## Lower Priority
- When Wi-Fi connects, check for app updates
- Diceware app has large "+" and "-' buttons on Cardputer. It should be smaller.
- lvgl-module has a keyboard.cpp that creates a `keyboard_group`. This group is set as the default group, so it can also work with trackball(= LVGL "encoder").
Make a separate group that is the default group. The keyboard can then use it (or use its own).

View File

@ -0,0 +1,12 @@
cmake_minimum_required(VERSION 3.20)
include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake")
file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
tactility_add_module(app-hub-module
SRCS ${SOURCE_FILES}
PRIV_INCLUDE_DIRS private/
INCLUDE_DIRS include/
REQUIRES TactilityKernel TactilityKernelCpp app-module service-module http-module cjson-module
)

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,14 @@
# app-hub-module
App Hub service (`tactility.apphub`): finds, installs and removes apps from the online app catalog.
The C API is in `<app_hub/app_hub.h>`.
- `app_hub_search()` lists the catalog apps that are compatible with the device, merged with all installed apps.
- `app_hub_refresh()`, `app_hub_install()` and `app_hub_remove()` queue an operation. Operations run one at a time
on a background task, which stops after 5 seconds without operations and starts again for the next one.
- Subscribe with `app_hub_event_subscribe()`, then wait with `app_hub_event_await()` and read with `app_hub_event_poll()`.
The events are `REFRESHING`/`REFRESHED`, `INSTALLING`/`INSTALLED` and `REMOVING`/`REMOVED`, with the app id and the result.
The catalog is stored in the service's user data directory, so it's available offline after the first refresh.
License: [Apache v2.0](LICENSE-Apache-2.0.md)

View File

@ -0,0 +1,193 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <app/package_manifest.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/error.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* App Hub service: finds, installs and removes apps from the online app catalog.
* Operations are queued and run one at a time on a background task. Their outcome is reported through events.
*/
#define APP_HUB_NAME_MAX_LENGTH 63
enum AppHubOperationType {
/** Download the app catalog */
APP_HUB_OPERATION_REFRESH,
/** Download and install an app from the catalog, replacing an installed version */
APP_HUB_OPERATION_INSTALL,
/** Uninstall an app */
APP_HUB_OPERATION_REMOVE,
};
/** An app from the catalog, an installed app, or both */
struct AppHubApp {
PackageId id;
/** The catalog name, or the name of the package's first app when it isn't in the catalog */
char name[APP_HUB_NAME_MAX_LENGTH + 1];
/** The catalog version, empty when the app isn't in the catalog */
char version_name[PACKAGE_MANIFEST_VERSION_NAME_LENGTH + 1];
uint64_t version_code;
/** The installed version, empty when the app isn't installed */
char installed_version_name[PACKAGE_MANIFEST_VERSION_NAME_LENGTH + 1];
uint64_t installed_version_code;
bool in_catalog;
bool installed;
/** The catalog has a newer version than the installed one */
bool update_available;
/** An install or remove operation for this app is queued or running */
bool operation_pending;
/** The pending operation, valid when operation_pending is set */
enum AppHubOperationType pending_operation;
};
/**
* @param[in] app the app, only valid during the call
* @return true to continue, false to stop
*/
typedef bool (*AppHubAppVisitorFn)(const struct AppHubApp* app, void* context);
// region Apps
/**
* Visit the catalog apps that are compatible with this device and all installed apps, sorted by name.
* @warning The visitor runs with the service's lock held: it must not call app_hub_*() functions.
* @param[in] query NULL or empty for all apps, otherwise a case-insensitive part of the name or id
* @param[in] visitor called for every matching app
* @param[in] context passed to @a visitor
*/
error_t app_hub_search(const char* query, AppHubAppVisitorFn visitor, void* context);
/**
* @param[in] id the app id, must not be NULL
* @param[out] app the app, must not be NULL
* @retval ERROR_NOT_FOUND when the app is neither in the catalog nor installed
*/
error_t app_hub_get_app(const char* id, struct AppHubApp* app);
/**
* Get the catalog description of an app.
* @param[in] id the app id, must not be NULL
* @param[out] description the description, must not be NULL
* @param[in] description_size the size of @a description
* @retval ERROR_NOT_FOUND when the app isn't in the catalog
* @retval ERROR_BUFFER_OVERFLOW when @a description_size is too small
*/
error_t app_hub_get_description(const char* id, char* description, size_t description_size);
// endregion
// region Operations
/**
* Queue a download of the app catalog.
* Only one refresh is queued or running at a time: while one is pending, its id is returned instead.
* @param[out] operation_id the id of the queued operation (optional)
*/
error_t app_hub_refresh(uint32_t* operation_id);
/**
* Queue the installation of a catalog app. An installed version is replaced.
* Only one install or remove is queued or running at a time. A refresh can be pending next to it.
* @param[out] operation_id the id of the queued operation (optional)
* @retval ERROR_NONE the operation was queued, or the same install was already pending and its id is returned
* @retval ERROR_RESOURCE_BUSY another install or remove is pending
*/
error_t app_hub_install(const char* id, uint32_t* operation_id);
/**
* Queue the removal of an installed app.
* Only one install or remove is queued or running at a time. A refresh can be pending next to it.
* @param[out] operation_id the id of the queued operation (optional)
* @retval ERROR_NONE the operation was queued, or the same removal was already pending and its id is returned
* @retval ERROR_RESOURCE_BUSY another install or remove is pending
*/
error_t app_hub_remove(const char* id, uint32_t* operation_id);
// endregion
// region Events
enum AppHubEventType {
/** The catalog download started */
APP_HUB_EVENT_REFRESHING,
/** The catalog download finished, see AppHubEvent.result */
APP_HUB_EVENT_REFRESHED,
/** The installation of AppHubEvent.app_id started */
APP_HUB_EVENT_INSTALLING,
/** The installation of AppHubEvent.app_id finished, see AppHubEvent.result */
APP_HUB_EVENT_INSTALLED,
/** The removal of AppHubEvent.app_id started */
APP_HUB_EVENT_REMOVING,
/** The removal of AppHubEvent.app_id finished, see AppHubEvent.result */
APP_HUB_EVENT_REMOVED,
};
struct AppHubEvent {
enum AppHubEventType type;
uint32_t operation_id;
/** Empty for the refresh events */
PackageId app_id;
/** The outcome of the operation for the REFRESHED, INSTALLED and REMOVED events, ERROR_NONE otherwise */
error_t result;
};
#define APP_HUB_EVENT_QUEUE_CAPACITY 8
/**
* Caller-owned subscription: wait on the event group, then drain with app_hub_event_poll().
* The subscriber must poll often enough: while APP_HUB_EVENT_QUEUE_CAPACITY events are queued, newer events are dropped.
* @warning Fields other than `bit` are for internal use only.
*/
struct AppHubEventSubscription {
uint32_t bit;
struct {
struct TaskEventGroup* event_group;
struct AppHubEvent queue[APP_HUB_EVENT_QUEUE_CAPACITY];
uint8_t head;
uint8_t count;
struct AppHubEventSubscription* next;
} internal;
};
/**
* @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 app_hub_event_subscribe(struct AppHubEventSubscription* sub, struct TaskEventGroup* event_group);
error_t app_hub_event_unsubscribe(struct AppHubEventSubscription* sub);
/**
* Block until an event is queued for the subscription, or the timeout elapses.
* @param[in] sub a registered subscription
* @param[in] timeout max ticks to wait
* @retval ERROR_NONE an event can be polled
* @retval ERROR_TIMEOUT no event arrived in time
*/
error_t app_hub_event_await(struct AppHubEventSubscription* sub, TickType_t timeout);
/**
* Non-blocking: pop the next event.
* @retval ERROR_TIMEOUT nothing queued
*/
error_t app_hub_event_poll(struct AppHubEventSubscription* sub, struct AppHubEvent* event);
// endregion
#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 app_hub_module;
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,38 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <TactilityCpp/Allocator.h>
#include <cstdint>
#include <string>
#include <vector>
constexpr auto* APP_HUB_CERTIFICATE_PATH = "/system/certificates/WE1.pem";
struct AppHubCatalogEntry {
std::string id;
std::string version_name;
int32_t version_code = 0;
std::string name;
std::string description;
std::string target_sdk;
std::vector<std::string> target_platforms;
/** Device ids the app is restricted to. Empty means unrestricted. */
std::vector<std::string> requires_device_id;
/** RAM in megabytes the app needs. 0 means no requirement. */
int32_t requires_ram = 0;
/** Path of the package, relative to the catalog's base URL */
std::string file;
};
using AppHubCatalog = std::vector<AppHubCatalogEntry, tt::OptExternalAllocator<AppHubCatalogEntry>>;
/** Parse an apps.json file */
bool app_hub_catalog_parse(const char* path, AppHubCatalog& catalog);
/** @return true when this device can install and run the entry's app (target platform, device and RAM requirements) */
bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry);
std::string app_hub_catalog_get_url();
std::string app_hub_catalog_get_download_url(const std::string& file);

View File

@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <service/manifest.h>
/** The service id, which also determines where the catalog is stored */
#define APP_HUB_SERVICE_ID "tactility.apphub"
extern const ServiceManifest app_hub_service_manifest;
/** @return true while the background task that runs the operations exists */
bool app_hub_is_worker_running();

View File

@ -0,0 +1,181 @@
// SPDX-License-Identifier: Apache-2.0
#include <app_hub/app_hub_catalog.h>
#include <app/package_manifest.h>
#include <tactility/log.h>
#include <cJSON.h>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <format>
#include <memory>
#include <string_view>
constexpr auto* TAG = "app_hub_catalog";
constexpr auto* BASE_URL = "https://cdn.tactilityproject.org/apps";
static std::string get_version_without_postfix() {
std::string version(TT_VERSION);
auto index = version.find_first_of('-');
return (index == std::string::npos) ? version : version.substr(0, index);
}
std::string app_hub_catalog_get_url() {
return std::format("{}/{}/apps.json", BASE_URL, get_version_without_postfix());
}
std::string app_hub_catalog_get_download_url(const std::string& file) {
return std::format("{}/{}/{}", BASE_URL, get_version_without_postfix(), file);
}
static bool read_string(const cJSON* object, const char* key, std::string& value) {
const cJSON* item = cJSON_GetObjectItemCaseSensitive(object, key);
if (!cJSON_IsString(item)) {
LOG_E(TAG, "%s is not a string", key);
return false;
}
value = item->valuestring;
return true;
}
static bool read_int32(const cJSON* object, const char* key, int32_t& value) {
const cJSON* item = cJSON_GetObjectItemCaseSensitive(object, key);
if (!cJSON_IsNumber(item) || item->valuedouble != static_cast<double>(static_cast<int32_t>(item->valuedouble))) {
LOG_E(TAG, "%s is not an integer", key);
return false;
}
value = static_cast<int32_t>(item->valuedouble);
return true;
}
static bool read_string_array(const cJSON* object, const char* key, std::vector<std::string>& values) {
const cJSON* array = cJSON_GetObjectItemCaseSensitive(object, key);
if (!cJSON_IsArray(array)) {
LOG_E(TAG, "%s is not an array", key);
return false;
}
values.clear();
const cJSON* item;
cJSON_ArrayForEach(item, array) {
if (!cJSON_IsString(item)) {
LOG_E(TAG, "%s has an item that is not a string", key);
return false;
}
values.emplace_back(item->valuestring);
}
return true;
}
static bool parse_entry(const cJSON* object, AppHubCatalogEntry& entry) {
// Optional: absent in apps.json files from before these fields existed
if (cJSON_HasObjectItem(object, "requiresDeviceId") && !read_string_array(object, "requiresDeviceId", entry.requires_device_id)) {
return false;
}
if (cJSON_HasObjectItem(object, "requiresRam") && !read_int32(object, "requiresRam", entry.requires_ram)) {
return false;
}
return read_string(object, "appId", entry.id) &&
read_string(object, "appVersionName", entry.version_name) &&
read_int32(object, "appVersionCode", entry.version_code) &&
read_string(object, "appName", entry.name) &&
read_string(object, "appDescription", entry.description) &&
read_string(object, "targetSdk", entry.target_sdk) &&
read_string(object, "file", entry.file) &&
read_string_array(object, "targetPlatforms", entry.target_platforms);
}
static std::unique_ptr<char[]> read_file(const char* path) {
FILE* file = fopen(path, "rb");
if (file == nullptr) {
return nullptr;
}
std::unique_ptr<char[]> data;
if (fseek(file, 0, SEEK_END) == 0) {
const long size = ftell(file);
if (size >= 0 && fseek(file, 0, SEEK_SET) == 0) {
data = std::make_unique<char[]>(size + 1);
if (fread(data.get(), 1, size, file) == static_cast<size_t>(size)) {
data[size] = '\0';
} else {
data = nullptr;
}
}
}
fclose(file);
return data;
}
bool app_hub_catalog_parse(const char* path, AppHubCatalog& catalog) {
auto data = read_file(path);
if (data == nullptr) {
LOG_E(TAG, "Failed to read %s", path);
return false;
}
cJSON* json = cJSON_Parse(data.get());
if (json == nullptr) {
LOG_E(TAG, "Failed to parse %s", path);
return false;
}
const cJSON* apps = cJSON_GetObjectItemCaseSensitive(json, "apps");
if (!cJSON_IsArray(apps)) {
LOG_E(TAG, "apps is not an array");
cJSON_Delete(json);
return false;
}
AppHubCatalog parsed;
parsed.resize(cJSON_GetArraySize(apps));
size_t index = 0;
const cJSON* item;
cJSON_ArrayForEach(item, apps) {
if (!parse_entry(item, parsed[index++])) {
LOG_E(TAG, "Failed to read entry");
cJSON_Delete(json);
return false;
}
}
cJSON_Delete(json);
catalog = std::move(parsed);
return true;
}
bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry) {
// The simulator isn't a real MCU target, so it has no platform to match against
#ifdef ESP_PLATFORM
if (!entry.target_platforms.empty() &&
std::ranges::find(entry.target_platforms, std::string_view(CONFIG_IDF_TARGET)) == entry.target_platforms.end()) {
return false;
}
#endif
// Same device and RAM rules as for installed packages
PackageManifest package {};
std::string device_ids;
for (const auto& device_id : entry.requires_device_id) {
if (!device_ids.empty()) {
device_ids += ',';
}
device_ids += device_id;
}
if (device_ids.size() >= sizeof(package.requires_device_id)) {
LOG_W(TAG, "%s: requiresDeviceId too long", entry.id.c_str());
return false;
}
strcpy(package.requires_device_id, device_ids.c_str());
if (entry.requires_ram < 0 || entry.requires_ram > UINT8_MAX) {
LOG_W(TAG, "%s: invalid requiresRam %d", entry.id.c_str(), static_cast<int>(entry.requires_ram));
return false;
}
package.requires_ram = static_cast<uint8_t>(entry.requires_ram);
return app_package_manifest_is_compatible(&package);
}

View File

@ -0,0 +1,705 @@
// SPDX-License-Identifier: Apache-2.0
#include <app_hub/app_hub.h>
#include <app_hub/app_hub_catalog.h>
#include <app_hub/app_hub_service.h>
#include <app/install.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <http/download.h>
#include <service/paths.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/concurrent/thread.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h>
#include <tactility/paths.h>
#include <tactility/time.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <deque>
#include <string>
#include <string_view>
#include <vector>
namespace {
constexpr auto* TAG = "app_hub";
constexpr auto* CATALOG_FILE_NAME = "apps.json";
constexpr auto* CATALOG_DOWNLOAD_FILE_NAME = "apps.json.tmp";
constexpr size_t WORKER_STACK_SIZE = 8192;
constexpr TickType_t WORKER_IDLE_TIMEOUT = pdMS_TO_TICKS(5000);
struct Operation {
uint32_t id;
AppHubOperationType type;
PackageId app_id;
};
struct InstalledPackage {
PackageManifest package;
AppId first_app_id;
};
using AppList = std::vector<AppHubApp, tt::OptExternalAllocator<AppHubApp>>;
struct GlobalMutex {
Mutex handle {};
GlobalMutex() { mutex_construct(&handle); }
~GlobalMutex() { mutex_destruct(&handle); }
};
GlobalMutex globalMutex;
void lock() { mutex_lock(&globalMutex.handle); }
void unlock() { mutex_unlock(&globalMutex.handle); }
// region State, guarded by the global lock
AppHubCatalog catalog;
bool catalogLoaded = false;
std::deque<Operation> queue;
Operation runningOperation {};
bool operationRunning = false;
uint32_t nextOperationId = 1;
AppHubEventSubscription* subscriptions = nullptr;
bool serviceRunning = false;
bool stopping = false;
Thread* worker = nullptr;
bool workerRunning = false;
TaskEventGroup workerEvents {};
uint32_t queueBit = 0;
uint32_t stopBit = 0;
// endregion
// region Events
AppHubEventType getStartedEventType(AppHubOperationType operation) {
switch (operation) {
case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHING;
case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLING;
case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVING;
}
return APP_HUB_EVENT_REFRESHING;
}
AppHubEventType getFinishedEventType(AppHubOperationType operation) {
switch (operation) {
case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHED;
case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLED;
case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVED;
}
return APP_HUB_EVENT_REFRESHED;
}
/** Requires the global lock */
void emitEvent(AppHubEventType type, const Operation& operation, error_t result) {
AppHubEvent event {};
event.type = type;
event.operation_id = operation.id;
strcpy(event.app_id, operation.app_id);
event.result = result;
for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) {
if (sub->internal.count >= APP_HUB_EVENT_QUEUE_CAPACITY) {
LOG_W(TAG, "A subscriber's event queue is full: dropped an event of operation %lu", static_cast<unsigned long>(event.operation_id));
continue;
}
const uint8_t tail = (sub->internal.head + sub->internal.count) % APP_HUB_EVENT_QUEUE_CAPACITY;
sub->internal.queue[tail] = event;
sub->internal.count++;
task_event_group_signal(sub->internal.event_group, sub->bit);
}
}
// endregion
// region Catalog
bool getCatalogPath(const char* fileName, char* path, size_t pathSize) {
return service_paths_get_user_data_path(APP_HUB_SERVICE_ID, fileName, path, pathSize) == ERROR_NONE;
}
/** Requires the global lock */
void ensureCatalogLoaded() {
if (catalogLoaded) {
return;
}
char path[FILE_MAX_PATH_STRING_LENGTH];
if (getCatalogPath(CATALOG_FILE_NAME, path, sizeof(path)) && path_exists(path)) {
catalogLoaded = app_hub_catalog_parse(path, catalog);
}
}
/** Requires the global lock */
const AppHubCatalogEntry* findCatalogEntry(const char* id) {
ensureCatalogLoaded();
for (const auto& entry : catalog) {
if (entry.id == id) {
return &entry;
}
}
return nullptr;
}
// endregion
// region Apps
void copyString(char* target, size_t targetSize, const char* source) {
strncpy(target, source, targetSize - 1);
target[targetSize - 1] = '\0';
}
/** Copies the installed packages, as app_manager_for_each_package() holds the app manager's lock */
std::vector<InstalledPackage> getInstalledPackages() {
std::vector<InstalledPackage> packages;
app_manager_for_each_package([](const AppPackage* package, void* context) {
auto& packages = *static_cast<std::vector<InstalledPackage>*>(context);
InstalledPackage installed {};
installed.package = package->package;
if (package->app_id_count > 0) {
copyString(installed.first_app_id, sizeof(installed.first_app_id), package->app_ids[0]);
}
packages.push_back(installed);
}, &packages);
return packages;
}
/** Requires the global lock. Only one refresh and one install or remove can be queued or running at a time. */
const Operation* findPendingOperationOfKind(bool refresh) {
if (operationRunning && (runningOperation.type == APP_HUB_OPERATION_REFRESH) == refresh) {
return &runningOperation;
}
for (const auto& operation : queue) {
if ((operation.type == APP_HUB_OPERATION_REFRESH) == refresh) {
return &operation;
}
}
return nullptr;
}
/** Requires the global lock */
const Operation* findPendingOperation(const char* id) {
if (operationRunning && runningOperation.type != APP_HUB_OPERATION_REFRESH && strcmp(runningOperation.app_id, id) == 0) {
return &runningOperation;
}
for (const auto& operation : queue) {
if (operation.type != APP_HUB_OPERATION_REFRESH && strcmp(operation.app_id, id) == 0) {
return &operation;
}
}
return nullptr;
}
/** Requires the global lock */
AppList collectApps(const std::vector<InstalledPackage>& installedPackages) {
ensureCatalogLoaded();
AppList apps;
for (const auto& entry : catalog) {
if (!app_hub_catalog_is_compatible(entry)) {
continue;
}
AppHubApp app {};
copyString(app.id, sizeof(app.id), entry.id.c_str());
copyString(app.name, sizeof(app.name), entry.name.c_str());
copyString(app.version_name, sizeof(app.version_name), entry.version_name.c_str());
app.version_code = static_cast<uint64_t>(entry.version_code);
app.in_catalog = true;
apps.push_back(app);
}
for (const auto& installed : installedPackages) {
auto existing = std::ranges::find_if(apps, [&installed](const AppHubApp& app) {
return strcmp(app.id, installed.package.id) == 0;
});
AppHubApp* app;
if (existing != apps.end()) {
app = &*existing;
} else {
app = &apps.emplace_back();
copyString(app->id, sizeof(app->id), installed.package.id);
AppManifest manifest;
const bool hasName = installed.first_app_id[0] != '\0' && app_manager_find_manifest(installed.first_app_id, &manifest) == ERROR_NONE;
copyString(app->name, sizeof(app->name), hasName ? manifest.name : installed.package.id);
}
app->installed = true;
copyString(app->installed_version_name, sizeof(app->installed_version_name), installed.package.version_name);
app->installed_version_code = installed.package.version_code;
app->update_available = app->in_catalog && app->installed_version_code < app->version_code;
}
for (auto& app : apps) {
const Operation* pending = findPendingOperation(app.id);
app.operation_pending = pending != nullptr;
if (pending != nullptr) {
app.pending_operation = pending->type;
}
}
return apps;
}
bool containsIgnoringCase(const char* text, const char* part) {
const std::string_view textView(text);
const std::string_view partView(part);
return std::ranges::search(textView, partView, [](char left, char right) {
return std::tolower(static_cast<unsigned char>(left)) == std::tolower(static_cast<unsigned char>(right));
}).begin() != textView.end() || partView.empty();
}
bool lessIgnoringCase(const AppHubApp& left, const AppHubApp& right) {
return std::ranges::lexicographical_compare(std::string_view(left.name), std::string_view(right.name), [](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) < std::tolower(static_cast<unsigned char>(b));
});
}
// endregion
// region Operations
error_t download(const char* url, const char* targetPath) {
HttpDownloadSubscription sub {};
if (http_download_subscribe(&sub, &workerEvents) != ERROR_NONE) {
return ERROR_RESOURCE;
}
if (http_download_start(url, APP_HUB_CERTIFICATE_PATH, targetPath, &sub) != ERROR_NONE) {
http_download_unsubscribe(&sub);
return ERROR_RESOURCE;
}
while (true) {
uint32_t flags = 0;
task_event_group_wait(&workerEvents, sub.bit | stopBit, false, &flags, portMAX_DELAY);
if (flags & stopBit) {
http_download_cancel(&sub);
http_download_unsubscribe(&sub);
return ERROR_INVALID_STATE;
}
HttpDownloadEvent event {};
if (http_download_poll(&sub, &event) == ERROR_NONE) {
http_download_unsubscribe(&sub);
if (event.type != HTTP_DOWNLOAD_EVENT_SUCCESS) {
LOG_E(TAG, "Download of %s failed (status %d)", url, event.status_code);
return ERROR_RESOURCE;
}
return ERROR_NONE;
}
}
}
error_t ensureDirectory(const char* path) {
const error_t error = directory_make(path, true);
return (error == ERROR_ALREADY_EXISTS) ? ERROR_NONE : error;
}
error_t executeRefresh() {
char directory[FILE_MAX_PATH_STRING_LENGTH];
char downloadPath[FILE_MAX_PATH_STRING_LENGTH];
char catalogPath[FILE_MAX_PATH_STRING_LENGTH];
if (service_paths_get_user_data_directory(APP_HUB_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE ||
!getCatalogPath(CATALOG_DOWNLOAD_FILE_NAME, downloadPath, sizeof(downloadPath)) ||
!getCatalogPath(CATALOG_FILE_NAME, catalogPath, sizeof(catalogPath))) {
return ERROR_NOT_FOUND;
}
error_t error = ensureDirectory(directory);
if (error != ERROR_NONE) {
return error;
}
error = download(app_hub_catalog_get_url().c_str(), downloadPath);
if (error != ERROR_NONE) {
file_remove(downloadPath);
return error;
}
AppHubCatalog downloaded;
if (!app_hub_catalog_parse(downloadPath, downloaded)) {
file_remove(downloadPath);
return ERROR_INVALID_ARGUMENT;
}
error = file_move(downloadPath, catalogPath, true);
if (error != ERROR_NONE) {
return error;
}
lock();
catalog = std::move(downloaded);
catalogLoaded = true;
unlock();
return ERROR_NONE;
}
error_t executeInstall(const char* id) {
lock();
const AppHubCatalogEntry* entry = findCatalogEntry(id);
const bool compatible = entry != nullptr && app_hub_catalog_is_compatible(*entry);
const std::string file = (entry != nullptr) ? entry->file : "";
unlock();
if (entry == nullptr) {
return ERROR_NOT_FOUND;
}
if (!compatible) {
return ERROR_NOT_SUPPORTED;
}
char tempDirectory[FILE_MAX_PATH_STRING_LENGTH];
error_t error = paths_get_temp_path(tempDirectory, sizeof(tempDirectory));
if (error != ERROR_NONE) {
return error;
}
error = ensureDirectory(tempDirectory);
if (error != ERROR_NONE) {
return error;
}
const auto separator = file.find_last_of('/');
const std::string fileName = (separator == std::string::npos) ? file : file.substr(separator + 1);
const std::string tempPath = std::string(tempDirectory) + "/" + fileName;
error = download(app_hub_catalog_get_download_url(file).c_str(), tempPath.c_str());
if (error == ERROR_NONE) {
error = app_install(tempPath.c_str());
if (error != ERROR_NONE) {
LOG_E(TAG, "Install of %s failed: %s", id, error_to_string(error));
}
}
if (path_exists(tempPath.c_str()) && file_remove(tempPath.c_str()) != ERROR_NONE) {
LOG_W(TAG, "Failed to remove %s", tempPath.c_str());
}
return error;
}
error_t execute(const Operation& operation) {
switch (operation.type) {
case APP_HUB_OPERATION_REFRESH:
return executeRefresh();
case APP_HUB_OPERATION_INSTALL:
return executeInstall(operation.app_id);
case APP_HUB_OPERATION_REMOVE:
return app_uninstall(operation.app_id);
}
return ERROR_INVALID_ARGUMENT;
}
int32_t workerMain(void* /*context*/) {
while (true) {
lock();
if (stopping) {
workerRunning = false;
unlock();
return 0;
}
if (queue.empty()) {
unlock();
uint32_t flags = 0;
const error_t waited = task_event_group_wait(&workerEvents, queueBit | stopBit, false, &flags, WORKER_IDLE_TIMEOUT);
if (waited == ERROR_TIMEOUT) {
// An operation can be queued between the wait and taking the lock
lock();
const bool idle = queue.empty();
if (idle) {
workerRunning = false;
}
unlock();
if (idle) {
return 0;
}
}
continue;
}
runningOperation = queue.front();
queue.pop_front();
operationRunning = true;
const Operation operation = runningOperation;
emitEvent(getStartedEventType(operation.type), operation, ERROR_NONE);
unlock();
const error_t result = execute(operation);
lock();
operationRunning = false;
emitEvent(getFinishedEventType(operation.type), operation, result);
unlock();
}
}
/** Requires the global lock */
error_t ensureWorkerRunning() {
if (workerRunning) {
return ERROR_NONE;
}
if (worker != nullptr) {
// The previous worker cleared workerRunning and exits without taking the lock
thread_join(worker, portMAX_DELAY, pdMS_TO_TICKS(10));
thread_free(worker);
}
worker = thread_alloc_full("apphub", WORKER_STACK_SIZE, workerMain, nullptr, -1);
if (worker == nullptr) {
return ERROR_OUT_OF_MEMORY;
}
if (thread_start(worker) != ERROR_NONE) {
thread_free(worker);
worker = nullptr;
return ERROR_RESOURCE;
}
workerRunning = true;
return ERROR_NONE;
}
error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId) {
if (id != nullptr && (id[0] == '\0' || strlen(id) >= sizeof(PackageId))) {
return ERROR_INVALID_ARGUMENT;
}
lock();
if (!serviceRunning) {
unlock();
return ERROR_INVALID_STATE;
}
const Operation* pending = findPendingOperationOfKind(type == APP_HUB_OPERATION_REFRESH);
if (pending != nullptr) {
const bool same = pending->type == type && (id == nullptr || strcmp(pending->app_id, id) == 0);
if (same && operationId != nullptr) {
*operationId = pending->id;
}
unlock();
return same ? ERROR_NONE : ERROR_RESOURCE_BUSY;
}
Operation operation {};
operation.id = nextOperationId++;
operation.type = type;
if (id != nullptr) {
strcpy(operation.app_id, id);
}
const error_t error = ensureWorkerRunning();
if (error == ERROR_NONE) {
queue.push_back(operation);
task_event_group_signal(&workerEvents, queueBit);
if (operationId != nullptr) {
*operationId = operation.id;
}
}
unlock();
return error;
}
// endregion
// region Service
void* createService(const ServiceManifest* /*manifest*/) {
return nullptr;
}
void destroyService(const ServiceManifest* /*manifest*/, void* /*data*/) {}
error_t onStart(ServiceInstance* /*instance*/, void* /*data*/) {
task_event_group_construct(&workerEvents);
if (task_event_group_claim_bit(&workerEvents, &queueBit) != ERROR_NONE ||
task_event_group_claim_bit(&workerEvents, &stopBit) != ERROR_NONE) {
task_event_group_destruct(&workerEvents);
return ERROR_RESOURCE;
}
lock();
serviceRunning = true;
stopping = false;
unlock();
return ERROR_NONE;
}
void onStop(ServiceInstance* /*instance*/, void* /*data*/) {
lock();
serviceRunning = false;
stopping = true;
queue.clear();
Thread* stoppingWorker = worker;
worker = nullptr;
unlock();
if (stoppingWorker != nullptr) {
task_event_group_signal(&workerEvents, stopBit);
thread_join(stoppingWorker, portMAX_DELAY, pdMS_TO_TICKS(10));
thread_free(stoppingWorker);
}
lock();
workerRunning = false;
unlock();
task_event_group_destruct(&workerEvents);
}
// endregion
} // namespace
const ServiceManifest app_hub_service_manifest = {
.id = APP_HUB_SERVICE_ID,
.create_service = createService,
.destroy_service = destroyService,
.on_start = onStart,
.on_stop = onStop,
};
bool app_hub_is_worker_running() {
lock();
const bool running = workerRunning;
unlock();
return running;
}
extern "C" {
// region Apps
error_t app_hub_search(const char* query, AppHubAppVisitorFn visitor, void* context) {
const auto installed = getInstalledPackages();
lock();
AppList apps = collectApps(installed);
std::ranges::sort(apps, lessIgnoringCase);
for (const auto& app : apps) {
if (query != nullptr && !containsIgnoringCase(app.name, query) && !containsIgnoringCase(app.id, query)) {
continue;
}
if (!visitor(&app, context)) {
break;
}
}
unlock();
return ERROR_NONE;
}
error_t app_hub_get_app(const char* id, AppHubApp* app) {
const auto installed = getInstalledPackages();
lock();
const AppList apps = collectApps(installed);
unlock();
const auto found = std::ranges::find_if(apps, [id](const AppHubApp& candidate) {
return strcmp(candidate.id, id) == 0;
});
if (found == apps.end()) {
return ERROR_NOT_FOUND;
}
*app = *found;
return ERROR_NONE;
}
error_t app_hub_get_description(const char* id, char* description, size_t description_size) {
lock();
const AppHubCatalogEntry* entry = findCatalogEntry(id);
error_t error = ERROR_NONE;
if (entry == nullptr) {
error = ERROR_NOT_FOUND;
} else if (entry->description.size() >= description_size) {
error = ERROR_BUFFER_OVERFLOW;
} else {
strcpy(description, entry->description.c_str());
}
unlock();
return error;
}
// endregion
// region Operations
error_t app_hub_refresh(uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_REFRESH, nullptr, operation_id);
}
error_t app_hub_install(const char* id, uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_INSTALL, id, operation_id);
}
error_t app_hub_remove(const char* id, uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_REMOVE, id, operation_id);
}
// endregion
// region Events
error_t app_hub_event_subscribe(AppHubEventSubscription* 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;
}
lock();
for (auto* existing = subscriptions; existing != nullptr; existing = existing->internal.next) {
if (existing == sub) {
unlock();
task_event_group_release_bit(event_group, bit);
return ERROR_INVALID_STATE;
}
}
sub->bit = bit;
sub->internal.event_group = event_group;
sub->internal.head = 0;
sub->internal.count = 0;
sub->internal.next = subscriptions;
subscriptions = sub;
unlock();
return ERROR_NONE;
}
error_t app_hub_event_unsubscribe(AppHubEventSubscription* sub) {
error_t result = ERROR_NOT_FOUND;
lock();
for (auto** link = &subscriptions; *link != nullptr; link = &(*link)->internal.next) {
if (*link == sub) {
*link = sub->internal.next;
result = ERROR_NONE;
break;
}
}
unlock();
if (result == ERROR_NONE) {
task_event_group_release_bit(sub->internal.event_group, sub->bit);
}
return result;
}
error_t app_hub_event_await(AppHubEventSubscription* sub, TickType_t timeout) {
const TickType_t start = get_ticks();
while (true) {
lock();
const bool hasEvent = sub->internal.count > 0;
TaskEventGroup* eventGroup = sub->internal.event_group;
unlock();
if (hasEvent) {
return ERROR_NONE;
}
const TickType_t elapsed = get_ticks() - start;
if (elapsed >= timeout) {
return ERROR_TIMEOUT;
}
// The bit can be left over from an event that was already polled, so the queue is checked again
task_event_group_wait(eventGroup, sub->bit, false, nullptr, timeout - elapsed);
}
}
error_t app_hub_event_poll(AppHubEventSubscription* sub, AppHubEvent* event) {
lock();
const bool hasEvent = sub->internal.count > 0;
if (hasEvent) {
*event = sub->internal.queue[sub->internal.head];
sub->internal.head = (sub->internal.head + 1) % APP_HUB_EVENT_QUEUE_CAPACITY;
sub->internal.count--;
}
unlock();
return hasEvent ? ERROR_NONE : ERROR_TIMEOUT;
}
// endregion
}

View File

@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0
#include <app_hub/app_hub.h>
#include <app_hub/app_hub_service.h>
#include <app_hub/module.h>
#include <service/manager.h>
#include <tactility/error.h>
#include <tactility/module.h>
extern "C" {
static error_t start() {
return service_manager_add(&app_hub_service_manifest, /*auto_start=*/true);
}
static error_t stop() {
const error_t error = service_manager_stop(app_hub_service_manifest.id);
if (error != ERROR_NONE && error != ERROR_NOT_FOUND) {
return error;
}
return service_manager_remove(app_hub_service_manifest.id);
}
static const ModuleSymbol app_hub_module_symbols[] = {
DEFINE_MODULE_SYMBOL(app_hub_search),
DEFINE_MODULE_SYMBOL(app_hub_get_app),
DEFINE_MODULE_SYMBOL(app_hub_get_description),
DEFINE_MODULE_SYMBOL(app_hub_refresh),
DEFINE_MODULE_SYMBOL(app_hub_install),
DEFINE_MODULE_SYMBOL(app_hub_remove),
DEFINE_MODULE_SYMBOL(app_hub_event_subscribe),
DEFINE_MODULE_SYMBOL(app_hub_event_unsubscribe),
DEFINE_MODULE_SYMBOL(app_hub_event_await),
DEFINE_MODULE_SYMBOL(app_hub_event_poll),
MODULE_SYMBOL_TERMINATOR
};
Module app_hub_module = {
.name = "app-hub",
.start = start,
.stop = stop,
.drivers = nullptr,
.symbols = app_hub_module_symbols,
.internal = nullptr,
};
}

View File

@ -0,0 +1,23 @@
project(AppHubModuleTests)
enable_language(C CXX ASM)
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
add_executable(AppHubModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
target_include_directories(AppHubModuleTests PRIVATE
${DOCTESTINC}
${PROJECT_SOURCE_DIR}/../private
)
add_test(NAME AppHubModuleTests COMMAND AppHubModuleTests)
target_link_libraries(AppHubModuleTests PUBLIC
TactilityKernel
TactilityKernelCpp
platform-posix
app-module
app-posix-module
service-module
app-hub-module
)

View File

@ -0,0 +1,256 @@
#include "doctest.h"
#include <app_hub/app_hub.h>
#include <app_hub/app_hub_service.h>
#include <app/manager.h>
#include <service/manager.h>
#include <service/paths.h>
#include <tactility/delay.h>
#include <tactility/filesystem/file_system.h>
#include <tactility/paths.h>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <string>
#include <vector>
namespace {
const char* TEST_ROOT = "/tmp/tactility_app_hub_test";
const char* CATALOG = R"({
"apps": [
{
"appId": "beta", "appVersionName": "1.0", "appVersionCode": 1, "appName": "beta tool",
"appDescription": "The beta tool", "targetSdk": "0.7.0", "file": "beta.app", "targetPlatforms": []
},
{
"appId": "alpha", "appVersionName": "2.0", "appVersionCode": 2, "appName": "Alpha App",
"appDescription": "The alpha app", "targetSdk": "0.7.0", "file": "alpha.app", "targetPlatforms": []
},
{
"appId": "other", "appVersionName": "1.0", "appVersionCode": 1, "appName": "Other device",
"appDescription": "", "targetSdk": "0.7.0", "file": "other.app", "targetPlatforms": [],
"requiresDeviceId": ["no-such-device"]
}
]
})";
error_t fakeMount(void*) { return ERROR_NONE; }
error_t fakeUnmount(void*) { return ERROR_NONE; }
bool fakeIsMounted(void*) { return false; }
error_t fakeGetPath(void* data, char* out_path, size_t out_path_size) {
const char* path = static_cast<const char*>(data);
if (strlen(path) + 1 > out_path_size) {
return ERROR_BUFFER_OVERFLOW;
}
strcpy(out_path, path);
return ERROR_NONE;
}
const FileSystemApi fakeDataApi = {
.mount = fakeMount,
.unmount = fakeUnmount,
.is_mounted = fakeIsMounted,
.get_path = fakeGetPath,
};
/** The service caches the catalog once it's loaded, so all tests share one data directory and catalog */
void ensureFixture() {
static bool created = false;
if (created) {
return;
}
created = true;
std::filesystem::remove_all(TEST_ROOT);
REQUIRE_NE(file_system_add("data", &fakeDataApi, const_cast<char*>(TEST_ROOT)), nullptr);
char path[FILE_MAX_PATH_STRING_LENGTH];
REQUIRE_EQ(service_paths_get_user_data_path(APP_HUB_SERVICE_ID, "apps.json", path, sizeof(path)), ERROR_NONE);
std::filesystem::create_directories(std::filesystem::path(path).parent_path());
FILE* file = fopen(path, "w");
REQUIRE_NE(file, nullptr);
fputs(CATALOG, file);
fclose(file);
REQUIRE_EQ(service_manager_add(&app_hub_service_manifest, true), ERROR_NONE);
}
std::vector<AppHubApp> search(const char* query) {
std::vector<AppHubApp> apps;
REQUIRE_EQ(app_hub_search(query, [](const AppHubApp* app, void* context) {
static_cast<std::vector<AppHubApp>*>(context)->push_back(*app);
return true;
}, &apps), ERROR_NONE);
return apps;
}
/** Waits for the next event of the subscription */
AppHubEvent waitForEvent(AppHubEventSubscription& sub) {
AppHubEvent event {};
REQUIRE_EQ(app_hub_event_await(&sub, pdMS_TO_TICKS(5000)), ERROR_NONE);
REQUIRE_EQ(app_hub_event_poll(&sub, &event), ERROR_NONE);
return event;
}
} // namespace
TEST_CASE("app_hub_search lists compatible catalog apps sorted by name") {
ensureFixture();
const auto apps = search(nullptr);
REQUIRE_EQ(apps.size(), 2);
CHECK_EQ(std::string(apps[0].id), "alpha");
CHECK_EQ(std::string(apps[0].name), "Alpha App");
CHECK_EQ(std::string(apps[0].version_name), "2.0");
CHECK_EQ(apps[0].version_code, 2);
CHECK(apps[0].in_catalog);
CHECK_FALSE(apps[0].installed);
CHECK_EQ(std::string(apps[1].id), "beta");
}
TEST_CASE("app_hub_search filters by name and id ignoring case") {
ensureFixture();
auto apps = search("TOOL");
REQUIRE_EQ(apps.size(), 1);
CHECK_EQ(std::string(apps[0].id), "beta");
apps = search("alp");
REQUIRE_EQ(apps.size(), 1);
CHECK_EQ(std::string(apps[0].id), "alpha");
CHECK(search("nothing matches").empty());
CHECK_EQ(search("").size(), 2);
}
TEST_CASE("app_hub_get_description returns the catalog description") {
ensureFixture();
char description[64];
CHECK_EQ(app_hub_get_description("alpha", description, sizeof(description)), ERROR_NONE);
CHECK_EQ(std::string(description), "The alpha app");
CHECK_EQ(app_hub_get_description("alpha", description, 4), ERROR_BUFFER_OVERFLOW);
CHECK_EQ(app_hub_get_description("missing", description, sizeof(description)), ERROR_NOT_FOUND);
}
TEST_CASE("app_hub merges installed packages with the catalog") {
ensureFixture();
PackageManifest alpha {};
strcpy(alpha.id, "alpha");
strcpy(alpha.version_name, "1.0");
alpha.version_code = 1;
PackageManifest sideloaded {};
strcpy(sideloaded.id, "sideloaded");
strcpy(sideloaded.version_name, "3.0");
sideloaded.version_code = 3;
REQUIRE_EQ(app_manager_add_package(&alpha, nullptr, 0), ERROR_NONE);
REQUIRE_EQ(app_manager_add_package(&sideloaded, nullptr, 0), ERROR_NONE);
AppHubApp app {};
REQUIRE_EQ(app_hub_get_app("alpha", &app), ERROR_NONE);
CHECK(app.installed);
CHECK(app.in_catalog);
CHECK(app.update_available);
CHECK_EQ(std::string(app.installed_version_name), "1.0");
REQUIRE_EQ(app_hub_get_app("sideloaded", &app), ERROR_NONE);
CHECK(app.installed);
CHECK_FALSE(app.in_catalog);
CHECK_FALSE(app.update_available);
CHECK_EQ(std::string(app.name), "sideloaded");
CHECK_EQ(search(nullptr).size(), 3);
CHECK_EQ(app_hub_get_app("other", &app), ERROR_NOT_FOUND);
CHECK_EQ(app_manager_remove_package("alpha"), ERROR_NONE);
CHECK_EQ(app_manager_remove_package("sideloaded"), ERROR_NONE);
}
TEST_CASE("app_hub operations report their outcome and the worker stops when idle") {
ensureFixture();
TaskEventGroup group {};
task_event_group_construct(&group);
AppHubEventSubscription sub {};
REQUIRE_EQ(app_hub_event_subscribe(&sub, &group), ERROR_NONE);
uint32_t removeId = 0;
REQUIRE_EQ(app_hub_remove("missing", &removeId), ERROR_NONE);
CHECK(app_hub_is_worker_running());
AppHubEvent event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_REMOVING);
CHECK_EQ(event.operation_id, removeId);
CHECK_EQ(std::string(event.app_id), "missing");
event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_REMOVED);
CHECK_EQ(std::string(event.app_id), "missing");
CHECK_EQ(event.operation_id, removeId);
CHECK_EQ(event.result, ERROR_NOT_FOUND);
uint32_t installId = 0;
REQUIRE_EQ(app_hub_install("unknown", &installId), ERROR_NONE);
CHECK_NE(installId, removeId);
event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_INSTALLING);
CHECK_EQ(std::string(event.app_id), "unknown");
event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_INSTALLED);
CHECK_EQ(std::string(event.app_id), "unknown");
CHECK_EQ(event.operation_id, installId);
CHECK_EQ(event.result, ERROR_NOT_FOUND);
delay_millis(6000);
CHECK_FALSE(app_hub_is_worker_running());
// A new operation starts the worker again
REQUIRE_EQ(app_hub_remove("missing", &removeId), ERROR_NONE);
CHECK(app_hub_is_worker_running());
CHECK_EQ(waitForEvent(sub).type, APP_HUB_EVENT_REMOVING);
event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_REMOVED);
CHECK_EQ(event.operation_id, removeId);
CHECK_EQ(app_hub_event_await(&sub, pdMS_TO_TICKS(10)), ERROR_TIMEOUT);
CHECK_EQ(app_hub_event_unsubscribe(&sub), ERROR_NONE);
task_event_group_destruct(&group);
}
TEST_CASE("app_hub drops newer events while a subscriber's queue is full") {
ensureFixture();
TaskEventGroup group {};
task_event_group_construct(&group);
AppHubEventSubscription polled {};
AppHubEventSubscription unpolled {};
REQUIRE_EQ(app_hub_event_subscribe(&polled, &group), ERROR_NONE);
REQUIRE_EQ(app_hub_event_subscribe(&unpolled, &group), ERROR_NONE);
// Each operation emits 2 events, so the last operation's events don't fit
uint32_t ids[APP_HUB_EVENT_QUEUE_CAPACITY / 2 + 1] = {};
for (auto& id : ids) {
REQUIRE_EQ(app_hub_remove("missing", &id), ERROR_NONE);
CHECK_EQ(waitForEvent(polled).type, APP_HUB_EVENT_REMOVING);
CHECK_EQ(waitForEvent(polled).type, APP_HUB_EVENT_REMOVED);
}
std::vector<uint32_t> received;
AppHubEvent event {};
while (app_hub_event_poll(&unpolled, &event) == ERROR_NONE) {
received.push_back(event.operation_id);
}
REQUIRE_EQ(received.size(), APP_HUB_EVENT_QUEUE_CAPACITY);
CHECK_EQ(received.front(), ids[0]);
CHECK_EQ(received.back(), ids[APP_HUB_EVENT_QUEUE_CAPACITY / 2 - 1]);
CHECK_EQ(app_hub_event_unsubscribe(&unpolled), ERROR_NONE);
CHECK_EQ(app_hub_event_unsubscribe(&polled), ERROR_NONE);
task_event_group_destruct(&group);
}
TEST_CASE("app_hub rejects invalid ids") {
ensureFixture();
CHECK_EQ(app_hub_install("", nullptr), ERROR_INVALID_ARGUMENT);
CHECK_EQ(app_hub_remove("an-id-that-is-far-too-long-for-a-package", nullptr), ERROR_INVALID_ARGUMENT);
}

View File

@ -1,22 +1,15 @@
#include "doctest.h"
#include <Tactility/app/apphub/AppHubEntry.h>
#include <tactility/filesystem/fs.h>
#include <tactility/paths.h>
#include <app_hub/app_hub_catalog.h>
#include <cstdio>
#include <string>
using namespace tt::app::apphub;
#include <vector>
namespace {
std::string writeAppsJson(const char* contents) {
char temp[FILE_MAX_PATH_STRING_LENGTH];
REQUIRE_EQ(paths_get_temp_path(temp, sizeof(temp)), ERROR_NONE);
REQUIRE_EQ(directory_make(temp, true), ERROR_NONE);
const std::string path = std::string(temp) + "/apphub_test_apps.json";
const std::string path = "/tmp/tactility_app_hub_catalog_test.json";
FILE* file = fopen(path.c_str(), "w");
REQUIRE_NE(file, nullptr);
fputs(contents, file);
@ -24,10 +17,10 @@ std::string writeAppsJson(const char* contents) {
return path;
}
AppHubEntry entryWith(std::vector<std::string> requiresDeviceId, int32_t requiresRam) {
AppHubEntry entry {};
entry.requiresDeviceId = std::move(requiresDeviceId);
entry.requiresRam = requiresRam;
AppHubCatalogEntry entryWith(std::vector<std::string> requiresDeviceId, int32_t requiresRam) {
AppHubCatalogEntry entry {};
entry.requires_device_id = std::move(requiresDeviceId);
entry.requires_ram = requiresRam;
return entry;
}
@ -39,30 +32,30 @@ TEST_CASE("app hub: requiresDeviceId and requiresRam are parsed, and optional")
"requiresDeviceId": ["m5stack-tab5", "lilygo-tdeck"], "requiresRam": 2},
{"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"}
]})");
AppHubEntryList entries;
REQUIRE(parseJson(path, entries));
AppHubCatalog entries;
REQUIRE(app_hub_catalog_parse(path.c_str(), entries));
REQUIRE_EQ(entries.size(), 2u);
CHECK_EQ(entries[0].requiresDeviceId, std::vector<std::string> { "m5stack-tab5", "lilygo-tdeck" });
CHECK_EQ(entries[0].requiresRam, 2);
CHECK(entries[1].requiresDeviceId.empty());
CHECK_EQ(entries[1].requiresRam, 0);
CHECK_EQ(entries[0].requires_device_id, std::vector<std::string> { "m5stack-tab5", "lilygo-tdeck" });
CHECK_EQ(entries[0].requires_ram, 2);
CHECK(entries[1].requires_device_id.empty());
CHECK_EQ(entries[1].requires_ram, 0);
remove(path.c_str());
}
TEST_CASE("app hub: reparsing into the same list resets optional requirements") {
AppHubEntryList entries;
AppHubCatalog entries;
const auto restricted = writeAppsJson(R"({"apps": [
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app",
"requiresDeviceId": ["m5stack-tab5"], "requiresRam": 2}
]})");
REQUIRE(parseJson(restricted, entries));
REQUIRE(app_hub_catalog_parse(restricted.c_str(), entries));
const auto unrestricted = writeAppsJson(R"({"apps": [
{"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"}
]})");
REQUIRE(parseJson(unrestricted, entries));
REQUIRE(app_hub_catalog_parse(unrestricted.c_str(), entries));
REQUIRE_EQ(entries.size(), 1u);
CHECK(entries[0].requiresDeviceId.empty());
CHECK_EQ(entries[0].requiresRam, 0);
CHECK(entries[0].requires_device_id.empty());
CHECK_EQ(entries[0].requires_ram, 0);
remove(unrestricted.c_str());
}
@ -71,14 +64,14 @@ TEST_CASE("app hub: a fractional requiresRam rejects the entry") {
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app",
"requiresRam": 1.5}
]})");
AppHubEntryList entries;
CHECK_FALSE(parseJson(path, entries));
AppHubCatalog entries;
CHECK_FALSE(app_hub_catalog_parse(path.c_str(), entries));
remove(path.c_str());
}
TEST_CASE("app hub: isCompatible applies the device and RAM requirements") {
CHECK(isCompatible(entryWith({}, 0)));
CHECK(isCompatible(entryWith({ "other-device", CONFIG_TT_DEVICE_ID }, 1)));
CHECK_FALSE(isCompatible(entryWith({ "other-device" }, 0)));
CHECK_FALSE(isCompatible(entryWith({}, 256)));
CHECK(app_hub_catalog_is_compatible(entryWith({}, 0)));
CHECK(app_hub_catalog_is_compatible(entryWith({ "other-device", CONFIG_TT_DEVICE_ID }, 1)));
CHECK_FALSE(app_hub_catalog_is_compatible(entryWith({ "other-device" }, 0)));
CHECK_FALSE(app_hub_catalog_is_compatible(entryWith({}, 256)));
}

View File

@ -0,0 +1,47 @@
#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

@ -672,9 +672,8 @@ int __wrap_putchar(int c) {
int __wrap_fputc(int c, FILE* stream) {
int fd = targetFdOf(stream);
if (fd >= 0) {
auto ch = static_cast<char>(c);
writeAllTo(fd, &ch, 1);
return c;
const auto ch = static_cast<unsigned char>(c);
return writeAllTo(fd, &ch, 1) == 1 ? ch : EOF;
}
return __real_fputc(c, stream);
}

View File

@ -53,6 +53,7 @@ TT_DYLD_INTERPOSE(__wrap_puts, puts)
TT_DYLD_INTERPOSE(__wrap_fputs, fputs)
TT_DYLD_INTERPOSE(__wrap_putchar, putchar)
TT_DYLD_INTERPOSE(__wrap_fputc, fputc)
TT_DYLD_INTERPOSE(__wrap_fputc, putc)
TT_DYLD_INTERPOSE(__wrap_fwrite, fwrite)
TT_DYLD_INTERPOSE(__wrap_getchar, getchar)
TT_DYLD_INTERPOSE(__wrap_fgetc, fgetc)

View File

@ -234,6 +234,11 @@ int fputc(int c, FILE* stream) {
return __wrap_fputc(c, stream);
}
// libstdc++'s std::cout writes single characters with putc(), and strings with fwrite()
int putc(int c, FILE* stream) {
return __wrap_fputc(c, stream);
}
size_t fwrite(const void* data, size_t size, size_t count, FILE* stream) {
return __wrap_fwrite(data, size, count, stream);
}

View File

@ -158,6 +158,7 @@ shared_symbol_code_point_names = [
"lightbulb",
"language", # Globe
"lists", # Chat app toolbar
"list_alt", # Settings apps: opens a selection dialog
"mail",
"menu",
"memory", # System Info
@ -242,6 +243,8 @@ statusbar_symbol_code_point_names = [
"usb",
# Mesh networking
"hub",
# App Hub installing an app
"download",
]
# Get more from https://fonts.google.com/icons?icon.set=Material+Symbols&icon.style=Rounded

View File

@ -9,7 +9,7 @@ extern "C" {
#endif
/** Changes whenever the icon TTF subset, its codepoints file or the icon name lists change */
#define LVGL_ICON_FONT_VERSION 0xDDEFCE33u
#define LVGL_ICON_FONT_VERSION 0xE8678DFBu
// Icon names per icon font, resolved through the .codepoints file next to the TTF
extern const char* const lvgl_icon_shared_names[];

View File

@ -31,6 +31,7 @@
#define LVGL_ICON_SHARED_LIGHTBULB "\xEE\xA4\x8F"
#define LVGL_ICON_SHARED_LANGUAGE "\xEE\xA8\x87"
#define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9"
#define LVGL_ICON_SHARED_LIST_ALT "\xEE\x83\xAE"
#define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99"
#define LVGL_ICON_SHARED_MENU "\xEE\x97\x92"
#define LVGL_ICON_SHARED_MEMORY "\xEE\x8C\xA2"

View File

@ -29,3 +29,4 @@
#define LVGL_ICON_STATUSBAR_BLUETOOTH_DISABLED "\xEE\x86\xA9"
#define LVGL_ICON_STATUSBAR_USB "\xEE\x87\xA0"
#define LVGL_ICON_STATUSBAR_HUB "\xEE\xA7\xB4"
#define LVGL_ICON_STATUSBAR_DOWNLOAD "\xEF\x82\x90"

View File

@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define LVGL_STATUSBAR_ICON_LIMIT 16
#define LVGL_STATUSBAR_IMAGE_MAX_LENGTH 63
/**
* @brief Set the function that opens the system menu, called when the statusbar's icons are clicked or the statusbar is swiped down.
* @param[in] callback the function, or NULL to do nothing
*/
void lvgl_statusbar_set_menu_callback(void (*callback)(void));
/** @brief Set whether the time is shown in the 24-hour format. The default is true. */
void lvgl_statusbar_set_time_format_24h(bool format_24h);
/**
* @brief Create a statusbar widget.
* @warning Needs to be called with the LVGL lock.
*/
lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent);
/**
* @brief Add an icon to all statusbars. Does not need the LVGL lock.
* @param[in] image the icon (e.g. a statusbar icon font symbol) or NULL for none, at most LVGL_STATUSBAR_IMAGE_MAX_LENGTH bytes
* @param[in] visible whether the icon is shown
* @return the icon id, or -1 when LVGL_STATUSBAR_ICON_LIMIT icons were added already. The other icon functions ignore id -1.
*/
int8_t lvgl_statusbar_icon_add(const char* image, bool visible);
/** @brief Remove an icon. Does not need the LVGL lock. Does nothing for id -1. */
void lvgl_statusbar_icon_remove(int8_t id);
/**
* @brief Update an icon's image. Does not need the LVGL lock. Does nothing for id -1.
* @param[in] image the icon or NULL for none, at most LVGL_STATUSBAR_IMAGE_MAX_LENGTH bytes
*/
void lvgl_statusbar_icon_set_image(int8_t id, const char* image);
/** @brief Update an icon's visibility. Does not need the LVGL lock. Does nothing for id -1. */
void lvgl_statusbar_icon_set_visibility(int8_t id, bool visible);
/** @return the height of the statusbar in the split layout */
int32_t lvgl_statusbar_get_height(void);
/**
* @brief Switch between the split layout (a bar on top) and the side layout (a vertical strip on the left).
* Circular displays always use the split layout. The caller places the statusbar in its parent.
* @warning Needs to be called with the LVGL lock.
*/
void lvgl_statusbar_set_side(lv_obj_t* statusbar, bool side);
/**
* @return the container for the action buttons of apps in the side layout, or NULL on circular displays. It is hidden in the split layout.
* @warning Needs to be called with the LVGL lock.
*/
lv_obj_t* lvgl_statusbar_get_action_container(lv_obj_t* statusbar);
#ifdef __cplusplus
}
#endif

View File

@ -33,6 +33,7 @@ const char* const lvgl_icon_shared_names[] = {
"lightbulb",
"language",
"lists",
"list_alt",
"mail",
"menu",
"memory",
@ -107,6 +108,7 @@ const char* const lvgl_icon_statusbar_names[] = {
"bluetooth_disabled",
"usb",
"hub",
"download",
};
const size_t lvgl_icon_statusbar_name_count = sizeof(lvgl_icon_statusbar_names) / sizeof(lvgl_icon_statusbar_names[0]);

View File

@ -23,6 +23,7 @@
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/page_indicator.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/statusbar.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/module.h>
@ -143,6 +144,16 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_spinner_create),
// lvgl_toolbar
DEFINE_MODULE_SYMBOL(lvgl_toolbar_configure),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_set_menu_callback),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_set_time_format_24h),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_create),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_icon_add),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_icon_remove),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_icon_set_image),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_icon_set_visibility),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_get_height),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_set_side),
DEFINE_MODULE_SYMBOL(lvgl_statusbar_get_action_container),
DEFINE_MODULE_SYMBOL(lvgl_toolbar_create),
DEFINE_MODULE_SYMBOL(lvgl_toolbar_set_title),
DEFINE_MODULE_SYMBOL(lvgl_toolbar_set_nav_action),

View File

@ -1,14 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
#define LV_USE_PRIVATE_API 1 // For actual lv_obj_t declaration
#include <Tactility/PubSub.h>
#include <Tactility/RecursiveMutex.h>
#include <Tactility/Timer.h>
#include <Tactility/lvgl/Statusbar.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/SystemMenu.h>
#include <Tactility/settings/Time.h>
#include <lvgl/widgets/statusbar.h>
#include <tactility/check.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/concurrent/recursive_mutex.h>
#include <tactility/concurrent/timer.h>
#include <tactility/log.h>
#include <tactility/system_event.h>
#include <tactility/time.h>
@ -22,45 +20,56 @@
#include <lvgl/widgets/icon_button.h>
#include <algorithm>
#include <memory>
#include <cstring>
#include <ctime>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
namespace tt::lvgl {
constexpr auto* TAG = "statusbar";
static void onUpdateTime();
constexpr auto* TAG = "lvgl_statusbar";
constexpr auto STATUSBAR_ICON_LIMIT = LVGL_STATUSBAR_ICON_LIMIT;
struct StatusbarIcon {
std::string image;
char image[LVGL_STATUSBAR_IMAGE_MAX_LENGTH + 1] = {};
bool visible = false;
bool claimed = false;
// When the icon was added: the side strip drops the newest icons that don't fit
uint32_t added_order = 0;
};
struct Statusbar;
struct StatusbarData {
RecursiveMutex mutex;
std::shared_ptr<PubSub<void*>> pubsub = std::make_shared<PubSub<void*>>();
RecursiveMutex mutex {};
StatusbarIcon icons[STATUSBAR_ICON_LIMIT] = {};
Timer* time_update_timer = new Timer(Timer::Type::Once, 200 / portTICK_PERIOD_MS, [] { onUpdateTime(); });
Timer* time_update_timer = nullptr;
uint8_t time_hours = 0;
uint8_t time_minutes = 0;
bool time_set = false;
bool time_format_24h = true;
uint32_t next_added_order = 0;
void (*menu_callback)() = nullptr;
// The created statusbars, which are updated when the icons or the time change
Mutex instances_mutex {};
Statusbar* instances = nullptr;
StatusbarData() {
recursive_mutex_construct(&mutex);
mutex_construct(&instances_mutex);
}
~StatusbarData() {
mutex_destruct(&instances_mutex);
recursive_mutex_destruct(&mutex);
}
};
static StatusbarData statusbar_data;
typedef struct {
struct Statusbar {
lv_obj_t obj;
lv_obj_t* time;
lv_obj_t* icons[STATUSBAR_ICON_LIMIT];
lv_obj_t* battery_icon;
PubSub<void*>::SubscriptionHandle pubsub_subscription;
// The next created statusbar
Statusbar* next;
// The parent of the icons, which opens the system menu
lv_obj_t* icon_row;
// Pushes the action container to the bottom in the side layout. Null on circular displays.
@ -70,7 +79,7 @@ typedef struct {
// The horizontal padding of the split layout
int32_t split_pad_hor;
bool side;
} Statusbar;
};
static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj);
static void statusbar_destructor(const lv_obj_class_t* class_p, lv_obj_t* obj);
@ -89,26 +98,43 @@ static TickType_t getNextUpdateTime() {
return pdMS_TO_TICKS(seconds_to_wait * 1000U);
}
static void onUpdateTime() {
static void notify_instances() {
mutex_lock(&statusbar_data.instances_mutex);
for (auto* statusbar = statusbar_data.instances; statusbar != nullptr; statusbar = statusbar->next) {
LOG_D(TAG, "Update event");
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
update_main(statusbar);
lv_obj_invalidate(&statusbar->obj);
lvgl_unlock();
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "Statusbar");
}
}
mutex_unlock(&statusbar_data.instances_mutex);
}
static void on_update_time(void* /*context*/) {
time_t now = ::time(nullptr);
tm* tm_struct = localtime(&now);
if (statusbar_data.mutex.lock(100 / portTICK_PERIOD_MS)) {
if (recursive_mutex_try_lock(&statusbar_data.mutex, 100 / portTICK_PERIOD_MS)) {
bool changed = false;
if (tm_struct->tm_year >= (2025 - 1900)) {
statusbar_data.time_hours = tm_struct->tm_hour;
statusbar_data.time_minutes = tm_struct->tm_min;
statusbar_data.time_set = true;
changed = true;
// Reschedule
statusbar_data.time_update_timer->reset(getNextUpdateTime());
// Notify widget
statusbar_data.pubsub->publish(nullptr);
timer_reset_with_interval(statusbar_data.time_update_timer, getNextUpdateTime());
} else {
statusbar_data.time_update_timer->reset(pdMS_TO_TICKS(60000U));
timer_reset_with_interval(statusbar_data.time_update_timer, pdMS_TO_TICKS(60000U));
}
statusbar_data.mutex.unlock();
recursive_mutex_unlock(&statusbar_data.mutex);
if (changed) {
notify_instances();
}
}
}
@ -127,22 +153,10 @@ static lv_obj_class_t statusbar_class = {
.theme_inheritable = false
};
static void statusbar_pubsub_event(Statusbar* statusbar) {
LOG_D(TAG, "Update event");
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
update_main(statusbar);
lv_obj_invalidate(&statusbar->obj);
lvgl_unlock();
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "Statusbar");
}
}
static void onTimeChanged(struct SystemEvent* /*event*/, void* /*context*/) {
if (statusbar_data.mutex.lock()) {
statusbar_data.time_update_timer->reset(5);
statusbar_data.mutex.unlock();
}
recursive_mutex_lock(&statusbar_data.mutex);
timer_reset_with_interval(statusbar_data.time_update_timer, 5);
recursive_mutex_unlock(&statusbar_data.mutex);
}
static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
@ -151,33 +165,53 @@ static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj)
lv_obj_set_scrollable(obj, false);
LV_TRACE_OBJ_CREATE("finished");
// Deliberately does NOT subscribe to statusbar_data.pubsub here - that happens at the end of
// statusbar_create(), once statusbar->icons[] is actually populated. Subscribing this early
// would let a concurrent statusbar_icon_add()/_remove()/_set_image()/_set_visibility() call
// from another task publish and run update_icon() on a still-null icons[] slot before this
// Deliberately does NOT register the instance here - that happens at the end of
// lvgl_statusbar_create(), once statusbar->icons[] is actually populated. Registering this early
// would let a concurrent lvgl_statusbar_icon_add()/_remove()/_set_image()/_set_visibility() call
// from another task run update_icon() on a still-null icons[] slot before this
// instance's own creation loop below has had a chance to fill it in.
if (!statusbar_data.time_update_timer->isRunning()) {
statusbar_data.time_update_timer->start();
recursive_mutex_lock(&statusbar_data.mutex);
if (statusbar_data.time_update_timer == nullptr) {
statusbar_data.time_update_timer = timer_alloc(TIMER_TYPE_ONCE, 200 / portTICK_PERIOD_MS, on_update_time, nullptr);
check(statusbar_data.time_update_timer != nullptr);
timer_start(statusbar_data.time_update_timer);
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, onTimeChanged, nullptr);
}
recursive_mutex_unlock(&statusbar_data.mutex);
}
static void statusbar_destructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
auto* statusbar = (Statusbar*)obj;
statusbar_data.pubsub->unsubscribe(statusbar->pubsub_subscription);
mutex_lock(&statusbar_data.instances_mutex);
for (auto** link = &statusbar_data.instances; *link != nullptr; link = &(*link)->next) {
if (*link == statusbar) {
*link = statusbar->next;
break;
}
}
mutex_unlock(&statusbar_data.instances_mutex);
}
static void update_icon(lv_obj_t* image, const StatusbarIcon* icon) {
if (!icon->image.empty() && icon->visible && icon->claimed) {
lv_image_set_src(image, icon->image.c_str());
if (icon->image[0] != '\0' && icon->visible && icon->claimed) {
lv_image_set_src(image, icon->image);
lv_obj_set_hidden(image, false);
} else {
lv_obj_set_hidden(image, true);
}
}
static void show_menu() {
recursive_mutex_lock(&statusbar_data.mutex);
auto* callback = statusbar_data.menu_callback;
recursive_mutex_unlock(&statusbar_data.mutex);
if (callback != nullptr) {
callback();
}
}
static void onIconRowClicked(lv_event_t* /*event*/) {
systemMenuShow();
show_menu();
}
static bool is_inside(lv_obj_t* obj, lv_obj_t* ancestor) {
@ -216,12 +250,27 @@ static void onStatusbarGesture(lv_event_t* /*event*/) {
if (indev != nullptr && lv_indev_get_gesture_dir(indev) == LV_DIR_BOTTOM) {
// The release must not click the pressed widget
lv_indev_wait_release(indev);
systemMenuShow();
show_menu();
}
}
lv_obj_t* statusbar_create(lv_obj_t* parent) {
statusbar_class.height_def = statusbar_get_height();
extern "C" {
void lvgl_statusbar_set_menu_callback(void (*callback)()) {
recursive_mutex_lock(&statusbar_data.mutex);
statusbar_data.menu_callback = callback;
recursive_mutex_unlock(&statusbar_data.mutex);
}
void lvgl_statusbar_set_time_format_24h(bool format_24h) {
recursive_mutex_lock(&statusbar_data.mutex);
statusbar_data.time_format_24h = format_24h;
recursive_mutex_unlock(&statusbar_data.mutex);
notify_instances();
}
lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent) {
statusbar_class.height_def = lvgl_statusbar_get_height();
lv_obj_t* obj = lv_obj_class_create_obj(&statusbar_class, parent);
lv_obj_class_init_obj(obj);
@ -264,7 +313,7 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
if (shape.shape == DISPLAY_SHAPE_ROUNDED) {
// Moves the content inward from the rounded corners, measured at the top of the icons
const int32_t content_top = (statusbar_get_height() - static_cast<int32_t>(icon_size)) / 2;
const int32_t content_top = (lvgl_statusbar_get_height() - static_cast<int32_t>(icon_size)) / 2;
lv_obj_set_style_pad_hor(obj, 2 + lvgl_display_get_row_inset(display, content_top), LV_STATE_DEFAULT);
}
@ -313,7 +362,7 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
}
statusbar_data.mutex.lock(MAX_TICKS);
recursive_mutex_lock(&statusbar_data.mutex);
for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
auto* image = lv_image_create(icon_row);
lv_obj_set_size(image, icon_size, icon_size); // regular padding doesn't work
@ -324,14 +373,14 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
update_icon(image, &(statusbar_data.icons[i]));
}
// Only now is statusbar->icons[] fully populated - see statusbar_constructor()'s comment for
// why the subscription can't be registered any earlier. Subscribing before unlocking (rather
// than after) closes a narrow window where a concurrent statusbar_icon_add()/_remove()/
// _set_image()/_set_visibility() call could publish - and be missed by this instance - after
// the icons are populated but before it's subscribed.
statusbar->pubsub_subscription = statusbar_data.pubsub->subscribe([statusbar](auto) {
statusbar_pubsub_event(statusbar);
});
statusbar_data.mutex.unlock();
// why the instance can't be registered any earlier. Registering before unlocking (rather
// than after) closes a narrow window where a concurrent icon change could be missed by
// this instance after the icons are populated but before it's registered.
mutex_lock(&statusbar_data.instances_mutex);
statusbar->next = statusbar_data.instances;
statusbar_data.instances = statusbar;
mutex_unlock(&statusbar_data.instances_mutex);
recursive_mutex_unlock(&statusbar_data.mutex);
// Only the icon row is focused, it joins the group by itself
if (lv_obj_get_group(obj) != nullptr) {
@ -341,9 +390,11 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) {
return obj;
}
}
static void update_time(Statusbar* statusbar) {
if (statusbar_data.time_set) {
bool format24 = settings::isTimeFormat24Hour();
bool format24 = statusbar_data.time_format_24h;
int hours = format24 ? statusbar_data.time_hours : statusbar_data.time_hours % 12;
lv_label_set_text_fmt(statusbar->time, "%d:%02d", hours, statusbar_data.time_minutes);
} else {
@ -384,12 +435,12 @@ static void apply_side_icon_limit(Statusbar* statusbar) {
}
static void update_icons(Statusbar* statusbar) {
if (statusbar_data.mutex.lock(200 / portTICK_PERIOD_MS)) {
if (recursive_mutex_try_lock(&statusbar_data.mutex, 200 / portTICK_PERIOD_MS)) {
for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
update_icon(statusbar->icons[i], &(statusbar_data.icons[i]));
}
apply_side_icon_limit(statusbar);
statusbar_data.mutex.unlock();
recursive_mutex_unlock(&statusbar_data.mutex);
}
}
@ -421,65 +472,85 @@ static void statusbar_event(const lv_obj_class_t* class_p, lv_event_t* event) {
}
}
int8_t statusbar_icon_add(const std::string& image, bool visible) {
statusbar_data.mutex.lock();
static void copy_image(char* target, const char* image) {
if (image == nullptr) {
target[0] = '\0';
return;
}
if (strlen(image) > LVGL_STATUSBAR_IMAGE_MAX_LENGTH) {
LOG_W(TAG, "Image too long: %s", image);
}
strncpy(target, image, LVGL_STATUSBAR_IMAGE_MAX_LENGTH);
target[LVGL_STATUSBAR_IMAGE_MAX_LENGTH] = '\0';
}
extern "C" {
int8_t lvgl_statusbar_icon_add(const char* image, bool visible) {
recursive_mutex_lock(&statusbar_data.mutex);
int8_t result = -1;
for (int8_t i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
if (!statusbar_data.icons[i].claimed) {
statusbar_data.icons[i].claimed = true;
statusbar_data.icons[i].visible = visible;
statusbar_data.icons[i].image = image;
copy_image(statusbar_data.icons[i].image, image);
statusbar_data.icons[i].added_order = statusbar_data.next_added_order++;
result = i;
LOG_D(TAG, "id %d: added", (int)i);
break;
}
}
statusbar_data.mutex.unlock();
statusbar_data.pubsub->publish(nullptr);
recursive_mutex_unlock(&statusbar_data.mutex);
if (result < 0) {
LOG_W(TAG, "No icon slot left, the icon isn't shown");
}
notify_instances();
return result;
}
int8_t statusbar_icon_add() {
return statusbar_icon_add("", false);
}
void statusbar_icon_remove(int8_t id) {
void lvgl_statusbar_icon_remove(int8_t id) {
LOG_D(TAG, "id %d: remove", (int)id);
if (id == -1) {
return;
}
check(id >= 0 && id < STATUSBAR_ICON_LIMIT);
statusbar_data.mutex.lock();
recursive_mutex_lock(&statusbar_data.mutex);
StatusbarIcon* icon = &statusbar_data.icons[id];
icon->claimed = false;
icon->visible = false;
icon->image = "";
statusbar_data.mutex.unlock();
statusbar_data.pubsub->publish(nullptr);
icon->image[0] = '\0';
recursive_mutex_unlock(&statusbar_data.mutex);
notify_instances();
}
void statusbar_icon_set_image(int8_t id, const std::string& image) {
if (image.empty()) {
LOG_D(TAG, "id %d: set image (none)", (int)id);
} else {
LOG_D(TAG, "id %d: set image %s", (int)id, image.c_str());
void lvgl_statusbar_icon_set_image(int8_t id, const char* image) {
LOG_D(TAG, "id %d: set image %s", (int)id, image != nullptr ? image : "(none)");
if (id == -1) {
return;
}
check(id >= 0 && id < STATUSBAR_ICON_LIMIT);
statusbar_data.mutex.lock();
recursive_mutex_lock(&statusbar_data.mutex);
StatusbarIcon* icon = &statusbar_data.icons[id];
check(icon->claimed);
icon->image = image;
statusbar_data.mutex.unlock();
statusbar_data.pubsub->publish(nullptr);
copy_image(icon->image, image);
recursive_mutex_unlock(&statusbar_data.mutex);
notify_instances();
}
void statusbar_icon_set_visibility(int8_t id, bool visible) {
void lvgl_statusbar_icon_set_visibility(int8_t id, bool visible) {
LOG_D(TAG, "id %d: set visibility %d", (int)id, (int)visible);
if (id == -1) {
return;
}
check(id >= 0 && id < STATUSBAR_ICON_LIMIT);
statusbar_data.mutex.lock();
recursive_mutex_lock(&statusbar_data.mutex);
StatusbarIcon* icon = &statusbar_data.icons[id];
check(icon->claimed);
icon->visible = visible;
statusbar_data.mutex.unlock();
statusbar_data.pubsub->publish(nullptr);
recursive_mutex_unlock(&statusbar_data.mutex);
notify_instances();
}
}
// The colour that is visible behind the object: its own background or the first visible one of its parents
@ -527,7 +598,9 @@ static int32_t get_widest_time_width(const lv_font_t* font, int32_t letterSpace)
return size.x;
}
void statusbar_set_side(lv_obj_t* obj, bool side) {
extern "C" {
void lvgl_statusbar_set_side(lv_obj_t* obj, bool side) {
auto* statusbar = reinterpret_cast<Statusbar*>(obj);
// Circular displays only have room for the statusbar at the top
if (statusbar->action_container == nullptr || statusbar->side == side) {
@ -569,7 +642,7 @@ void statusbar_set_side(lv_obj_t* obj, bool side) {
lv_obj_move_to_index(statusbar->icon_row, 1);
lv_obj_set_hidden(statusbar->action_container, false);
} else {
lv_obj_set_size(obj, LV_PCT(100), statusbar_get_height());
lv_obj_set_size(obj, LV_PCT(100), lvgl_statusbar_get_height());
lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_hor(obj, statusbar->split_pad_hor, LV_STATE_DEFAULT);
lv_obj_set_style_pad_ver(obj, 0, LV_STATE_DEFAULT);
@ -589,14 +662,14 @@ void statusbar_set_side(lv_obj_t* obj, bool side) {
update_icons(statusbar);
}
lv_obj_t* statusbar_get_action_container(lv_obj_t* obj) {
lv_obj_t* lvgl_statusbar_get_action_container(lv_obj_t* obj) {
return reinterpret_cast<Statusbar*>(obj)->action_container;
}
int statusbar_get_height() {
int32_t lvgl_statusbar_get_height() {
const auto icon_size = lvgl_get_statusbar_icon_font_height();
const auto vertical_padding = static_cast<uint32_t>((static_cast<float>(icon_size) * 0.1f));
return icon_size + (2 * vertical_padding) + 4;
return static_cast<int32_t>(icon_size + (2 * vertical_padding) + 4);
}
} // namespace
}

View File

@ -8,5 +8,5 @@ tactility_add_module(mesh-module
SRCS ${SOURCE_FILES}
PRIV_INCLUDE_DIRS private/
INCLUDE_DIRS include/
REQUIRES TactilityKernel TactilityKernelCpp service-module
REQUIRES TactilityKernel TactilityKernelCpp app-module service-module
)

View File

@ -1,9 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <app/manifest.h>
#include <service/manifest.h>
extern const ServiceManifest mesh_service_manifest;
/** The "mesh" command-line app */
extern "C" const AppManifest mesh_command_manifest;
/** Whether the service was enabled the last time it was configured */
bool mesh_service_should_auto_start();

View File

@ -0,0 +1,258 @@
// SPDX-License-Identifier: Apache-2.0
#include <mesh/mesh.h>
#include <mesh/mesh_service.h>
#include <app/manifest.h>
#include <tactility/drivers/radio.h>
#include <tactility/radio_settings.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
constexpr size_t CHANNEL_LIMIT = 8;
static void print_usage() {
printf(
"usage: mesh <command>\n"
" status\n"
" on|off\n"
" backend [<id>]\n"
" region [<name>]\n"
" preset [<name>]\n"
" channels\n"
" join <#channel>\n"
);
}
static int report_error(const char* action, error_t error) {
printf("mesh: %s failed: %s\n", action, error_to_string(error));
return 1;
}
static int command_status() {
printf("enabled: %s\n", mesh_is_enabled() ? "yes" : "no");
printf("state: %s\n", mesh_state_to_string(mesh_get_state()));
char backend[MESH_BACKEND_ID_MAX_LENGTH + 1];
if (mesh_get_backend(backend, sizeof(backend)) == ERROR_NONE) {
printf("backend: %s\n", backend);
}
char preset[MESH_NAME_MAX_LENGTH + 1];
if (mesh_get_preset(preset, sizeof(preset)) == ERROR_NONE) {
printf("preset: %s\n", preset);
}
printf("region: %s\n", radio_region_to_string(radio_settings_load_region()));
return 0;
}
static int command_enable(bool enabled) {
error_t error = mesh_set_enabled(enabled);
if (error != ERROR_NONE) {
return report_error(enabled ? "on" : "off", error);
}
printf("state: %s\n", mesh_state_to_string(mesh_get_state()));
return 0;
}
static int command_backend(int argc, char* argv[]) {
if (argc > 3) {
printf("usage: mesh backend [<id>]\n");
return 1;
}
if (argc == 3) {
error_t error = mesh_set_backend(argv[2]);
if (error == ERROR_NOT_FOUND) {
printf("mesh: unknown backend: %s\n", argv[2]);
return 1;
} else if (error != ERROR_NONE) {
return report_error("backend", error);
}
printf("backend: %s\n", argv[2]);
return 0;
}
char selected[MESH_BACKEND_ID_MAX_LENGTH + 1] = {};
mesh_get_backend(selected, sizeof(selected));
const size_t count = mesh_get_backend_count();
if (count == 0) {
printf("No backends available\n");
return 0;
}
for (size_t i = 0; i < count; i++) {
char id[MESH_BACKEND_ID_MAX_LENGTH + 1];
if (mesh_get_backend_id(i, id, sizeof(id)) == ERROR_NONE) {
printf("%s %s\n", strcmp(id, selected) == 0 ? "*" : " ", id);
}
}
return 0;
}
static int command_region(int argc, char* argv[]) {
if (argc > 3) {
printf("usage: mesh region [<name>]\n");
return 1;
}
if (argc == 3) {
RadioRegion region = RADIO_REGION_UNSET;
if (radio_region_from_string(argv[2], &region) != ERROR_NONE || region == RADIO_REGION_UNSET) {
printf("mesh: unknown region: %s\n", argv[2]);
return 1;
}
error_t error = radio_settings_save_region(region);
if (error != ERROR_NONE) {
return report_error("region", error);
}
printf("region: %s\n", argv[2]);
return 0;
}
const RadioRegion selected = radio_settings_load_region();
for (int i = RADIO_REGION_UNSET + 1; i < RADIO_REGION_COUNT; i++) {
const auto region = static_cast<RadioRegion>(i);
printf("%s %s\n", region == selected ? "*" : " ", radio_region_to_string(region));
}
return 0;
}
static int command_preset(int argc, char* argv[]) {
if (argc > 3) {
printf("usage: mesh preset [<name>]\n");
return 1;
}
if (argc == 3) {
error_t error = mesh_set_preset(argv[2]);
if (error == ERROR_NOT_FOUND) {
printf("mesh: unknown preset: %s\n", argv[2]);
return 1;
} else if (error != ERROR_NONE) {
return report_error("preset", error);
}
printf("preset: %s\n", argv[2]);
return 0;
}
char selected[MESH_NAME_MAX_LENGTH + 1] = {};
mesh_get_preset(selected, sizeof(selected));
const size_t count = mesh_get_preset_count();
if (count == 0) {
printf("The backend has no presets\n");
return 0;
}
for (size_t i = 0; i < count; i++) {
char name[MESH_NAME_MAX_LENGTH + 1];
if (mesh_get_preset_name(i, name, sizeof(name)) == ERROR_NONE) {
printf("%s %s\n", strcmp(name, selected) == 0 ? "*" : " ", name);
}
}
return 0;
}
static int command_channels() {
MeshChannel channels[CHANNEL_LIMIT];
size_t count = 0;
error_t error = mesh_get_channels(channels, CHANNEL_LIMIT, &count);
if (error == ERROR_NOT_SUPPORTED) {
printf("The backend has no channels\n");
return 0;
} else if (error != ERROR_NONE) {
return report_error("channels", error);
}
if (count == 0) {
printf("No channels\n");
return 0;
}
for (size_t i = 0; i < count; i++) {
printf("%u %s\n", channels[i].index, channels[i].name);
}
return 0;
}
static int command_join(int argc, char* argv[]) {
const char* name = argc == 3 ? argv[2] : nullptr;
if (name == nullptr || name[0] != '#' || name[1] == '\0') {
printf("usage: mesh join <#channel>\n");
return 1;
}
if (strlen(name) > MESH_NAME_MAX_LENGTH) {
printf("mesh: channel name must be at most %d characters\n", MESH_NAME_MAX_LENGTH);
return 1;
}
// A new channel gets the next free index
MeshChannel channels[CHANNEL_LIMIT];
size_t count = 0;
uint8_t index = 0;
bool found = false;
if (mesh_get_channels(channels, CHANNEL_LIMIT, &count) == ERROR_NONE) {
for (size_t i = 0; i < count; i++) {
if (strcmp(channels[i].name, name) == 0) {
index = channels[i].index;
found = true;
break;
}
index = std::max<uint8_t>(index, channels[i].index + 1);
}
if (!found && count >= CHANNEL_LIMIT) {
printf("mesh: no free channel slot\n");
return 1;
}
}
MeshChannel channel {};
channel.index = index;
strncpy(channel.name, name, MESH_NAME_MAX_LENGTH);
channel.key_length = 0;
error_t error = mesh_set_channel(&channel);
if (error != ERROR_NONE) {
return report_error("join", error);
}
printf("Joined %s\n", name);
return 0;
}
static int32_t mesh_command_main(int argc, char* argv[]) {
if (argc < 2 || strcmp(argv[1], "help") == 0) {
print_usage();
return 0;
}
const char* command = argv[1];
if (strcmp(command, "status") == 0) {
return command_status();
} else if (strcmp(command, "on") == 0 || strcmp(command, "off") == 0) {
return command_enable(strcmp(command, "on") == 0);
} else if (strcmp(command, "backend") == 0) {
return command_backend(argc, argv);
} else if (strcmp(command, "region") == 0) {
return command_region(argc, argv);
} else if (strcmp(command, "preset") == 0) {
return command_preset(argc, argv);
} else if (strcmp(command, "channels") == 0) {
return command_channels();
} else if (strcmp(command, "join") == 0) {
return command_join(argc, argv);
}
printf("mesh: unknown command: %s\n", command);
print_usage();
return 1;
}
extern "C" {
const AppManifest mesh_command_manifest = {
.id = "mesh",
.name = "mesh",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(mesh_command_main) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 5120, .desired_memory_capability = 0 },
};
}

View File

@ -3,6 +3,7 @@
#include <mesh/mesh_service.h>
#include <mesh/module.h>
#include <app/manager.h>
#include <service/manager.h>
#include <tactility/error.h>
@ -11,15 +12,27 @@
extern "C" {
static error_t start() {
return service_manager_add(&mesh_service_manifest, mesh_service_should_auto_start());
error_t error = app_manager_add(&mesh_command_manifest);
if (error != ERROR_NONE) {
return error;
}
error = service_manager_add(&mesh_service_manifest, mesh_service_should_auto_start());
if (error != ERROR_NONE) {
app_manager_remove(mesh_command_manifest.id);
}
return error;
}
static error_t stop() {
const error_t error = service_manager_stop(mesh_service_manifest.id);
error_t error = service_manager_stop(mesh_service_manifest.id);
if (error != ERROR_NONE && error != ERROR_NOT_FOUND) {
return error;
}
return service_manager_remove(mesh_service_manifest.id);
error = service_manager_remove(mesh_service_manifest.id);
if (error != ERROR_NONE) {
return error;
}
return app_manager_remove(mesh_command_manifest.id);
}
static const ModuleSymbol mesh_module_symbols[] = {

View File

@ -29,6 +29,7 @@ list(APPEND REQUIRES_LIST
lvgl-module
lvgl-window-manager-module
app-module
app-hub-module
coreutils-module
audio-decoder-module
binfont-module

View File

@ -1,43 +0,0 @@
#pragma once
#include <lvgl.h>
#include <string>
namespace tt::lvgl {
constexpr auto STATUSBAR_ICON_LIMIT = 8;
/** Create a statusbar widget. Needs to be called with LVGL lock. */
lv_obj_t* statusbar_create(lv_obj_t* parent);
/** Add an icon to the statusbar. Does not need to be called with LVGL lock. */
int8_t statusbar_icon_add(const std::string& image, bool visible);
/** Add an icon to the statusbar. Does not need to be called with LVGL lock. */
int8_t statusbar_icon_add();
/** Remove an icon from the statusbar. Does not need to be called with LVGL lock. */
void statusbar_icon_remove(int8_t id);
/** Update an icon's image from the statusbar. Does not need to be called with LVGL lock. */
void statusbar_icon_set_image(int8_t id, const std::string& image);
/** Update the visibility for an icon on the statusbar. Does not need to be called with LVGL lock. */
void statusbar_icon_set_visibility(int8_t id, bool visible);
int statusbar_get_height();
/**
* Switches between the split layout (a bar on top) and the side layout (a vertical strip on the left). Circular displays always use the split layout.
* The caller places the statusbar in its parent. Needs to be called with LVGL lock.
*/
void statusbar_set_side(lv_obj_t* statusbar, bool side);
/**
* @return the container for the action buttons of apps in the side layout, or nullptr on circular displays. It is hidden in the split layout.
* Needs to be called with LVGL lock.
*/
lv_obj_t* statusbar_get_action_container(lv_obj_t* statusbar);
} // namespace

View File

@ -1,13 +0,0 @@
#pragma once
#include <string>
namespace tt::app::apphub {
constexpr auto* CERTIFICATE_PATH = "/system/certificates/WE1.pem";
std::string getAppsJsonUrl();
std::string getDownloadUrl(const std::string& relativePath);
}

View File

@ -1,34 +0,0 @@
#pragma once
#include <TactilityCpp/Allocator.h>
#include <string>
#include <vector>
namespace tt::app::apphub {
struct AppHubEntry {
std::string appId;
std::string appVersionName;
int32_t appVersionCode;
std::string appName;
std::string appDescription;
std::string targetSdk;
std::vector<std::string> targetPlatforms;
/** Device ids the app is restricted to (manifest "requires.device.id"). Empty means unrestricted. */
std::vector<std::string> requiresDeviceId;
/** RAM in megabytes the app needs (manifest "requires.ram"). 0 means no requirement. */
int32_t requiresRam = 0;
std::string file;
};
// The top-level entries buffer prefers PSRAM/SPIRAM via OptExternalAllocator; individual
// AppHubEntry string/vector members still use the default (internal-RAM) allocator.
using AppHubEntryList = std::vector<AppHubEntry, OptExternalAllocator<AppHubEntry>>;
bool parseJson(const std::string& filePath, AppHubEntryList& entries);
/** @return true if this device can install and run the entry's app (target platform, device and RAM requirements) */
bool isCompatible(const AppHubEntry& entry);
}

View File

@ -1,9 +1,7 @@
#pragma once
#include <Tactility/app/apphub/AppHubEntry.h>
namespace tt::app::apphubdetails {
void start(const apphub::AppHubEntry& entry);
void start(const char* appId);
} // namespace

View File

@ -1,9 +0,0 @@
#pragma once
#include <string>
namespace tt::app::apppackagedetails {
void start(const std::string& packageId);
} // namespace

View File

@ -42,8 +42,6 @@ namespace app {
namespace apphub { extern const ::AppManifest manifest; }
namespace appearancesettings { extern const ::AppManifest manifest; }
namespace apphubdetails { extern const ::AppManifest manifest; }
namespace apppackagedetails { extern const ::AppManifest manifest; }
namespace apppackagelist { extern const ::AppManifest manifest; }
namespace audiosettings { extern const ::AppManifest manifest; }
namespace boot { extern const ::AppManifest manifest; }
namespace development { extern const ::AppManifest manifest; }
@ -110,10 +108,8 @@ static void registerInternalApps() {
LOG_I(TAG, "Registering internal apps");
app_manager_add(&app::alertdialog::manifest);
app_manager_add(&app::apppackagedetails::manifest);
app_manager_add(&app::apphub::manifest);
app_manager_add(&app::apphubdetails::manifest);
app_manager_add(&app::apppackagelist::manifest);
if (service::audio::isAvailable()) {
app_manager_add(&app::audiosettings::manifest);
}

View File

@ -9,6 +9,7 @@
#include <Tactility/settings/RadioSettings.h>
#include <location/module.h>
#include <app_hub/module.h>
#include <wifi/module.h>
#if TT_FEATURE_MESH
#include <mesh/module.h>
@ -51,6 +52,7 @@ void registerAndStartServices() {
}
check(module_ensure_started(&wifi_module) == ERROR_NONE);
check(module_ensure_started(&location_module) == ERROR_NONE);
check(module_ensure_started(&app_hub_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

View File

@ -23,6 +23,7 @@
#include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/icons/shared.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
@ -522,8 +523,9 @@ ColorRowWidgets createColorRow(lv_obj_t* parent, size_t index, Context* ctx) {
lv_obj_set_style_radius(widgets.swatch, LV_DPX(4), LV_STATE_DEFAULT);
auto* button = lv_button_create(widgets.row);
auto* button_label = lv_label_create(button);
lv_label_set_text(button_label, "Change");
auto* button_icon = lv_label_create(button);
lv_obj_set_style_text_font(button_icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(button_icon, LVGL_ICON_SHARED_PALETTE);
lv_obj_set_user_data(button, reinterpret_cast<void*>(static_cast<uintptr_t>(index)));
lv_obj_add_event_cb(button, onColorButtonPressed, LV_EVENT_SHORT_CLICKED, ctx);

View File

@ -1,27 +0,0 @@
#include <Tactility/app/apphub/AppHub.h>
#include <format>
namespace tt::app::apphub {
constexpr auto* BASE_URL = "https://cdn.tactilityproject.org/apps";
static std::string getVersionWithoutPostfix() {
std::string version(TT_VERSION);
auto index = version.find_first_of('-');
if (index == std::string::npos) {
return version;
} else {
return version.substr(0, index);
}
}
std::string getAppsJsonUrl() {
return std::format("{}/{}/apps.json", BASE_URL, getVersionWithoutPostfix());
}
std::string getDownloadUrl(const std::string& relativePath) {
return std::format("{}/{}/{}", BASE_URL, getVersionWithoutPostfix(), relativePath);
}
}

View File

@ -1,16 +1,11 @@
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/Mutex.h>
#include <Tactility/app/apphub/AppHub.h>
#include <Tactility/app/apphub/AppHubEntry.h>
#include <Tactility/app/apphubdetails/AppHubDetailsApp.h>
#include <Tactility/file/File.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <http/download.h>
#include <app_hub/app_hub.h>
#include <lvgl_window_manager/window_manager.h>
@ -19,16 +14,20 @@
#include <tactility/drivers/wifi.h>
#include <tactility/log.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/toolbar.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <format>
#include <string_view>
#include <string>
#include <vector>
namespace tt::app::apphub {
@ -38,13 +37,24 @@ extern const ::AppManifest manifest;
namespace {
enum class Filter {
All,
Installed,
Updates
};
constexpr auto FILTER_LABELS = std::to_array<const char*>({ "All", "Installed", "Updates" });
struct Context {
uint32_t appInstanceId;
lv_obj_t* contentWrapper = nullptr;
lv_obj_t* refreshButton = nullptr;
std::string cachedAppsJsonFile = std::format("{}/app_hub.json", getTempPath());
AppHubEntryList entries;
lv_obj_t* filterChips = nullptr;
// Survives across a bury/resurface cycle
Filter filter = Filter::All;
/** The app ids of the list's buttons, by index */
std::vector<std::string> appIds;
Mutex mutex;
// Survives across a bury/resurface cycle (e.g. opening AppHubDetailsApp and returning),
@ -54,21 +64,89 @@ struct Context {
// list, contentWrapper is still an empty spinner; scrollY must come from here instead of a
// live read off it.
bool restoreScrollOnNextShow = false;
// Only the first createWidgets() call triggers a network refresh. The rest uses the cached file.
// Only the first createWidgets() call triggers a network refresh. The rest uses the cached catalog.
bool needsInitialRefresh = true;
TaskEventGroup* eventGroup = nullptr;
uint32_t refreshRequestedBit = 0;
std::atomic<bool> refreshRequested {false};
HttpDownloadSubscription downloadSub {};
bool downloadInProgress = false;
AppHubEventSubscription appHubSub {};
/** The id of the queued catalog refresh, 0 when none is pending */
std::atomic<uint32_t> refreshOperationId {0};
};
void showApps(Context* ctx);
void refresh(Context* ctx);
bool matchesFilter(const AppHubApp& app, Filter filter) {
switch (filter) {
case Filter::All:
return true;
case Filter::Installed:
return app.installed;
case Filter::Updates:
return app.update_available;
}
return true;
}
const char* getEmptyFilterText(Filter filter) {
return (filter == Filter::Updates) ? "No updates available" : "No installed apps";
}
void updateFilterChips(Context* ctx) {
const uint32_t count = lv_obj_get_child_count(ctx->filterChips);
for (uint32_t i = 0; i < count; i++) {
lv_obj_set_state(lv_obj_get_child(ctx->filterChips, static_cast<int32_t>(i)), LV_STATE_CHECKED, i == static_cast<uint32_t>(ctx->filter));
}
}
void updateUpdatesChipText(Context* ctx, size_t updateCount) {
auto* chip = lv_obj_get_child(ctx->filterChips, static_cast<int32_t>(Filter::Updates));
const auto text = std::format("{} {}", updateCount, updateCount == 1 ? "update" : "updates");
lv_label_set_text(lv_obj_get_child(chip, 0), text.c_str());
}
void onFilterPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* chip = lv_event_get_target_obj(event);
const auto filter = static_cast<Filter>(lv_obj_get_index(chip));
if (filter == ctx->filter) {
return;
}
ctx->filter = filter;
updateFilterChips(ctx);
// A different filter shows a different list, which starts at the top
ctx->scrollY = 0;
ctx->restoreScrollOnNextShow = true;
showApps(ctx);
}
/** The filter pills wrap onto the next row when they don't fit */
lv_obj_t* createFilterChips(lv_obj_t* parent, Context* ctx) {
auto* chips = lv_obj_create(parent);
lv_obj_set_size(chips, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(chips, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(chips, 0, LV_STATE_DEFAULT);
// The chips' margins leave room for their focus rings and space them apart
lv_obj_set_style_pad_column(chips, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_row(chips, 0, LV_STATE_DEFAULT);
lv_obj_set_scrollable(chips, false);
for (const auto* label : FILTER_LABELS) {
auto* chip = lvgl_chip_create(chips);
lv_obj_add_event_cb(chip, onFilterPressed, LV_EVENT_SHORT_CLICKED, ctx);
lv_label_set_text(lv_label_create(chip), label);
}
lvgl_grid_navigation_add(chips);
return chips;
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId);
@ -80,8 +158,8 @@ void onAppPressed(lv_event_t* e) {
const auto* user_data = lv_obj_get_user_data(widget);
const intptr_t index = reinterpret_cast<intptr_t>(user_data);
ctx->mutex.lock();
if (index < ctx->entries.size()) {
apphubdetails::start(ctx->entries[index]);
if (index < ctx->appIds.size()) {
apphubdetails::start(ctx->appIds[index].c_str());
}
ctx->mutex.unlock();
}
@ -117,6 +195,15 @@ void showNoInternet(Context* ctx) {
showRefreshFailedError(ctx, "No Internet Connection");
}
bool isInContentWrapper(Context* ctx, lv_obj_t* obj) {
for (auto* parent = lv_obj_get_parent(obj); parent != nullptr; parent = lv_obj_get_parent(parent)) {
if (parent == ctx->contentWrapper) {
return true;
}
}
return false;
}
void showApps(Context* ctx) {
// Refresh rebuilds the list from scratch (cached copy, then again once the network fetch
// lands), which would otherwise reset the user's scroll position each time.
@ -127,17 +214,34 @@ void showApps(Context* ctx) {
} else {
scrollY = lv_obj_get_scroll_y(ctx->contentWrapper);
}
lv_obj_clean(ctx->contentWrapper);
ctx->mutex.lock();
if (parseJson(ctx->cachedAppsJsonFile, ctx->entries)) {
std::erase_if(ctx->entries, [](const AppHubEntry& entry) {
return !isCompatible(entry);
});
// Rebuilding deletes the focused entry button, so the same app is focused again afterwards
std::string focused_app_id;
bool focused_by_key = false;
auto* group = lv_group_get_default();
auto* focused = group != nullptr ? lv_group_get_focused(group) : nullptr;
if (focused != nullptr && isInContentWrapper(ctx, focused)) {
const auto index = reinterpret_cast<intptr_t>(lv_obj_get_user_data(focused));
if (index < ctx->appIds.size()) {
focused_app_id = ctx->appIds[index];
focused_by_key = lv_obj_has_state(focused, LV_STATE_FOCUS_KEY);
}
}
lv_obj_clean(ctx->contentWrapper);
std::ranges::sort(ctx->entries, [](auto left, auto right) {
return left.appName < right.appName;
});
std::vector<AppHubApp> apps;
app_hub_search(nullptr, [](const AppHubApp* app, void* context) {
static_cast<std::vector<AppHubApp>*>(context)->push_back(*app);
return true;
}, &apps);
const bool hasApps = !apps.empty();
updateUpdatesChipText(ctx, std::ranges::count_if(apps, [](const AppHubApp& app) { return app.update_available; }));
std::erase_if(apps, [ctx](const AppHubApp& app) {
return !matchesFilter(app, ctx->filter);
});
ctx->appIds.clear();
if (!apps.empty()) {
auto* card = createCard(ctx->contentWrapper);
lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT);
// The list items' pressed and focused backgrounds follow the card's rounded corners
@ -149,20 +253,37 @@ void showApps(Context* ctx) {
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT);
for (int i = 0; i < ctx->entries.size(); i++) {
auto& entry = ctx->entries[i];
LOG_I(TAG, "Adding %s", entry.appName.c_str());
AppManifest manifest;
const char* icon = app_manager_find_manifest(entry.appId.c_str(), &manifest) == ERROR_NONE ? LV_SYMBOL_OK : nullptr;
auto* entry_button = lvgl_list_add_button(list, icon, entry.appName.c_str());
lv_obj_t* focus_target = nullptr;
for (int i = 0; i < apps.size(); i++) {
const auto& app = apps[i];
const char* icon = app.installed ? LV_SYMBOL_OK : nullptr;
auto* entry_button = lvgl_list_add_button(list, icon, app.name);
auto int_as_voidptr = reinterpret_cast<void*>(i);
lv_obj_set_user_data(entry_button, int_as_voidptr);
lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx);
ctx->appIds.emplace_back(app.id);
if (app.id == focused_app_id) {
focus_target = entry_button;
}
}
lv_obj_scroll_to_y(ctx->contentWrapper, scrollY, LV_ANIM_OFF);
} else {
if (focus_target != nullptr) {
lv_group_focus_obj(focus_target);
if (focused_by_key) {
lv_obj_add_state(focus_target, LV_STATE_FOCUS_KEY);
}
}
} else if (hasApps) {
auto* label = lv_label_create(createCard(ctx->contentWrapper));
lv_obj_set_width(label, LV_PCT(100));
lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP);
lv_label_set_text(label, getEmptyFilterText(ctx->filter));
} else if (ctx->refreshOperationId == 0) {
showRefreshFailedError(ctx, "Failed to load content");
} else {
auto* spinner = lvgl_spinner_create(ctx->contentWrapper);
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0);
}
ctx->mutex.unlock();
}
@ -172,14 +293,11 @@ void refresh(Context* ctx) {
// Buried (e.g. AppHubDetailsApp is open): destroyWidgets() already released these. A refresh
// request queued just before burying could still land here, so re-check rather than assume
// requestRefresh() and refresh() always run against a live window.
if (ctx->downloadInProgress || ctx->contentWrapper == nullptr) {
if (ctx->refreshOperationId != 0 || ctx->contentWrapper == nullptr) {
return;
}
lvgl_lock();
lv_obj_clean(ctx->contentWrapper);
auto* spinner = lvgl_spinner_create(ctx->contentWrapper);
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_hidden(ctx->refreshButton, true);
lvgl_unlock();
@ -197,57 +315,42 @@ void refresh(Context* ctx) {
return;
}
if (file::isFile(ctx->cachedAppsJsonFile)) {
lvgl_lock();
showApps(ctx);
lvgl_unlock();
}
if (http_download_subscribe(&ctx->downloadSub, ctx->eventGroup) != ERROR_NONE) {
LOG_E(TAG, "Failed to subscribe to download events");
uint32_t operation_id = 0;
if (app_hub_refresh(&operation_id) != ERROR_NONE) {
LOG_E(TAG, "Failed to queue the catalog refresh");
lvgl_lock();
showRefreshFailedError(ctx, "Cannot reach server");
lvgl_unlock();
return;
}
ctx->refreshOperationId = operation_id;
auto url = getAppsJsonUrl();
if (http_download_start(url.c_str(), CERTIFICATE_PATH, ctx->cachedAppsJsonFile.c_str(), &ctx->downloadSub) != ERROR_NONE) {
LOG_E(TAG, "Failed to start download");
http_download_unsubscribe(&ctx->downloadSub);
lvgl_lock();
showRefreshFailedError(ctx, "Cannot reach server");
lvgl_unlock();
return;
}
ctx->downloadInProgress = true;
// Shows the cached catalog while the new one downloads
lvgl_lock();
showApps(ctx);
lvgl_unlock();
}
// Called from appMain()'s loop once http_download_poll() reports the download's terminal event.
void onDownloadFinished(Context* ctx, const HttpDownloadEvent& event) {
ctx->downloadInProgress = false;
http_download_unsubscribe(&ctx->downloadSub);
bool succeeded = event.type == HTTP_DOWNLOAD_EVENT_SUCCESS;
ctx->needsInitialRefresh = !succeeded;
if (succeeded) {
LOG_I(TAG, "Request success (status %d)", event.status_code);
} else {
LOG_E(TAG, "Request failed (status %d): %s", event.status_code, event.error.message);
void onOperationFinished(Context* ctx, const AppHubEvent& event) {
const bool isRefresh = event.operation_id == ctx->refreshOperationId;
if (isRefresh) {
ctx->refreshOperationId = 0;
ctx->needsInitialRefresh = event.result != ERROR_NONE;
if (event.result != ERROR_NONE) {
LOG_E(TAG, "Catalog refresh failed: %s", error_to_string(event.result));
}
}
if (ctx->contentWrapper == nullptr) {
// Buried (e.g. AppHubDetailsApp is open): destroyWidgets() already released the widgets
// above. createWidgets() picks this up via needsInitialRefresh on resurface instead.
// Buried (e.g. AppHubDetailsApp is open): createWidgets() rebuilds the list on resurface
return;
}
lvgl_lock();
if (succeeded) {
showApps(ctx);
} else {
if (isRefresh && event.result != ERROR_NONE) {
showRefreshFailedError(ctx, "Cannot reach server");
} else {
showApps(ctx);
}
lvgl_unlock();
}
@ -264,18 +367,21 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx);
lv_obj_set_hidden(ctx->refreshButton, true);
ctx->filterChips = createFilterChips(parent, ctx);
updateFilterChips(ctx);
ctx->contentWrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_width(ctx->contentWrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->contentWrapper, 1);
ctx->restoreScrollOnNextShow = true;
if (ctx->needsInitialRefresh) {
if (ctx->needsInitialRefresh && ctx->refreshOperationId == 0) {
requestRefresh(ctx);
} else {
// Resurfacing (e.g. returning from AppHubDetailsApp): redisplay the cache already
// loaded this session instead of hitting the network again. window_manager calls
// createWidgets() with the LVGL lock already held, so this can touch widgets directly.
// Resurfacing (e.g. returning from AppHubDetailsApp): redisplay the cached catalog
// instead of hitting the network again. window_manager calls createWidgets() with the
// LVGL lock already held, so this can touch widgets directly.
showApps(ctx);
}
}
@ -285,6 +391,7 @@ void destroyWidgets(void* userData) {
ctx->scrollY = lv_obj_get_scroll_y(ctx->contentWrapper);
ctx->contentWrapper = nullptr;
ctx->refreshButton = nullptr;
ctx->filterChips = nullptr;
}
int32_t appMain(int argc, char* argv[]) {
@ -301,6 +408,7 @@ int32_t appMain(int argc, char* argv[]) {
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
check(app_hub_event_subscribe(&ctx.appHubSub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
@ -319,10 +427,12 @@ int32_t appMain(int argc, char* argv[]) {
}
}
if (ctx.downloadInProgress) {
HttpDownloadEvent download_event {};
if (http_download_poll(&ctx.downloadSub, &download_event) == ERROR_NONE) {
onDownloadFinished(&ctx, download_event);
AppHubEvent app_hub_event {};
while (app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) {
if (app_hub_event.type == APP_HUB_EVENT_REFRESHED ||
app_hub_event.type == APP_HUB_EVENT_INSTALLED ||
app_hub_event.type == APP_HUB_EVENT_REMOVED) {
onOperationFinished(&ctx, app_hub_event);
}
}
@ -331,13 +441,8 @@ int32_t appMain(int argc, char* argv[]) {
}
}
if (ctx.downloadInProgress) {
http_download_cancel(&ctx.downloadSub);
http_download_unsubscribe(&ctx.downloadSub);
ctx.downloadInProgress = false;
}
window_manager_remove(window);
check(app_hub_event_unsubscribe(&ctx.appHubSub) == ERROR_NONE);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);

View File

@ -1,112 +0,0 @@
#include <Tactility/app/apphub/AppHubEntry.h>
#include <Tactility/file/File.h>
#include <Tactility/json/Reader.h>
#include <app/package_manifest.h>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#include <tactility/log.h>
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <string_view>
namespace tt::app::apphub {
constexpr auto* TAG = "AppHubJson";
static bool parseEntry(const cJSON* object, AppHubEntry& entry) {
const json::Reader reader(object);
// Optional: absent in apps.json files from before these fields existed
// Reset because parseJson() reuses entries from a previous parse
entry.requiresDeviceId.clear();
entry.requiresRam = 0;
if (cJSON_HasObjectItem(object, "requiresDeviceId") && !reader.readStringArray("requiresDeviceId", entry.requiresDeviceId)) {
return false;
}
if (cJSON_HasObjectItem(object, "requiresRam") && !reader.readInt32("requiresRam", entry.requiresRam)) {
return false;
}
return reader.readString("appId", entry.appId) &&
reader.readString("appVersionName", entry.appVersionName) &&
reader.readInt32("appVersionCode", entry.appVersionCode) &&
reader.readString("appName", entry.appName) &&
reader.readString("appDescription", entry.appDescription) &&
reader.readString("targetSdk", entry.targetSdk) &&
reader.readString("file", entry.file) &&
reader.readStringArray("targetPlatforms", entry.targetPlatforms);
}
bool parseJson(const std::string& filePath, AppHubEntryList& entries) {
auto data = file::readString(filePath);
if (data == nullptr) {
LOG_E(TAG, "Failed to read %s", filePath.c_str());
return false;
}
auto data_ptr = reinterpret_cast<const char*>(data.get());
auto* json = cJSON_Parse(data_ptr);
if (json == nullptr) {
LOG_E(TAG, "Failed to parse %s", filePath.c_str());
return false;
}
const cJSON* apps_json = cJSON_GetObjectItemCaseSensitive(json, "apps");
if (!cJSON_IsArray(apps_json)) {
cJSON_Delete(json);
LOG_E(TAG, "apps is not an array");
return false;
}
auto apps_size = cJSON_GetArraySize(apps_json);
entries.resize(apps_size);
for (int i = 0; i < apps_size; ++i) {
auto& entry = entries.at(i);
auto* entry_json = cJSON_GetArrayItem(apps_json, i);
if (!parseEntry(entry_json, entry)) {
LOG_E(TAG, "Failed to read entry");
cJSON_Delete(json);
return false;
}
}
cJSON_Delete(json);
return true;
}
bool isCompatible(const AppHubEntry& entry) {
// The simulator isn't a real MCU target, so it has no platform to match against
#ifdef ESP_PLATFORM
if (!entry.targetPlatforms.empty() &&
std::ranges::find(entry.targetPlatforms, std::string_view(CONFIG_IDF_TARGET)) == entry.targetPlatforms.end()) {
return false;
}
#endif
// Same device and RAM rules as for installed packages
PackageManifest package {};
std::string deviceIds;
for (const auto& deviceId : entry.requiresDeviceId) {
if (!deviceIds.empty()) {
deviceIds += ',';
}
deviceIds += deviceId;
}
if (deviceIds.size() >= sizeof(package.requires_device_id)) {
LOG_W(TAG, "%s: requiresDeviceId too long", entry.appId.c_str());
return false;
}
strcpy(package.requires_device_id, deviceIds.c_str());
if (entry.requiresRam < 0 || entry.requiresRam > UINT8_MAX) {
LOG_W(TAG, "%s: invalid requiresRam %d", entry.appId.c_str(), static_cast<int>(entry.requiresRam));
return false;
}
package.requires_ram = static_cast<uint8_t>(entry.requiresRam);
return app_package_manifest_is_compatible(&package);
}
}

View File

@ -1,19 +1,13 @@
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/apphub/AppHub.h>
#include <Tactility/app/apphub/AppHubEntry.h>
#include <Tactility/file/File.h>
#include <Tactility/app/apphubdetails/AppHubDetailsApp.h>
#include <app/event.h>
#include <app/install.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/package_manifest.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <http/download.h>
#include <app_hub/app_hub.h>
#include <lvgl_window_manager/window_manager.h>
@ -24,8 +18,10 @@
#include <tactility/log.h>
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <format>
#include <string>
#include <vector>
namespace tt::app::apphubdetails {
@ -38,10 +34,11 @@ namespace {
constexpr auto* CONFIRM_TEXT = "Confirm";
constexpr auto* CANCEL_TEXT = "Cancel";
constexpr int32_t CONFIRMATION_BUTTON_INDEX = 0;
constexpr size_t DESCRIPTION_MAX_SIZE = 2048;
struct Context {
uint32_t appInstanceId;
apphub::AppHubEntry entry;
std::string appId;
lv_obj_t* toolbar = nullptr;
lv_obj_t* spinner = nullptr;
@ -54,19 +51,40 @@ struct Context {
std::atomic<uint32_t> uninstallDialogId = 0;
std::atomic<uint32_t> updateDialogId = 0;
// doInstall()/onDownloadFinished() and the poll for them both run on appMain()'s own task
// (triggered via confirm-dialog APP_EVENT_RESULTs), so HttpDownloadSubscription's
// subscribe/start/poll are naturally all on one consistent task already.
TaskEventGroup* eventGroup = nullptr;
HttpDownloadSubscription downloadSub {};
bool downloadInProgress = false;
AppHubEventSubscription appHubSub {};
};
AppHubApp getApp(Context* ctx) {
AppHubApp app {};
if (app_hub_get_app(ctx->appId.c_str(), &app) != ERROR_NONE) {
strncpy(app.id, ctx->appId.c_str(), sizeof(app.id) - 1);
strncpy(app.name, ctx->appId.c_str(), sizeof(app.name) - 1);
}
return app;
}
/** @return true when App Hub is installing or removing another app, which blocks this app's install and remove */
bool isAnotherAppBusy(Context* ctx) {
struct Search {
const char* appId;
bool busy;
} search { ctx->appId.c_str(), false };
app_hub_search(nullptr, [](const AppHubApp* app, void* context) {
auto* search = static_cast<Search*>(context);
if (app->operation_pending && strcmp(app->id, search->appId) != 0) {
search->busy = true;
return false;
}
return true;
}, &search);
return search.busy;
}
void updateViews(Context* ctx);
uint32_t showConfirmDialog(Context* ctx, const char* action) {
const auto message = std::format("{} {}?", action, ctx->entry.appName);
const auto message = std::format("{} {}?", action, getApp(ctx).name);
return alertdialog::start(ctx->appInstanceId, CONFIRM_TEXT, message, std::vector<std::string> { CONFIRM_TEXT, CANCEL_TEXT });
}
@ -90,128 +108,54 @@ void onUpdatePressed(lv_event_t* e) {
ctx->updateDialogId = showConfirmDialog(ctx, "Update");
}
void uninstallApp(Context* ctx) {
LOG_I(TAG, "Uninstall");
lvgl_lock();
lv_obj_set_hidden(ctx->spinner, false);
lvgl_unlock();
app_uninstall(ctx->entry.appId.c_str());
void queueOperation(Context* ctx, error_t (*operation)(const char* id, uint32_t* operation_id), const char* failure) {
const error_t error = operation(ctx->appId.c_str(), nullptr);
if (error == ERROR_RESOURCE_BUSY) {
alertdialog::start(ctx->appInstanceId, "Busy", "Another app is being installed or removed. Try again when it's done.");
return;
} else if (error != ERROR_NONE) {
alertdialog::start(ctx->appInstanceId, "Error", failure);
return;
}
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
}
// Path doInstall() downloads to and onDownloadFinished() installs from - deterministic from ctx->entry,
// which doesn't change once this app instance is running, so it's recomputed at each use instead of stored.
std::string getTempFilePath(Context* ctx) {
auto file_name = file::getLastPathSegment(ctx->entry.file);
return std::format("{}/{}", getTempPath(), file_name);
}
void doInstall(Context* ctx) {
if (ctx->downloadInProgress) {
return;
void onOperationEvent(Context* ctx, const AppHubEvent& event) {
// Also another app's install or removal, as it blocks the buttons
const bool finished = event.type == APP_HUB_EVENT_INSTALLED || event.type == APP_HUB_EVENT_REMOVED;
if (finished && ctx->appId == event.app_id && event.result != ERROR_NONE) {
LOG_E(TAG, "Operation failed: %s", error_to_string(event.result));
const char* message = (event.type == APP_HUB_EVENT_REMOVED) ? "Failed to uninstall app" : "Failed to install app";
alertdialog::start(ctx->appInstanceId, "Error", message);
}
if (http_download_subscribe(&ctx->downloadSub, ctx->eventGroup) != ERROR_NONE) {
LOG_E(TAG, "Failed to subscribe to download events");
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
return;
}
auto url = apphub::getDownloadUrl(ctx->entry.file);
auto temp_file_path = getTempFilePath(ctx);
if (http_download_start(url.c_str(), apphub::CERTIFICATE_PATH, temp_file_path.c_str(), &ctx->downloadSub) != ERROR_NONE) {
LOG_E(TAG, "Failed to start download");
http_download_unsubscribe(&ctx->downloadSub);
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
return;
}
ctx->downloadInProgress = true;
}
// Called from appMain()'s loop once http_download_poll() reports the download's terminal event.
void onDownloadFinished(Context* ctx, const HttpDownloadEvent& event) {
ctx->downloadInProgress = false;
http_download_unsubscribe(&ctx->downloadSub);
auto temp_file_path = getTempFilePath(ctx);
if (event.type == HTTP_DOWNLOAD_EVENT_SUCCESS) {
error_t install_result = app_install(temp_file_path.c_str());
if (install_result != ERROR_NONE) {
LOG_E(TAG, "Install of %s failed", temp_file_path.c_str());
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
}
if (!file::deleteFile(temp_file_path)) {
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
} else {
LOG_I(TAG, "Deleted temporary file %s", temp_file_path.c_str());
}
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
} else {
LOG_E(TAG, "Download failed (status %d): %s", event.status_code,
event.type == HTTP_DOWNLOAD_EVENT_ERROR ? event.error.message : "Cancelled");
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
if (file::isFile(temp_file_path) && !file::deleteFile(temp_file_path.c_str())) {
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
}
}
}
void installApp(Context* ctx) {
LOG_I(TAG, "Install");
lvgl_lock();
lv_obj_set_hidden(ctx->spinner, false);
updateViews(ctx);
lvgl_unlock();
doInstall(ctx);
}
void updateApp(Context* ctx) {
LOG_I(TAG, "Update");
lvgl_lock();
lv_obj_set_hidden(ctx->spinner, false);
lvgl_unlock();
LOG_I(TAG, "Removing previous version");
app_uninstall(ctx->entry.appId.c_str());
LOG_I(TAG, "Installing new version");
doInstall(ctx);
}
void updateViews(Context* ctx) {
lvgl_toolbar_clear_actions(ctx->toolbar);
auto app_id = ctx->entry.appId.c_str();
ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar);
lv_obj_set_hidden(ctx->spinner, true);
lv_obj_set_hidden(ctx->updateLabel, true);
char install_path[128];
bool is_installed = app_get_install_path(app_id, install_path, sizeof(install_path)) == ERROR_NONE
&& file::isFile(std::string(install_path) + "/manifest.properties");
// This app's install or removal continues after the window closes, so it can already be running when the window opens.
// Another app's install or removal blocks this app's buttons until it's done.
const auto app = getApp(ctx);
const bool busy = app.operation_pending || ((app.installed || app.in_catalog) && isAnotherAppBusy(ctx));
lv_obj_set_hidden(ctx->spinner, !busy);
if (busy) {
return;
}
if (is_installed) {
std::string metadata_path = std::string(install_path) + "/manifest.properties";
PackageManifest package;
if (app_package_manifest_parse(metadata_path.c_str(), &package, nullptr, 0) == ERROR_NONE
&& package.version_code < ctx->entry.appVersionCode) {
if (app.installed) {
if (app.update_available) {
ctx->updateButton = lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, ctx);
lv_obj_set_hidden(ctx->updateLabel, false);
}
lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_TRASH, onUninstallPressed, ctx);
} else {
} else if (app.in_catalog) {
lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onInstallPressed, ctx);
}
}
@ -222,7 +166,8 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
ctx->toolbar = lvgl_toolbar_create(parent, ctx->entry.appName.c_str());
const auto app = getApp(ctx);
ctx->toolbar = lvgl_toolbar_create(parent, app.name);
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* wrapper = lv_obj_create(parent);
@ -238,8 +183,9 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* description_label = lv_label_create(wrapper);
lv_obj_set_width(description_label, LV_PCT(100));
lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP);
if (!ctx->entry.appDescription.empty()) {
std::string description = ctx->entry.appDescription;
std::vector<char> description_buffer(DESCRIPTION_MAX_SIZE);
if (app_hub_get_description(ctx->appId.c_str(), description_buffer.data(), description_buffer.size()) == ERROR_NONE && description_buffer[0] != '\0') {
std::string description = description_buffer.data();
for (size_t pos = 0; (pos = description.find("\\n", pos)) != std::string::npos;) {
description.replace(pos, 2, "\n");
}
@ -249,29 +195,19 @@ void createWidgets(lv_obj_t* parent, void* userData) {
}
auto* version_label = lv_label_create(wrapper);
lv_label_set_text_fmt(version_label, "Version %s", ctx->entry.appVersionName.c_str());
lv_label_set_text_fmt(version_label, "Version %s", app.in_catalog ? app.version_name : app.installed_version_name);
updateViews(ctx);
}
int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id();
// argv layout: [0]=appId, [1]=appVersionName, [2]=appVersionCode, [3]=appName,
// [4]=appDescription, [5]=targetSdk, [6]=file, [7..argc)=targetPlatforms.
// argv layout: [0]=appId
Context ctx {};
ctx.appInstanceId = appInstanceId;
if (argc >= 7) {
ctx.entry.appId = argv[0];
ctx.entry.appVersionName = argv[1];
ctx.entry.appVersionCode = static_cast<int32_t>(strtol(argv[2], nullptr, 10));
ctx.entry.appName = argv[3];
ctx.entry.appDescription = argv[4];
ctx.entry.targetSdk = argv[5];
ctx.entry.file = argv[6];
for (int i = 7; i < argc; i++) {
ctx.entry.targetPlatforms.emplace_back(argv[i]);
}
if (argc >= 1) {
ctx.appId = argv[0];
}
TaskEventGroup event_group {};
@ -280,6 +216,7 @@ int32_t appMain(int argc, char* argv[]) {
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
check(app_hub_event_subscribe(&ctx.appHubSub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
@ -296,11 +233,11 @@ int32_t appMain(int argc, char* argv[]) {
case APP_EVENT_RESULT: {
bool confirmed = event.result.result == CONFIRMATION_BUTTON_INDEX;
if (event.result.launch_id == ctx.installDialogId && confirmed) {
installApp(&ctx);
queueOperation(&ctx, app_hub_install, "Failed to install app");
} else if (event.result.launch_id == ctx.uninstallDialogId && confirmed) {
uninstallApp(&ctx);
queueOperation(&ctx, app_hub_remove, "Failed to uninstall app");
} else if (event.result.launch_id == ctx.updateDialogId && confirmed) {
updateApp(&ctx);
queueOperation(&ctx, app_hub_install, "Failed to update app");
}
app_manager_stop(event.result.launch_id);
break;
@ -311,23 +248,17 @@ int32_t appMain(int argc, char* argv[]) {
if (shouldClose) break;
}
if (ctx.downloadInProgress) {
HttpDownloadEvent download_event {};
if (http_download_poll(&ctx.downloadSub, &download_event) == ERROR_NONE) {
onDownloadFinished(&ctx, download_event);
AppHubEvent app_hub_event {};
while (!shouldClose && app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) {
if (app_hub_event.type != APP_HUB_EVENT_REFRESHING && app_hub_event.type != APP_HUB_EVENT_REFRESHED) {
onOperationEvent(&ctx, app_hub_event);
}
}
}
if (ctx.downloadInProgress) {
// Safe to call immediately, even mid-download - no need to wait for the terminal event
// first, so app close doesn't block on the network.
http_download_cancel(&ctx.downloadSub);
http_download_unsubscribe(&ctx.downloadSub);
ctx.downloadInProgress = false;
}
// Queued operations continue in the background after the window closes
window_manager_remove(window);
check(app_hub_event_unsubscribe(&ctx.appHubSub) == ERROR_NONE);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);
@ -336,25 +267,13 @@ int32_t appMain(int argc, char* argv[]) {
} // namespace
void start(const apphub::AppHubEntry& entry) {
void start(const char* appId) {
// Fire-and-forget (parent_instance_id 0): AppHub's own multi-app browsing list isn't
// waiting on a result. targetPlatforms is variable-length, so it goes last in argv.
std::string versionCode = std::to_string(entry.appVersionCode);
std::vector<const char*> argv {
entry.appId.c_str(),
entry.appVersionName.c_str(),
versionCode.c_str(),
entry.appName.c_str(),
entry.appDescription.c_str(),
entry.targetSdk.c_str(),
entry.file.c_str(),
};
for (const auto& platform: entry.targetPlatforms) {
argv.push_back(platform.c_str());
}
// waiting on a result.
const char* argv[] = { appId };
uint32_t instanceId = 0;
AppStartContext context = app_start_context_for_manifest(&manifest);
app_start_context_set_arguments_ext(&context, static_cast<int>(argv.size()), argv.data());
app_start_context_set_arguments_ext(&context, 1, argv);
app_start_context_set_parent(&context, /*parent_instance_id=*/0);
app_start_with_context(&context, &instanceId);
}

View File

@ -1,200 +0,0 @@
#include <app/event.h>
#include <app/install.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/package_manifest.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <format>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl_window_manager/window_manager.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/lvgl/Style.h>
#include <tactility/check.h>
#include <tactility/log.h>
constexpr auto* TAG = "AppPackageDetails";
namespace tt::app::apppackagedetails {
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
std::string targetPackageId;
PackageManifest targetPackage = {};
std::vector<std::string> appIds;
uint32_t pendingUninstallDialogId = 0;
};
struct FindPackageContext {
const std::string* packageId;
PackageManifest* outPackage;
std::vector<std::string>* outAppIds;
bool found = false;
};
void onVisitPackage(const ::AppPackage* pkg, void* context) {
auto* findContext = static_cast<FindPackageContext*>(context);
if (findContext->found || *findContext->packageId != pkg->package.id) {
return;
}
*findContext->outPackage = pkg->package;
findContext->outAppIds->assign(pkg->app_ids, pkg->app_ids + pkg->app_id_count);
findContext->found = true;
}
bool findPackage(const std::string& packageId, PackageManifest& outPackage, std::vector<std::string>& outAppIds) {
FindPackageContext findContext { &packageId, &outPackage, &outAppIds };
app_manager_for_each_package(onVisitPackage, &findContext);
return findContext.found;
}
void onPressUninstall(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
std::vector<std::string> choices = { "Yes", "No" };
ctx->pendingUninstallDialogId = alertdialog::start(
ctx->appInstanceId,
"Confirmation",
std::format("Uninstall {}?", ctx->targetPackage.id),
choices
);
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId);
}
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 title = std::format("{} details", ctx->targetPackage.id);
auto* toolbar = lvgl_toolbar_create(parent, title.c_str());
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, 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 identifier = std::format("Identifier: {}", ctx->targetPackage.id);
auto* identifier_label = lv_label_create(wrapper);
lv_label_set_text(identifier_label, identifier.c_str());
auto version = std::format("Version: {} ({})", ctx->targetPackage.version_name, ctx->targetPackage.version_code);
auto* version_label = lv_label_create(wrapper);
lv_label_set_text(version_label, version.c_str());
char install_path[192];
std::string location = "unknown";
if (app_get_install_path(ctx->targetPackage.id, install_path, sizeof(install_path)) == ERROR_NONE) {
location = install_path;
}
auto location_text = std::format("Location: {}", location);
auto* location_label = lv_label_create(wrapper);
lv_label_set_text(location_label, location_text.c_str());
std::string apps;
for (const auto& appId : ctx->appIds) {
AppManifest appManifest {};
const char* label = app_manager_find_manifest(appId.c_str(), &appManifest) == ERROR_NONE ? appManifest.name : appId.c_str();
apps += apps.empty() ? label : std::format(", {}", label);
}
auto apps_text = std::format("Apps: {}", apps);
auto* apps_label = lv_label_create(wrapper);
lv_label_set_text(apps_label, apps_text.c_str());
auto* uninstall_button = lv_button_create(wrapper);
lv_obj_set_width(uninstall_button, LV_PCT(100));
lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, ctx);
auto* uninstall_label = lv_label_create(uninstall_button);
lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(uninstall_label, "Uninstall");
}
int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.targetPackageId = (argc > 0) ? argv[0] : std::string();
if (!findPackage(ctx.targetPackageId, ctx.targetPackage, ctx.appIds)) {
LOG_W(TAG, "Package %s not found", ctx.targetPackageId.c_str());
return 0;
}
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY);
AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
switch (event.type) {
case APP_EVENT_CLOSE:
shouldClose = true;
break;
case APP_EVENT_RESULT:
if (event.result.launch_id == ctx.pendingUninstallDialogId) {
if (event.result.result == 0) { // 0 = Yes
app_uninstall(ctx.targetPackage.id);
shouldClose = true;
}
app_manager_stop(event.result.launch_id);
}
break;
default:
break;
}
if (shouldClose) break;
}
}
window_manager_remove(window);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);
return 0;
}
} // namespace
void start(const std::string& packageId) {
const char* argv[] = { packageId.c_str() };
uint32_t instanceId = 0;
AppStartContext context = app_start_context_for_manifest(&manifest);
app_start_context_set_arguments_ext(&context, 1, argv);
app_start_with_context(&context, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "tactility.apppackagedetails",
.name = "Package Details",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
.stack = {}
};
} // namespace

View File

@ -1,162 +0,0 @@
#include <lvgl/icons/shared.h>
#include <lvgl/fonts.h>
#include <Tactility/app/apppackagedetails/AppPackageDetails.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/package_manifest.h>
#include <app/scheduler.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <TactilityCpp/Allocator.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl.h>
#include <algorithm>
#include <cstring>
#include <vector>
namespace tt::app::apppackagelist {
extern const ::AppManifest manifest;
namespace {
// Prefers PSRAM, like the other app list buffers
using PackageManifestList = std::vector<PackageManifest, OptExternalAllocator<PackageManifest>>;
struct Context {
uint32_t appInstanceId;
// Must outlive the widgets - button user-data points into this, not a createWidgets()-local vector.
PackageManifestList packages = {};
};
void onPackagePressed(lv_event_t* e) {
const auto* package = static_cast<const PackageManifest*>(lv_event_get_user_data(e));
apppackagedetails::start(package->id);
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId);
}
void createPackageWidget(const PackageManifest* package, lv_obj_t* list) {
// A v2 package's single app shares its id, and its name is the nicer label. Otherwise show the package id.
AppManifest appManifest;
const char* label = (app_manager_find_manifest(package->id, &appManifest) == ERROR_NONE) ? appManifest.name : package->id;
lv_obj_t* btn = lvgl_list_add_button(list, LVGL_ICON_SHARED_DEPLOYED_CODE, label);
lv_obj_t* image = lv_obj_get_child(btn, 0);
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_default_font(), LV_PART_MAIN);
lv_obj_add_event_cb(btn, &onPackagePressed, LV_EVENT_SHORT_CLICKED, const_cast<PackageManifest*>(package));
}
void collectPackage(const ::AppPackage* pkg, void* context) {
auto* packages = static_cast<PackageManifestList*>(context);
packages->push_back(pkg->package);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
// Flex column + flex_grow, because a fixed height computed once from
// lv_obj_get_content_height(parent) goes stale after a resize.
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, "Installed Apps");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* content = lv_obj_create(parent);
lv_obj_set_width(content, LV_PCT(100));
lv_obj_set_flex_grow(content, 1);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT);
auto* card = lvgl_card_create(content);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
// createWidgets() can rerun for this same Context (window rebuild-on-remove).
ctx->packages.clear();
app_manager_for_each_package(collectPackage, &ctx->packages);
std::ranges::sort(ctx->packages, [](const PackageManifest& left, const PackageManifest& right) {
return strcmp(left.id, right.id) < 0;
});
if (ctx->packages.empty()) {
auto* no_apps_label = lv_label_create(card);
lv_label_set_text(no_apps_label, "No apps installed.");
return;
}
// The list items' pressed and focused backgrounds follow the card's rounded corners
lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT);
lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT);
// The card provides the background
lv_obj_t* list = lvgl_list_create(card);
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT);
for (const auto& package : ctx->packages) {
createPackageWidget(&package, list);
}
}
int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx { appInstanceId };
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY);
AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
switch (event.type) {
case APP_EVENT_CLOSE:
shouldClose = true;
break;
default:
break;
}
if (shouldClose) break;
}
}
window_manager_remove(window);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "tactility.apppackagelist",
.name = "Apps",
.category = APP_CATEGORY_SETTINGS,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
.flags = 0,
.stack = { .depth = 4096, .desired_memory_capability = 0 },
};
} // namespace

View File

@ -339,7 +339,9 @@ lv_obj_t* createValueRow(lv_obj_t* card, const char* title, lv_event_cb_t onChan
auto* value_label = lv_label_create(row);
auto* button = lv_button_create(row);
lv_label_set_text(lv_label_create(button), "Change");
auto* icon = lv_label_create(button);
lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(icon, LVGL_ICON_SHARED_LIST_ALT);
lv_obj_add_event_cb(button, onChange, LV_EVENT_SHORT_CLICKED, ctx);
return value_label;
}

View File

@ -19,7 +19,6 @@ struct IconEntry {
constexpr IconEntry ICONS[] = {
{"tactility.appearance", LVGL_ICON_SHARED_PALETTE},
{"tactility.apppackagelist", LVGL_ICON_SHARED_DEPLOYED_CODE},
{"tactility.audiosettings", LVGL_ICON_SHARED_MUSIC_NOTE},
{"tactility.btmanage", LVGL_ICON_SHARED_BLUETOOTH},
{"tactility.development", LVGL_ICON_SHARED_LOGO_DEV},

View File

@ -21,6 +21,7 @@
#include <lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <map>
#include <string>
@ -185,7 +186,9 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_style_text_font(ctx->languageValueLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT);
auto* select_button = lv_button_create(language_row);
lv_label_set_text(lv_label_create(select_button), "Change");
auto* select_icon = lv_label_create(select_button);
lv_obj_set_style_text_font(select_icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(select_icon, LVGL_ICON_SHARED_LIST_ALT);
lv_obj_add_event_cb(select_button, onSelectLanguagePressed, LV_EVENT_SHORT_CLICKED, ctx);
updateViews(ctx);

View File

@ -19,6 +19,7 @@
#include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h>
@ -307,7 +308,9 @@ lv_obj_t* createTitleAndValue(lv_obj_t* row, const char* title) {
void createChangeButton(lv_obj_t* row, lv_event_cb_t callback, Context* ctx) {
auto* button = lv_button_create(row);
lv_label_set_text(lv_label_create(button), "Change");
auto* icon = lv_label_create(button);
lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(icon, LVGL_ICON_SHARED_LIST_ALT);
lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, ctx);
}

View File

@ -17,6 +17,7 @@
#include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/toolbar.h>
@ -191,7 +192,9 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_label_set_text(ctx->dateFormatLabel, getDateFormat().c_str());
auto* date_format_button = lv_button_create(date_format_row);
lv_label_set_text(lv_label_create(date_format_button), "Change");
auto* date_format_icon = lv_label_create(date_format_button);
lv_obj_set_style_text_font(date_format_icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(date_format_icon, LVGL_ICON_SHARED_LIST_ALT);
lv_obj_add_event_cb(date_format_button, onSelectDateFormatPressed, LV_EVENT_SHORT_CLICKED, ctx);
// "Timezone Europe/Brussels [Change]"
@ -213,7 +216,9 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_label_set_text(ctx->timeZoneLabel, timeZoneName.c_str());
auto* timezone_button = lv_button_create(timezone_row);
lv_label_set_text(lv_label_create(timezone_button), "Change");
auto* timezone_icon = lv_label_create(timezone_button);
lv_obj_set_style_text_font(timezone_icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(timezone_icon, LVGL_ICON_SHARED_LIST_ALT);
lv_obj_add_event_cb(timezone_button, onTimeZonePressed, LV_EVENT_SHORT_CLICKED, ctx);
}

View File

@ -9,13 +9,14 @@
#include <Tactility/CpuAffinity.h>
#include <Tactility/TactilityConfig.h>
#include <Tactility/lvgl/KeyboardDeviceListener.h>
#include <Tactility/lvgl/Statusbar.h>
#include <Tactility/lvgl/SystemBars.h>
#include <Tactility/lvgl/SystemMenu.h>
#include <Tactility/lvgl/TrackballInit.h>
#include <Tactility/lvgl/UsbHidInput.h>
#include <Tactility/service/ServiceManifest.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/settings/DisplaySettings.h>
#include <Tactility/settings/Time.h>
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
#include <Tactility/settings/TouchCalibrationSettings.h>
@ -28,6 +29,7 @@
#include <lvgl/insets.h>
#include <lvgl/fonts.h>
#include <lvgl/devices/pointer.h>
#include <lvgl/widgets/statusbar.h>
#include <lvgl/lvgl.h>
#include <lvgl/module.h>
#include <lvgl/widgets/toolbar.h>
@ -84,7 +86,9 @@ static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) {
lv_obj_set_scrollable(vertical_container, false);
// The statusbar fits itself into the top of round and rounded displays
auto* statusbar = lvgl::statusbar_create(vertical_container);
lvgl_statusbar_set_menu_callback(systemMenuShow);
lvgl_statusbar_set_time_format_24h(settings::isTimeFormat24Hour());
auto* statusbar = lvgl_statusbar_create(vertical_container);
auto* app_container = lv_obj_create(vertical_container);
lv_obj_set_style_pad_all(app_container, 0, LV_STATE_DEFAULT);

View File

@ -1,6 +1,6 @@
#include <Tactility/lvgl/SystemBars.h>
#include <Tactility/lvgl/Statusbar.h>
#include <lvgl/widgets/statusbar.h>
#include <Tactility/lvgl/SystemMenu.h>
#include <lvgl_window_manager/window_manager.h>
@ -59,7 +59,7 @@ void onPointerReleased(lv_event_t* event) {
lv_indev_t* indev = getEventIndev(event);
lv_point_t point;
lv_indev_get_point(indev, &point);
const int32_t edge = statusbar_get_height();
const int32_t edge = lvgl_statusbar_get_height();
const int32_t dy = point.y - state.pressPoint.y;
const int32_t dx = point.x - state.pressPoint.x;
if (state.pressPoint.y <= edge && dy >= edge * 2 && dy > 2 * LV_ABS(dx)) {
@ -105,7 +105,7 @@ void applySeparator(bool side) {
// The statusbar is the container's first child: on top in the split layout, and on the left in the side layout
void applyLayout(SystemBarsLayout layout) {
const bool side = layout == SystemBarsLayout::Side;
statusbar_set_side(state.statusbar, side);
lvgl_statusbar_set_side(state.statusbar, side);
applySeparator(side);
if (side) {
lv_obj_set_flex_flow(state.container, LV_FLEX_FLOW_ROW);
@ -153,7 +153,7 @@ void systemBarsRefresh() {
const auto height = lv_display_get_vertical_resolution(state.display);
auto layout = settings::launcher::resolveSystemBarsLayout(settings, settings::launcher::getSystemBarsCapabilities(), width, height);
// Circular displays only have room for the statusbar at the top
if (statusbar_get_action_container(state.statusbar) == nullptr) {
if (lvgl_statusbar_get_action_container(state.statusbar) == nullptr) {
layout = SystemBarsLayout::Split;
}
if (state.applied && layout == state.layout) {
@ -178,7 +178,7 @@ lv_obj_t* systemBarsGetActionContainer() {
if (state.statusbar == nullptr || state.layout != SystemBarsLayout::Side) {
return nullptr;
}
return statusbar_get_action_container(state.statusbar);
return lvgl_statusbar_get_action_container(state.statusbar);
}
SystemBarsListenerId systemBarsAddListener(std::function<void()> listener) {

View File

@ -318,6 +318,8 @@ static void createWidgets(lv_obj_t* parent, void* /*userData*/) {
if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) {
auto* close_button = createTile(card, LVGL_ICON_SHARED_CLOSE);
lv_obj_add_event_cb(close_button, onCloseClicked, LV_EVENT_SHORT_CLICKED, nullptr);
lv_group_focus_obj(close_button);
lv_obj_add_state(close_button, LV_STATE_FOCUS_KEY);
}
}

View File

@ -1,4 +1,4 @@
#include <Tactility/lvgl/Statusbar.h>
#include <lvgl/widgets/statusbar.h>
#include <Tactility/service/ServiceContext.h>
#include <Tactility/service/ServiceManifest.h>
#include <Tactility/service/memorychecker/MemoryCheckerService.h>
@ -54,8 +54,8 @@ bool MemoryCheckerService::onStart(ServiceContext& service) {
auto lock = mutex.asScopedLock();
lock.lock();
statusbarIconId = lvgl::statusbar_icon_add(LVGL_ICON_STATUSBAR_MEMORY, false);
lvgl::statusbar_icon_set_visibility(statusbarIconId, false);
statusbarIconId = lvgl_statusbar_icon_add(LVGL_ICON_STATUSBAR_MEMORY, false);
lvgl_statusbar_icon_set_visibility(statusbarIconId, false);
timer.setCallbackPriority(Thread::Priority::Lower);
timer.start();
@ -70,7 +70,7 @@ void MemoryCheckerService::onStop(ServiceContext& service) {
auto lock = mutex.asScopedLock();
lock.lock();
lvgl::statusbar_icon_remove(statusbarIconId);
lvgl_statusbar_icon_remove(statusbarIconId);
}
void MemoryCheckerService::onTimerUpdate() {
@ -80,7 +80,7 @@ void MemoryCheckerService::onTimerUpdate() {
bool memory_low = isMemoryLow();
if (memory_low != memoryLow) {
memoryLow = memory_low;
lvgl::statusbar_icon_set_visibility(statusbarIconId, memory_low);
lvgl_statusbar_icon_set_visibility(statusbarIconId, memory_low);
}
}

View File

@ -1,4 +1,4 @@
#include <Tactility/lvgl/Statusbar.h>
#include <lvgl/widgets/statusbar.h>
#include <Tactility/Mutex.h>
#include <Tactility/Timer.h>
@ -24,7 +24,11 @@
#include <lvgl/icons/statusbar.h>
#include <lvgl/lvgl.h>
#include <app_hub/app_hub.h>
#include <cstring>
#include <set>
#include <string>
#include <gps/gps.h>
@ -162,6 +166,13 @@ class StatusbarService final : public Service {
const char* power_last_icon = nullptr;
int8_t usb_icon_id;
bool usb_last_state = false;
int8_t install_icon_id;
bool install_last_state = false;
TaskEventGroup appHubEventGroup {};
AppHubEventSubscription appHubSubscription {};
bool appHubSubscribed = false;
/** The apps that App Hub is installing */
std::set<std::string> installingApps;
void lock() const {
mutex.lock();
@ -176,10 +187,10 @@ class StatusbarService final : public Service {
const char* icon = device_has_active_by_type(&GPS_TYPE) ? LVGL_ICON_STATUSBAR_LOCATION_ON : nullptr;
if (gps_last_icon != icon) {
if (icon != nullptr) {
lvgl::statusbar_icon_set_image(gps_icon_id, icon);
lvgl::statusbar_icon_set_visibility(gps_icon_id, true);
lvgl_statusbar_icon_set_image(gps_icon_id, icon);
lvgl_statusbar_icon_set_visibility(gps_icon_id, true);
} else {
lvgl::statusbar_icon_set_visibility(gps_icon_id, false);
lvgl_statusbar_icon_set_visibility(gps_icon_id, false);
}
gps_last_icon = icon;
}
@ -189,7 +200,7 @@ class StatusbarService final : public Service {
void updateMeshIcon() {
const bool active = mesh_get_state() == MESH_STATE_ON;
if (active != mesh_last_state) {
lvgl::statusbar_icon_set_visibility(mesh_icon_id, active);
lvgl_statusbar_icon_set_visibility(mesh_icon_id, active);
mesh_last_state = active;
}
}
@ -227,10 +238,10 @@ class StatusbarService final : public Service {
const char* desired_icon = getBluetoothStatusIcon(radio_state, scanning, connected);
if (bt_last_icon != desired_icon) {
if (desired_icon != nullptr) {
lvgl::statusbar_icon_set_image(bt_icon_id, desired_icon);
lvgl::statusbar_icon_set_visibility(bt_icon_id, true);
lvgl_statusbar_icon_set_image(bt_icon_id, desired_icon);
lvgl_statusbar_icon_set_visibility(bt_icon_id, true);
} else {
lvgl::statusbar_icon_set_visibility(bt_icon_id, false);
lvgl_statusbar_icon_set_visibility(bt_icon_id, false);
}
bt_last_icon = desired_icon;
}
@ -240,10 +251,10 @@ class StatusbarService final : public Service {
const char* desired_icon = getWifiStatusIcon();
if (wifi_last_icon != desired_icon) {
if (desired_icon != nullptr) {
lvgl::statusbar_icon_set_image(wifi_icon_id, desired_icon);
lvgl::statusbar_icon_set_visibility(wifi_icon_id, true);
lvgl_statusbar_icon_set_image(wifi_icon_id, desired_icon);
lvgl_statusbar_icon_set_visibility(wifi_icon_id, true);
} else {
lvgl::statusbar_icon_set_visibility(wifi_icon_id, false);
lvgl_statusbar_icon_set_visibility(wifi_icon_id, false);
}
wifi_last_icon = desired_icon;
}
@ -253,10 +264,10 @@ class StatusbarService final : public Service {
const char* desired_icon = getPowerStatusIcon();
if (power_last_icon != desired_icon) {
if (desired_icon != nullptr) {
lvgl::statusbar_icon_set_image(power_icon_id, desired_icon);
lvgl::statusbar_icon_set_visibility(power_icon_id, true);
lvgl_statusbar_icon_set_image(power_icon_id, desired_icon);
lvgl_statusbar_icon_set_visibility(power_icon_id, true);
} else {
lvgl::statusbar_icon_set_visibility(power_icon_id, false);
lvgl_statusbar_icon_set_visibility(power_icon_id, false);
}
power_last_icon = desired_icon;
}
@ -298,11 +309,27 @@ class StatusbarService final : public Service {
});
}
if (connected != usb_last_state) {
lvgl::statusbar_icon_set_visibility(usb_icon_id, connected);
lvgl_statusbar_icon_set_visibility(usb_icon_id, connected);
usb_last_state = connected;
}
}
void updateInstallIcon() {
AppHubEvent event {};
while (appHubSubscribed && app_hub_event_poll(&appHubSubscription, &event) == ERROR_NONE) {
if (event.type == APP_HUB_EVENT_INSTALLING) {
installingApps.insert(event.app_id);
} else if (event.type == APP_HUB_EVENT_INSTALLED) {
installingApps.erase(event.app_id);
}
}
const bool installing = !installingApps.empty();
if (installing != install_last_state) {
lvgl_statusbar_icon_set_visibility(install_icon_id, installing);
install_last_state = installing;
}
}
void update() {
if (lvgl_is_running()) {
if (lvgl_try_lock(200)) {
@ -314,6 +341,7 @@ class StatusbarService final : public Service {
updateWifiIcon();
updatePowerStatusIcon();
updateUsbIcon();
updateInstallIcon();
lvgl_unlock();
}
}
@ -322,25 +350,28 @@ class StatusbarService final : public Service {
public:
StatusbarService() {
gps_icon_id = lvgl::statusbar_icon_add();
gps_icon_id = lvgl_statusbar_icon_add(nullptr, false);
// Added before the mesh icon, as the side strip hides the last added icons first when they don't fit
install_icon_id = lvgl_statusbar_icon_add(LVGL_ICON_STATUSBAR_DOWNLOAD, false);
#if TT_FEATURE_MESH
mesh_icon_id = lvgl::statusbar_icon_add();
mesh_icon_id = lvgl_statusbar_icon_add(nullptr, false);
#endif
bt_icon_id = lvgl::statusbar_icon_add();
usb_icon_id = lvgl::statusbar_icon_add();
wifi_icon_id = lvgl::statusbar_icon_add();
power_icon_id = lvgl::statusbar_icon_add();
bt_icon_id = lvgl_statusbar_icon_add(nullptr, false);
usb_icon_id = lvgl_statusbar_icon_add(nullptr, false);
wifi_icon_id = lvgl_statusbar_icon_add(nullptr, false);
power_icon_id = lvgl_statusbar_icon_add(nullptr, false);
}
~StatusbarService() override {
lvgl::statusbar_icon_remove(power_icon_id);
lvgl::statusbar_icon_remove(wifi_icon_id);
lvgl::statusbar_icon_remove(usb_icon_id);
lvgl::statusbar_icon_remove(bt_icon_id);
lvgl_statusbar_icon_remove(power_icon_id);
lvgl_statusbar_icon_remove(wifi_icon_id);
lvgl_statusbar_icon_remove(usb_icon_id);
lvgl_statusbar_icon_remove(bt_icon_id);
#if TT_FEATURE_MESH
lvgl::statusbar_icon_remove(mesh_icon_id);
lvgl_statusbar_icon_remove(mesh_icon_id);
#endif
lvgl::statusbar_icon_remove(gps_icon_id);
lvgl_statusbar_icon_remove(install_icon_id);
lvgl_statusbar_icon_remove(gps_icon_id);
}
bool onStart(ServiceContext& serviceContext) override {
@ -350,14 +381,20 @@ public:
}
// TODO: Make thread-safe for LVGL
lvgl::statusbar_icon_set_visibility(wifi_icon_id, true);
lvgl::statusbar_icon_set_image(usb_icon_id, LVGL_ICON_STATUSBAR_USB);
lvgl::statusbar_icon_set_visibility(usb_icon_id, false);
lvgl_statusbar_icon_set_visibility(wifi_icon_id, true);
lvgl_statusbar_icon_set_image(usb_icon_id, LVGL_ICON_STATUSBAR_USB);
lvgl_statusbar_icon_set_visibility(usb_icon_id, false);
#if TT_FEATURE_MESH
lvgl::statusbar_icon_set_image(mesh_icon_id, LVGL_ICON_STATUSBAR_HUB);
lvgl::statusbar_icon_set_visibility(mesh_icon_id, false);
lvgl_statusbar_icon_set_image(mesh_icon_id, LVGL_ICON_STATUSBAR_HUB);
lvgl_statusbar_icon_set_visibility(mesh_icon_id, false);
#endif
task_event_group_construct(&appHubEventGroup);
appHubSubscribed = app_hub_event_subscribe(&appHubSubscription, &appHubEventGroup) == ERROR_NONE;
if (!appHubSubscribed) {
LOG_E(TAG, "Failed to subscribe to App Hub events");
}
auto service = findServiceById<StatusbarService>(manifest.id);
assert(service);
service->update();
@ -377,6 +414,12 @@ public:
void onStop(ServiceContext& service) override{
updateTimer->stop();
updateTimer = nullptr;
if (appHubSubscribed) {
app_hub_event_unsubscribe(&appHubSubscription);
appHubSubscribed = false;
}
installingApps.clear();
task_event_group_destruct(&appHubEventGroup);
}
};

View File

@ -5,7 +5,7 @@
#include <Tactility/settings/WebServerSettings.h>
#include <Tactility/MountPoints.h>
#include <Tactility/file/File.h>
#include <Tactility/lvgl/Statusbar.h>
#include <lvgl/widgets/statusbar.h>
#include <Tactility/Mutex.h>
#include <Tactility/DeprecatedPaths.h>
@ -205,8 +205,8 @@ bool WebServerService::onStart(ServiceContext& service) {
g_webServerInstance.store(this);
// Create statusbar icon (hidden initially, shown when server actually starts)
statusbarIconId = lvgl::statusbar_icon_add();
lvgl::statusbar_icon_set_visibility(statusbarIconId, false);
statusbarIconId = lvgl_statusbar_icon_add(nullptr, false);
lvgl_statusbar_icon_set_visibility(statusbarIconId, false);
// Load and cache settings once at boot
bool serverEnabled;
@ -249,7 +249,7 @@ void WebServerService::onStop(ServiceContext& service) {
// Remove statusbar icon
if (statusbarIconId >= 0) {
lvgl::statusbar_icon_remove(statusbarIconId);
lvgl_statusbar_icon_remove(statusbarIconId);
statusbarIconId = -1;
}
}
@ -513,8 +513,8 @@ bool WebServerService::startServer() {
// Show statusbar icon
if (statusbarIconId >= 0) {
lvgl::statusbar_icon_set_image(statusbarIconId, LVGL_ICON_STATUSBAR_CLOUD);
lvgl::statusbar_icon_set_visibility(statusbarIconId, true);
lvgl_statusbar_icon_set_image(statusbarIconId, LVGL_ICON_STATUSBAR_CLOUD);
lvgl_statusbar_icon_set_visibility(statusbarIconId, true);
LOG_I(TAG, "WebServer statusbar icon shown (%s mode)",
settings.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station");
}
@ -543,7 +543,7 @@ void WebServerService::stopServer() {
publish_event(this, WebServerEvent::WebServerStopped);
if (statusbarIconId >= 0) {
lvgl::statusbar_icon_set_visibility(statusbarIconId, false);
lvgl_statusbar_icon_set_visibility(statusbarIconId, false);
}
}

View File

@ -1,70 +1,20 @@
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/settings/RadioSettings.h>
#include <tactility/device.h>
#include <tactility/log.h>
#include <cstring>
#include <format>
#include <map>
#include <string>
#include <tactility/radio_settings.h>
namespace tt::settings::radio {
constexpr auto* TAG = "RadioSettings";
constexpr auto* FILE_PATH_FORMAT = "{}/settings/radio.properties";
constexpr auto* KEY_REGION = "region";
static std::string getFilePath() {
return std::format(FILE_PATH_FORMAT, getDataPath());
}
RadioRegion loadRegion() {
const auto path = getFilePath();
std::map<std::string, std::string> map;
if (!file::isFile(path) || !file::loadPropertiesFile(path, map)) {
return RADIO_REGION_UNSET;
}
const auto entry = map.find(KEY_REGION);
if (entry == map.end()) {
return RADIO_REGION_UNSET;
}
for (int region = RADIO_REGION_UNSET; region < RADIO_REGION_COUNT; region++) {
if (entry->second == radio_region_to_string(static_cast<RadioRegion>(region))) {
return static_cast<RadioRegion>(region);
}
}
LOG_W(TAG, "Unknown region \"%s\"", entry->second.c_str());
return RADIO_REGION_UNSET;
}
static void applyToDevices(RadioRegion region) {
device_for_each_of_type(&RADIO_TYPE, &region, [](Device* device, void* context) {
radio_set_region(device, *static_cast<RadioRegion*>(context));
return true;
});
return radio_settings_load_region();
}
bool saveRegion(RadioRegion region) {
const auto path = getFilePath();
std::map<std::string, std::string> map;
map[KEY_REGION] = radio_region_to_string(region);
if (!file::findOrCreateParentDirectory(path, 0755) || !file::savePropertiesFile(path, map)) {
LOG_E(TAG, "Failed to save %s", path.c_str());
return false;
}
applyToDevices(region);
return true;
return radio_settings_save_region(region) == ERROR_NONE;
}
void applyRegion() {
const auto region = loadRegion();
if (region != RADIO_REGION_UNSET) {
LOG_I(TAG, "Region %s", radio_region_to_string(region));
applyToDevices(region);
}
radio_settings_apply_region();
}
const char* getRegionLabel(RadioRegion region) {

View File

@ -7,6 +7,8 @@
#include <tactility/preferences.h>
#include <tactility/system_event.h>
#include <lvgl/widgets/statusbar.h>
#ifdef ESP_PLATFORM
#include <ctime>
#endif
@ -125,6 +127,7 @@ void setTimeFormat24Hour(bool show24Hour) {
properties.timeFormat24h = show24Hour;
saveSystemSettings(properties);
lvgl_statusbar_set_time_format_24h(show24Hour);
}
}

View File

@ -41,6 +41,7 @@ target_link_libraries(TactilityTests PRIVATE
lvgl-module
lvgl-window-manager-module
app-module
app-hub-module
coreutils-module
audio-decoder-module
c-symbols-module

View File

@ -596,6 +596,13 @@ size_t radio_get_region_bands(enum RadioRegion region, const struct RadioRegionB
/** @return a short name for the region, e.g. "EU_868" */
const char* radio_region_to_string(enum RadioRegion region);
/**
* @param[in] name a short region name, e.g. "EU_868"
* @param[out] region the region with that name
* @retval ERROR_NOT_FOUND when no region has that name
*/
error_t radio_region_from_string(const char* name, enum RadioRegion* region);
// endregion
#ifdef __cplusplus

View File

@ -0,0 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/drivers/radio.h>
#include <tactility/error.h>
#ifdef __cplusplus
extern "C" {
#endif
/** @return the persisted regulatory region, RADIO_REGION_UNSET when none was saved */
enum RadioRegion radio_settings_load_region(void);
/**
* Persist the regulatory region and apply it to all radio devices.
* @retval ERROR_NOT_FOUND when the user data storage isn't available
* @retval ERROR_RESOURCE when writing failed
*/
error_t radio_settings_save_region(enum RadioRegion region);
/** Apply the persisted region to all radio devices */
void radio_settings_apply_region(void);
#ifdef __cplusplus
}
#endif

View File

@ -589,6 +589,16 @@ const char* radio_region_to_string(enum RadioRegion region) {
return REGION_INFOS[region].name;
}
error_t radio_region_from_string(const char* name, enum RadioRegion* region) {
for (int i = RADIO_REGION_UNSET; i < RADIO_REGION_COUNT; i++) {
if (strcmp(REGION_INFOS[i].name, name) == 0) {
*region = static_cast<RadioRegion>(i);
return ERROR_NONE;
}
}
return ERROR_NOT_FOUND;
}
// endregion
const struct DeviceType RADIO_TYPE = {

View File

@ -0,0 +1,95 @@
// SPDX-License-Identifier: Apache-2.0
#include <tactility/radio_settings.h>
#include <tactility/device.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h>
#include <tactility/paths.h>
#include <tactility/properties_file.h>
#include <cstdio>
constexpr auto* TAG = "radio_settings";
constexpr auto* KEY_REGION = "region";
static error_t get_settings_paths(char* directory, size_t directory_size, char* path, size_t path_size) {
char data_path[FILE_MAX_PATH_STRING_LENGTH];
const error_t error = paths_get_data_path(data_path, sizeof(data_path));
if (error != ERROR_NONE) {
return error;
}
const int directory_length = snprintf(directory, directory_size, "%s/settings", data_path);
const int path_length = snprintf(path, path_size, "%s/radio.properties", directory);
if (directory_length < 0 || static_cast<size_t>(directory_length) >= directory_size ||
path_length < 0 || static_cast<size_t>(path_length) >= path_size) {
return ERROR_BUFFER_OVERFLOW;
}
return ERROR_NONE;
}
static void apply_to_devices(RadioRegion region) {
device_for_each_of_type(&RADIO_TYPE, &region, [](Device* device, void* context) {
radio_set_region(device, *static_cast<RadioRegion*>(context));
return true;
});
}
extern "C" {
RadioRegion radio_settings_load_region() {
char directory[FILE_MAX_PATH_STRING_LENGTH];
char path[FILE_MAX_PATH_STRING_LENGTH];
if (get_settings_paths(directory, sizeof(directory), path, sizeof(path)) != ERROR_NONE) {
return RADIO_REGION_UNSET;
}
PropertiesFile* file = properties_file_open(path);
if (file == nullptr) {
return RADIO_REGION_UNSET;
}
RadioRegion region = RADIO_REGION_UNSET;
char value[32];
if (properties_file_get(file, KEY_REGION, value, sizeof(value)) == ERROR_NONE &&
radio_region_from_string(value, &region) != ERROR_NONE) {
LOG_W(TAG, "Unknown region \"%s\"", value);
}
properties_file_close(file);
return region;
}
error_t radio_settings_save_region(RadioRegion region) {
char directory[FILE_MAX_PATH_STRING_LENGTH];
char path[FILE_MAX_PATH_STRING_LENGTH];
error_t error = get_settings_paths(directory, sizeof(directory), path, sizeof(path));
if (error != ERROR_NONE) {
return error;
}
error = directory_make(directory, true);
if (error != ERROR_NONE && error != 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_REGION, radio_region_to_string(region));
error = properties_file_close(file);
if (error != ERROR_NONE) {
LOG_E(TAG, "Failed to save %s", path);
return error;
}
apply_to_devices(region);
return ERROR_NONE;
}
void radio_settings_apply_region() {
const RadioRegion region = radio_settings_load_region();
if (region != RADIO_REGION_UNSET) {
LOG_I(TAG, "Region %s", radio_region_to_string(region));
apply_to_devices(region);
}
}
}

View File

@ -54,6 +54,7 @@
#include <tactility/paths.h>
#include <tactility/preferences.h>
#include <tactility/properties_file.h>
#include <tactility/radio_settings.h>
#include <tactility/wifi_auto_scan.h>
#ifndef ESP_PLATFORM
@ -454,6 +455,7 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(radio_get_region),
DEFINE_MODULE_SYMBOL(radio_get_region_bands),
DEFINE_MODULE_SYMBOL(radio_region_to_string),
DEFINE_MODULE_SYMBOL(radio_region_from_string),
DEFINE_MODULE_SYMBOL(radio_state_event_subscribe),
DEFINE_MODULE_SYMBOL(radio_state_event_unsubscribe),
DEFINE_MODULE_SYMBOL(radio_state_event_poll),
@ -623,6 +625,10 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(properties_file_get),
DEFINE_MODULE_SYMBOL(properties_file_set),
DEFINE_MODULE_SYMBOL(properties_file_for_each),
// radio_settings
DEFINE_MODULE_SYMBOL(radio_settings_load_region),
DEFINE_MODULE_SYMBOL(radio_settings_save_region),
DEFINE_MODULE_SYMBOL(radio_settings_apply_region),
// terminator
MODULE_SYMBOL_TERMINATOR,
};

View File

@ -0,0 +1,97 @@
#include "doctest.h"
#include <tactility/drivers/radio.h>
#include <tactility/filesystem/file_system.h>
#include <tactility/radio_settings.h>
#include <cstdio>
#include <cstring>
#include <filesystem>
namespace {
const char* TEST_ROOT = "/tmp/tactility_kernel_radio_settings_test";
const char* SETTINGS_PATH = "/tmp/tactility_kernel_radio_settings_test/tactility/settings/radio.properties";
error_t fake_mount(void*) { return ERROR_NONE; }
error_t fake_unmount(void*) { return ERROR_NONE; }
bool fake_is_mounted(void*) { return false; }
error_t fake_get_path(void* data, char* out_path, size_t out_path_size) {
const char* path = static_cast<const char*>(data);
if (std::strlen(path) + 1 > out_path_size) {
return ERROR_BUFFER_OVERFLOW;
}
std::strcpy(out_path, path);
return ERROR_NONE;
}
const FileSystemApi fake_data_api = {
.mount = fake_mount,
.unmount = fake_unmount,
.is_mounted = fake_is_mounted,
.get_path = fake_get_path,
};
// Registers a "data" filesystem in a scratch directory for the lifetime of one test case
struct FakeDataFs {
FileSystem* fs;
FakeDataFs() {
std::filesystem::remove_all(TEST_ROOT);
fs = file_system_add("data", &fake_data_api, const_cast<char*>(TEST_ROOT));
}
~FakeDataFs() {
file_system_remove(fs);
std::filesystem::remove_all(TEST_ROOT);
}
};
void write_settings(const char* content) {
std::filesystem::create_directories(std::filesystem::path(SETTINGS_PATH).parent_path());
FILE* file = std::fopen(SETTINGS_PATH, "w");
REQUIRE(file != nullptr);
std::fputs(content, file);
std::fclose(file);
}
} // namespace
TEST_CASE("radio_region_from_string resolves every region name") {
for (int i = RADIO_REGION_UNSET; i < RADIO_REGION_COUNT; i++) {
RadioRegion region = RADIO_REGION_COUNT;
CHECK_EQ(radio_region_from_string(radio_region_to_string(static_cast<RadioRegion>(i)), &region), ERROR_NONE);
CHECK_EQ(region, static_cast<RadioRegion>(i));
}
RadioRegion region = RADIO_REGION_EU_868;
CHECK_EQ(radio_region_from_string("MARS_123", &region), ERROR_NOT_FOUND);
CHECK_EQ(region, RADIO_REGION_EU_868);
}
TEST_CASE("radio_settings_load_region is unset without a settings file") {
FakeDataFs fake;
CHECK_EQ(radio_settings_load_region(), RADIO_REGION_UNSET);
}
TEST_CASE("radio_settings_save_region persists the region") {
FakeDataFs fake;
CHECK_EQ(radio_settings_save_region(RADIO_REGION_US_915), ERROR_NONE);
CHECK_EQ(radio_settings_load_region(), RADIO_REGION_US_915);
}
TEST_CASE("radio_settings_load_region reads the existing file format") {
FakeDataFs fake;
write_settings("region=EU_868\n");
CHECK_EQ(radio_settings_load_region(), RADIO_REGION_EU_868);
}
TEST_CASE("radio_settings_load_region ignores an unknown region") {
FakeDataFs fake;
write_settings("region=MARS_123\n");
CHECK_EQ(radio_settings_load_region(), RADIO_REGION_UNSET);
}
TEST_CASE("radio_settings_save_region fails without user data storage") {
CHECK_EQ(radio_settings_save_region(RADIO_REGION_US_915), ERROR_NOT_FOUND);
}

View File

@ -10,6 +10,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/TactilityKernel/tests ${CMAKE_CURRENT_BINAR
add_subdirectory(${CMAKE_SOURCE_DIR}/Tactility/Tests ${CMAKE_CURRENT_BINARY_DIR}/Tactility)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_BINARY_DIR}/crypt-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-hub-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-hub-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-posix-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/binfont-module/tests ${CMAKE_CURRENT_BINARY_DIR}/binfont-module)
@ -28,6 +29,7 @@ add_dependencies(build-tests TactilityTests)
add_dependencies(build-tests TactilityKernelTests)
add_dependencies(build-tests CryptModuleTests)
add_dependencies(build-tests AppModuleTests)
add_dependencies(build-tests AppHubModuleTests)
add_dependencies(build-tests AppPosixModuleTests)
add_dependencies(build-tests HttpModuleTests)
add_dependencies(build-tests BinFontModuleTests)