mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-10-17 02:45:05 +00:00
Merge branch 'main' into location-service
This commit is contained in:
commit
080aa088cf
11
.claude/rules/claude-builds.md
Normal file
11
.claude/rules/claude-builds.md
Normal 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.
|
||||
@ -1,4 +1,5 @@
|
||||
app-module
|
||||
app-hub-module
|
||||
audio-decoder-module
|
||||
cjson-module
|
||||
crypt-module
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -51,6 +51,7 @@ language ea07
|
||||
lightbulb e90f
|
||||
lightstrip fff75
|
||||
link e250
|
||||
list_alt e0ee
|
||||
lists e9b9
|
||||
location_off e0c7
|
||||
location_on f1db
|
||||
|
||||
Binary file not shown.
@ -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>;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
12
Modules/app-hub-module/CMakeLists.txt
Normal file
12
Modules/app-hub-module/CMakeLists.txt
Normal 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
|
||||
)
|
||||
195
Modules/app-hub-module/LICENSE-Apache-2.0.md
Normal file
195
Modules/app-hub-module/LICENSE-Apache-2.0.md
Normal file
@ -0,0 +1,195 @@
|
||||
Apache License
|
||||
==============
|
||||
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
|
||||
### 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.
|
||||
|
||||
14
Modules/app-hub-module/README.md
Normal file
14
Modules/app-hub-module/README.md
Normal 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)
|
||||
193
Modules/app-hub-module/include/app_hub/app_hub.h
Normal file
193
Modules/app-hub-module/include/app_hub/app_hub.h
Normal 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
|
||||
14
Modules/app-hub-module/include/app_hub/module.h
Normal file
14
Modules/app-hub-module/include/app_hub/module.h
Normal 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
|
||||
38
Modules/app-hub-module/private/app_hub/app_hub_catalog.h
Normal file
38
Modules/app-hub-module/private/app_hub/app_hub_catalog.h
Normal 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);
|
||||
12
Modules/app-hub-module/private/app_hub/app_hub_service.h
Normal file
12
Modules/app-hub-module/private/app_hub/app_hub_service.h
Normal 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();
|
||||
181
Modules/app-hub-module/source/app_hub_catalog.cpp
Normal file
181
Modules/app-hub-module/source/app_hub_catalog.cpp
Normal 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);
|
||||
}
|
||||
705
Modules/app-hub-module/source/app_hub_service.cpp
Normal file
705
Modules/app-hub-module/source/app_hub_service.cpp
Normal 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
|
||||
|
||||
}
|
||||
48
Modules/app-hub-module/source/module.cpp
Normal file
48
Modules/app-hub-module/source/module.cpp
Normal 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,
|
||||
};
|
||||
|
||||
}
|
||||
23
Modules/app-hub-module/tests/CMakeLists.txt
Normal file
23
Modules/app-hub-module/tests/CMakeLists.txt
Normal 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
|
||||
)
|
||||
256
Modules/app-hub-module/tests/source/app_hub_test.cpp
Normal file
256
Modules/app-hub-module/tests/source/app_hub_test.cpp
Normal 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);
|
||||
}
|
||||
@ -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)));
|
||||
}
|
||||
47
Modules/app-hub-module/tests/source/main.cpp
Normal file
47
Modules/app-hub-module/tests/source/main.cpp
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
69
Modules/lvgl-module/include/lvgl/widgets/statusbar.h
Normal file
69
Modules/lvgl-module/include/lvgl/widgets/statusbar.h
Normal 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
|
||||
@ -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]);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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();
|
||||
|
||||
258
Modules/mesh-module/source/mesh_command.cpp
Normal file
258
Modules/mesh-module/source/mesh_command.cpp
Normal 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], ®ion) != 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 },
|
||||
};
|
||||
|
||||
}
|
||||
@ -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[] = {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::apppackagedetails {
|
||||
|
||||
void start(const std::string& packageId);
|
||||
|
||||
} // namespace
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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, ®ion, [](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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
26
TactilityKernel/include/tactility/radio_settings.h
Normal file
26
TactilityKernel/include/tactility/radio_settings.h
Normal 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
|
||||
@ -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 = {
|
||||
|
||||
95
TactilityKernel/source/radio_settings.cpp
Normal file
95
TactilityKernel/source/radio_settings.cpp
Normal 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, ®ion, [](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, ®ion) != 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
97
TactilityKernel/tests/source/radio_settings_test.cpp
Normal file
97
TactilityKernel/tests/source/radio_settings_test.cpp
Normal 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)), ®ion), ERROR_NONE);
|
||||
CHECK_EQ(region, static_cast<RadioRegion>(i));
|
||||
}
|
||||
RadioRegion region = RADIO_REGION_EU_868;
|
||||
CHECK_EQ(radio_region_from_string("MARS_123", ®ion), 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);
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user