Simulator profiles and orientation, ESP-NOW move, tt project generation, App Hub crash fix (#703)

This commit is contained in:
Ken Van Hoeylandt 2026-10-19 22:47:39 +02:00 committed by GitHub
parent 0e94c7b678
commit 9f5d6e5815
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 2157 additions and 720 deletions

View File

@ -9,6 +9,11 @@ Two kernel modules cover LVGL:
- **`lvgl-window-manager-module`** (`Modules/lvgl-window-manager-module/`, `<lvgl_window_manager/*.h>`) manages a single stacked window per app instance on top of `lvgl-module`. `window_manager_start()`/`window_manager_stop()` create/tear down the root widget (plus optional chrome from a configured `WindowManagerScreenInitFn`); `window_manager_create()`/`window_manager_remove()` push/pop an app's window and (re)populate it via a `WindowCreateWidgetsFn`. Only the topmost window ever has live widgets - burying and resurfacing a window deletes and rebuilds its widget tree rather than hiding/showing it. That rebuild-on-remove path can run `create_widgets` on a *different* app's thread (whichever app's `window_manager_remove()` call caused this window to resurface), so `create_widgets` must only rebuild already-committed state, never decide what happens next - state transitions belong in the app's own `main()` event loop, driven by real `APP_EVENT_RESULT`s. - **`lvgl-window-manager-module`** (`Modules/lvgl-window-manager-module/`, `<lvgl_window_manager/*.h>`) manages a single stacked window per app instance on top of `lvgl-module`. `window_manager_start()`/`window_manager_stop()` create/tear down the root widget (plus optional chrome from a configured `WindowManagerScreenInitFn`); `window_manager_create()`/`window_manager_remove()` push/pop an app's window and (re)populate it via a `WindowCreateWidgetsFn`. Only the topmost window ever has live widgets - burying and resurfacing a window deletes and rebuilds its widget tree rather than hiding/showing it. That rebuild-on-remove path can run `create_widgets` on a *different* app's thread (whichever app's `window_manager_remove()` call caused this window to resurface), so `create_widgets` must only rebuild already-committed state, never decide what happens next - state transitions belong in the app's own `main()` event loop, driven by real `APP_EVENT_RESULT`s.
Widget pointers an app keeps become dangling once its window is buried (e.g. by a dialog it started). Use `window_manager_create_ext()` with a `destroy_widgets` callback that clears them, and skip widget updates from the app's own thread while they're cleared. Widget pointers an app keeps become dangling once its window is buried (e.g. by a dialog it started). Use `window_manager_create_ext()` with a `destroy_widgets` callback that clears them, and skip widget updates from the app's own thread while they're cleared.
## Lists in cards
Every list item has a divider below it, so the last one lies on the border of the card that holds the list.
Call `lvgl_list_hide_last_divider()` (`lvgl/widgets/list.h`) on the list, or on the card when the items are its direct children, after the last item was added.
## No file operations on the LVGL thread ## No file operations on the LVGL thread
Code that runs on the LVGL thread must not do file operations (including reading or writing settings): `create_widgets`, LVGL event callbacks, timers, and anything under the LVGL lock. Code that runs on the LVGL thread must not do file operations (including reading or writing settings): `create_widgets`, LVGL event callbacks, timers, and anything under the LVGL lock.

View File

@ -30,11 +30,24 @@ runs:
cp -r release/TactilitySDK $TACTILITY_PLATFORM_PATH/TactilitySDK cp -r release/TactilitySDK $TACTILITY_PLATFORM_PATH/TactilitySDK
mkdir -p test_sdk/sdk/$(cat version.txt)/tools mkdir -p test_sdk/sdk/$(cat version.txt)/tools
cp Buildscripts/TactilitySDK/Tools/*.py test_sdk/sdk/$(cat version.txt)/tools/ cp Buildscripts/TactilitySDK/Tools/*.py test_sdk/sdk/$(cat version.txt)/tools/
python -c "import sys; sys.path.insert(0, 'Buildscripts/CDN'); from new_project import create_new_project_zip; create_new_project_zip('test_sdk/sdk/$(cat version.txt)')"
- name: 'Test Integration' - name: 'Test Integration'
shell: bash shell: bash
env: env:
TACTILITY_ARCH: ${{ steps.arch.outputs.value }} TACTILITY_ARCH: ${{ steps.arch.outputs.value }}
run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python ../../tt.py app build -a posix-$TACTILITY_ARCH run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python ../../tt.py app build -a posix-$TACTILITY_ARCH
- name: 'Test New Project'
shell: bash
env:
TACTILITY_ARCH: ${{ steps.arch.outputs.value }}
run: |
SDK_VERSION=$(cat version.txt)
# The answers are: generate, SDK version, package id, app id, app name and terminal app. An empty line takes the default.
for terminal in "" "y"; do
project_path="$RUNNER_TEMP/new-project-${terminal:-gui}"
mkdir -p "$project_path"
(cd "$project_path" && printf 'y\n%s\n\n\n\n%s\n' "$SDK_VERSION" "$terminal" | TACTILITY_SDK_PATH="$GITHUB_WORKSPACE/test_sdk" python "$GITHUB_WORKSPACE/tt.py" app build -a posix-$TACTILITY_ARCH)
done
- name: 'Upload Artifact' - name: 'Upload Artifact'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:

View File

@ -40,6 +40,7 @@ runs:
cp -r release/TactilitySDK $TACTILITY_PLATFORM_PATH/TactilitySDK cp -r release/TactilitySDK $TACTILITY_PLATFORM_PATH/TactilitySDK
mkdir -p test_sdk/sdk/$(cat version.txt)/tools mkdir -p test_sdk/sdk/$(cat version.txt)/tools
cp Buildscripts/TactilitySDK/Tools/*.py test_sdk/sdk/$(cat version.txt)/tools/ cp Buildscripts/TactilitySDK/Tools/*.py test_sdk/sdk/$(cat version.txt)/tools/
python -c "import sys; sys.path.insert(0, 'Buildscripts/CDN'); from new_project import create_new_project_zip; create_new_project_zip('test_sdk/sdk/$(cat version.txt)')"
- name: 'Test Integration' - name: 'Test Integration'
uses: espressif/esp-idf-ci-action@v1 uses: espressif/esp-idf-ci-action@v1
with: with:

View File

@ -8,6 +8,8 @@ import json
import shutil import shutil
import zipfile import zipfile
from new_project import create_new_project_zip
VERBOSE = False VERBOSE = False
TOOLS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "Tools") TOOLS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "Tools")
@ -18,6 +20,7 @@ class SdkIndex:
gitCommit: str gitCommit: str
platforms: dict platforms: dict
tools: dict tools: dict
newProject: dict
shell_color_red = "\033[91m" shell_color_red = "\033[91m"
shell_color_orange = "\033[93m" shell_color_orange = "\033[93m"
@ -69,7 +72,8 @@ def main(in_path: str, out_path: str, version: str):
created=datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%S'), created=datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%S'),
gitCommit=get_git_commit_hash(), gitCommit=get_git_commit_hash(),
platforms={}, platforms={},
tools=create_tools_bundle(out_path) tools=create_tools_bundle(out_path),
newProject=create_new_project_zip(out_path)
) )
for artifact_directory in artifact_directories: for artifact_directory in artifact_directories:
if VERBOSE: if VERBOSE:

View File

@ -0,0 +1,19 @@
import hashlib
import os
import zipfile
NEW_PROJECT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "TactilitySDK", "NewProject")
ARCHIVE_NAME = "new-project.zip"
def create_new_project_zip(out_path: str):
"""Zips the project templates that tt.py generates a new app from. Returns the entry of index.json."""
archive_path = os.path.join(out_path, ARCHIVE_NAME)
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
for directory, directory_names, file_names in os.walk(NEW_PROJECT_PATH):
directory_names.sort()
for file_name in sorted(file_names):
file_path = os.path.join(directory, file_name)
archive.write(file_path, os.path.relpath(file_path, NEW_PROJECT_PATH))
with open(archive_path, "rb") as archive:
sha256 = hashlib.sha256(archive.read()).hexdigest()
return {"file": ARCHIVE_NAME, "sha256": sha256}

View File

@ -0,0 +1,4 @@
include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake")
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
tactility_component_register(SRCS ${SOURCE_FILES})

View File

@ -0,0 +1,51 @@
#include <app/event.h>
#include <app/scheduler.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <stdbool.h>
static void create_widgets(lv_obj_t* parent, void* user_data) {
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Hello World");
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_t* label = lv_label_create(parent);
lv_label_set_text(label, "Hello, world!");
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
}
int main(int argc, char* argv[]) {
AppInstanceId app_instance_id = app_scheduler_current_app_id();
struct TaskEventGroup event_group = {0};
task_event_group_construct(&event_group);
struct AppEventSubscription sub = {0};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(app_instance_id, create_widgets, NULL);
bool should_close = false;
while (!should_close) {
task_event_group_wait_any(&event_group, NULL, portMAX_DELAY);
struct AppEvent event;
while (app_event_poll(&sub, &event) == ERROR_NONE) {
if (event.type == APP_EVENT_CLOSE) {
should_close = true;
break;
}
}
}
window_manager_remove(window);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);
return 0;
}

View File

@ -0,0 +1,9 @@
manifest.version=0.3
target.sdk=@SDK_VERSION@
target.platforms=all
id=@PACKAGE_ID@
version.name=0.1.0
version.code=1
app.0.id=@APP_ID@
app.0.name=@APP_NAME@
app.0.binary=main

View File

@ -0,0 +1,4 @@
include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake")
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
tactility_component_register(SRCS ${SOURCE_FILES})

View File

@ -0,0 +1,6 @@
#include <stdio.h>
int main(int argc, char* argv[]) {
printf("Hello, world!\n");
return 0;
}

View File

@ -0,0 +1,10 @@
manifest.version=0.3
target.sdk=@SDK_VERSION@
target.platforms=all
id=@PACKAGE_ID@
version.name=0.1.0
version.code=1
app.0.id=@APP_ID@
app.0.name=@APP_NAME@
app.0.binary=main
app.0.headless=true

View File

@ -46,7 +46,7 @@ def print_help():
print(" Parameters:") print(" Parameters:")
print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print(" (optional) --host, -h [input] Defaults to the host of 'tt host'")
print("") print("")
print(" install Install the application") print(" install [app file] Install the application, or the given .app file")
print(" Parameters:") print(" Parameters:")
print(" (optional) --host, -h [input] Defaults to the host of 'tt host'") print(" (optional) --host, -h [input] Defaults to the host of 'tt host'")
print("") print("")
@ -227,6 +227,8 @@ def parse_command_line(argv):
args[canonical] = "true" args[canonical] = "true"
elif action is None: elif action is None:
action = token action = token
elif action == "install" and "app-file" not in args:
args["app-file"] = token
else: else:
exit_with_error(f"Unexpected argument: {token}") exit_with_error(f"Unexpected argument: {token}")
index += 1 index += 1
@ -731,18 +733,41 @@ def run_action(manifest, arguments):
device.run(host, device.DEFAULT_PORT, app_id) device.run(host, device.DEFAULT_PORT, app_id)
print_status_success("Running") print_status_success("Running")
def install_action(manifest, arguments): def install_package(host, package_path):
print_status_busy("Installing")
device = load_device_client()
device.install(host, device.DEFAULT_PORT, package_path)
print_status_success("Installing")
def install_with_manifest_action(manifest, arguments):
host = get_host(arguments) host = get_host(arguments)
package_path = package_name(manifest) package_path = package_name(manifest)
if not os.path.isfile(package_path): if not os.path.isfile(package_path):
print_status_error(f"Package not found: {package_path} (run 'build' first)") print_status_error(f"Package not found: {package_path} (run 'build' first)")
return False return False
print_status_busy("Installing") install_package(host, package_path)
device = load_device_client()
device.install(host, device.DEFAULT_PORT, package_path)
print_status_success("Installing")
return True return True
def install_with_app_file_action(app_file, arguments):
host = get_host(arguments)
if not os.path.isfile(app_file):
print_status_error(f"App file not found: {app_file}")
return False
install_package(host, app_file)
return True
def is_app_file(path):
return path.endswith(".app")
def install_action(manifest, arguments):
app_file = arguments.get("app-file")
if app_file is None:
return install_with_manifest_action(manifest, arguments)
if not is_app_file(app_file):
print_status_error(f"Not an app file: {app_file}")
return False
return install_with_app_file_action(app_file, arguments)
def uninstall_action(manifest, arguments): def uninstall_action(manifest, arguments):
host = get_host(arguments) host = get_host(arguments)
app_id = get_package_id(manifest) app_id = get_package_id(manifest)
@ -778,6 +803,12 @@ if __name__ == "__main__":
verbose = parsed_command.arguments.has("verbose") verbose = parsed_command.arguments.has("verbose")
use_ccache = not parsed_command.arguments.has("no-ccache") use_ccache = not parsed_command.arguments.has("no-ccache")
# Installing an app file doesn't need an app project
if parsed_command.arguments.has("app-file"):
if not install_action(None, parsed_command.arguments):
sys.exit(1)
sys.exit()
app_path = parsed_command.arguments.get("path") app_path = parsed_command.arguments.get("path")
if app_path is not None: if app_path is not None:
if not os.path.isdir(app_path) or not os.path.isfile(os.path.join(app_path, "manifest.properties")): if not os.path.isdir(app_path) or not os.path.isfile(os.path.join(app_path, "manifest.properties")):

View File

@ -1,10 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Exports the tools that tt runs to release/sdk/<version>/tools/, for tt with TACTILITY_SDK_PATH=release.""" """Exports the tools that tt runs to release/sdk/<version>/tools/ and the new project to release/sdk/<version>/new-project.zip, for tt with TACTILITY_SDK_PATH=release."""
import os import os
import shutil import shutil
import sys
ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
sys.path.insert(0, os.path.join(ROOT_PATH, "Buildscripts", "CDN"))
from new_project import create_new_project_zip
TOOLS_PATH = os.path.join(ROOT_PATH, "Buildscripts", "TactilitySDK", "Tools") TOOLS_PATH = os.path.join(ROOT_PATH, "Buildscripts", "TactilitySDK", "Tools")
@ -17,4 +21,6 @@ if __name__ == "__main__":
for file_name in sorted(os.listdir(TOOLS_PATH)): for file_name in sorted(os.listdir(TOOLS_PATH)):
if file_name.endswith(".py"): if file_name.endswith(".py"):
shutil.copy2(os.path.join(TOOLS_PATH, file_name), os.path.join(output_path, file_name)) shutil.copy2(os.path.join(TOOLS_PATH, file_name), os.path.join(output_path, file_name))
# Next to the tools, where tt looks for the new project of a local SDK
create_new_project_zip(os.path.dirname(output_path))
print(f"Exported the tools to {output_path}") print(f"Exported the tools to {output_path}")

View File

@ -12,6 +12,7 @@
#include <bindings/bm8563.h> #include <bindings/bm8563.h>
#include <bindings/ssd16xx.h> #include <bindings/ssd16xx.h>
#include <bindings/sx1262.h>
/ { / {
compatible = "root"; compatible = "root";
@ -79,7 +80,7 @@
frequency-khz = <20000>; frequency-khz = <20000>;
}; };
epd@1 { display@1 {
compatible = "solomon-systech,ssd16xx"; compatible = "solomon-systech,ssd16xx";
pin-dc = <&gpio0 13 GPIO_FLAG_NONE>; pin-dc = <&gpio0 13 GPIO_FLAG_NONE>;
pin-reset = <&gpio0 12 GPIO_FLAG_NONE>; pin-reset = <&gpio0 12 GPIO_FLAG_NONE>;
@ -90,4 +91,28 @@
rotation = <1>; rotation = <1>;
}; };
}; };
// Optional Mesh Card with a Seeed Wio-SX1262 module, detected at runtime.
// Schematic: https://github.com/CL-32/CL-32/tree/main/Add-on/CL-32-Mesh
spi2 {
compatible = "espressif,esp32-spi";
host = <SPI3_HOST>;
cs-gpios = <&gpio0 38 GPIO_FLAG_NONE>;
pin-mosi = <&gpio0 17 GPIO_FLAG_NONE>;
pin-miso = <&gpio0 16 GPIO_FLAG_NONE>;
pin-sclk = <&gpio0 18 GPIO_FLAG_NONE>;
// The module is powered by the 3.3 V rail. DIO3 powers the TCXO (1.7 V to 3.3 V, at least 200 mV
// below VCC). DIO2 enables the TX path of the RF switch and RF_SW (GPIO 15) the RX path.
radio@0 {
compatible = "semtech,sx1262";
reg = <0>;
pin-reset = <&gpio0 21 GPIO_FLAG_NONE>;
pin-dio1 = <&gpio0 39 GPIO_FLAG_NONE>;
pin-busy = <&gpio0 40 GPIO_FLAG_NONE>;
pin-rx-enable = <&gpio0 15 GPIO_FLAG_NONE>;
tcxo-millivolts = <1800>;
dio2-as-rf-switch;
};
};
}; };

View File

@ -3,4 +3,5 @@ dependencies:
- Drivers/tca8418-module - Drivers/tca8418-module
- Drivers/ssd16xx-module - Drivers/ssd16xx-module
- Drivers/bm8563-module - Drivers/bm8563-module
- Drivers/sx126x-module
dts: cl32-v02-v03.dts dts: cl32-v02-v03.dts

View File

@ -16,6 +16,9 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
PRIVATE TactilityKernel PRIVATE TactilityKernel
PRIVATE TactilityFreeRtos PRIVATE TactilityFreeRtos
PRIVATE lvgl PRIVATE lvgl
PRIVATE app-module
PRIVATE lvgl-module
PRIVATE lvgl-window-manager-module
PRIVATE SDL2-static PRIVATE SDL2-static
) )

View File

@ -1,6 +1,7 @@
#pragma once #pragma once
#include "Main.h" #include "Main.h"
#include "SimulatorProfile.h"
#include "drivers/sdl_bridge.h" #include "drivers/sdl_bridge.h"
#include <csignal> #include <csignal>
@ -19,7 +20,8 @@ extern "C" {
void app_main(); // ESP-IDF's main function, implemented in the application void app_main(); // ESP-IDF's main function, implemented in the application
} }
int main() { int main(int, char** argv) {
simulator::setProcessArguments(argv);
// The folder for the "data" file system is not in the repository // The folder for the "data" file system is not in the repository
mkdir("data", 0755); mkdir("data", 0755);

View File

@ -0,0 +1,228 @@
#include "SimulatorProfile.h"
#include <tactility/log.h>
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <sys/stat.h>
#include <sys/time.h>
#include <unistd.h>
#ifdef __APPLE__
#include <mach-o/dyld.h>
#endif
namespace simulator {
constexpr auto* TAG = "SimulatorProfile";
constexpr auto* PROFILE_DIRECTORY = "data/tactility";
constexpr auto* PROFILE_PATH = "data/tactility/simulator_profile.txt";
constexpr auto* DISPLAY_SETTINGS_DIRECTORY = "data/tactility/user/app/tactility.display";
constexpr auto* DISPLAY_SETTINGS_PATH = "data/tactility/user/app/tactility.display/display.properties";
constexpr auto* ORIENTATION_KEY = "orientation=";
static const Profile PROFILES[PROFILE_COUNT] = {
{ "Default", 640, 320, false, false, false, true, true },
{ "CL-32", 384, 168, false, true, true, true, false },
{ "LilyGO T-Deck", 320, 240, false, false, false, true, true },
{ "LilyGO T-Lora Pager", 480, 222, false, false, false, true, false },
{ "M5Stack Cardputer", 240, 135, false, true, false, true, false },
// Half the res on each axis to compensate for DPI differences
{ "M5Stack Tab5", 640, 360, true, false, false, true, true },
};
static size_t activeProfileIndex = 0;
static OrientationOverride activeOrientation = OrientationOverride::Default;
static char** processArguments = nullptr;
/** Absolute, because argv[0] can be a bare name that was found through PATH. Empty when it can't be resolved. */
static std::string executablePath;
const Profile& getProfile(size_t index) {
return PROFILES[index < PROFILE_COUNT ? index : 0];
}
static const char* toString(OrientationOverride orientation) {
switch (orientation) {
case OrientationOverride::Landscape:
return "landscape";
case OrientationOverride::Portrait:
return "portrait";
default:
return "default";
}
}
/** The first line has the profile name, the optional second line the orientation */
static void loadSelection(size_t& profileIndex, OrientationOverride& orientation) {
profileIndex = 0;
orientation = OrientationOverride::Default;
FILE* file = fopen(PROFILE_PATH, "r");
if (file == nullptr) {
return;
}
char name[64] = {};
char orientationText[16] = {};
const bool read = fgets(name, sizeof(name), file) != nullptr;
if (read) {
fgets(orientationText, sizeof(orientationText), file);
}
fclose(file);
if (!read) {
return;
}
name[strcspn(name, "\r\n")] = '\0';
orientationText[strcspn(orientationText, "\r\n")] = '\0';
for (size_t i = 0; i < PROFILE_COUNT; i++) {
if (strcmp(PROFILES[i].name, name) == 0) {
profileIndex = i;
}
}
for (auto candidate : { OrientationOverride::Landscape, OrientationOverride::Portrait }) {
if (strcmp(toString(candidate), orientationText) == 0) {
orientation = candidate;
}
}
}
size_t loadSelectedProfileIndex() {
size_t profileIndex;
OrientationOverride orientation;
loadSelection(profileIndex, orientation);
return profileIndex;
}
OrientationOverride loadSelectedOrientation() {
size_t profileIndex;
OrientationOverride orientation;
loadSelection(profileIndex, orientation);
return orientation;
}
static const char* toDisplaySettingsValue(OrientationOverride orientation) {
return orientation == OrientationOverride::Portrait ? "Portrait" : "Landscape";
}
/** Creates the folders of a path, like "mkdir -p" */
static void makeDirectories(const std::string& path) {
for (size_t position = path.find('/'); position != std::string::npos; position = path.find('/', position + 1)) {
mkdir(path.substr(0, position).c_str(), 0755);
}
mkdir(path.c_str(), 0755);
}
/**
* The Display settings apply their saved orientation to the display. Writes the override there,
* or removes the saved orientation so that the display's own orientation applies.
*/
static void applyOrientationToDisplaySettings(OrientationOverride orientation) {
std::string kept;
if (FILE* file = fopen(DISPLAY_SETTINGS_PATH, "r"); file != nullptr) {
char line[256];
while (fgets(line, sizeof(line), file) != nullptr) {
if (strncmp(line, ORIENTATION_KEY, strlen(ORIENTATION_KEY)) != 0) {
kept += line;
}
}
fclose(file);
}
if (orientation != OrientationOverride::Default) {
kept += std::string(ORIENTATION_KEY) + toDisplaySettingsValue(orientation) + "\n";
} else if (kept.empty() && access(DISPLAY_SETTINGS_PATH, F_OK) != 0) {
return;
}
makeDirectories(DISPLAY_SETTINGS_DIRECTORY);
FILE* file = fopen(DISPLAY_SETTINGS_PATH, "w");
if (file == nullptr) {
LOG_E(TAG, "Failed to write %s", DISPLAY_SETTINGS_PATH);
return;
}
fputs(kept.c_str(), file);
fclose(file);
}
bool saveSelection(size_t profileIndex, OrientationOverride orientation) {
size_t savedProfileIndex;
OrientationOverride savedOrientation;
loadSelection(savedProfileIndex, savedOrientation);
const bool changed = profileIndex != savedProfileIndex || orientation != savedOrientation;
mkdir(PROFILE_DIRECTORY, 0755);
FILE* file = fopen(PROFILE_PATH, "w");
if (file == nullptr) {
LOG_E(TAG, "Failed to write %s", PROFILE_PATH);
return false;
}
const bool written = fprintf(file, "%s\n%s\n", getProfile(profileIndex).name, toString(orientation)) > 0;
if (fclose(file) != 0 || !written) {
return false;
}
if (changed) {
applyOrientationToDisplaySettings(orientation);
}
return true;
}
void initActiveProfile() {
loadSelection(activeProfileIndex, activeOrientation);
LOG_I(TAG, "Profile: %s, orientation: %s", getProfile(activeProfileIndex).name, toString(activeOrientation));
}
size_t getActiveProfileIndex() {
return activeProfileIndex;
}
OrientationOverride getActiveOrientation() {
return activeOrientation;
}
static std::string resolveExecutablePath(const char* argv0) {
char path[PATH_MAX];
#ifdef __APPLE__
uint32_t size = sizeof(path);
char resolved[PATH_MAX];
if (_NSGetExecutablePath(path, &size) == 0 && realpath(path, resolved) != nullptr) {
return resolved;
}
#else
const ssize_t length = readlink("/proc/self/exe", path, sizeof(path) - 1);
if (length > 0) {
path[length] = '\0';
return path;
}
#endif
// Only a path with a slash can be resolved without searching PATH
if (strchr(argv0, '/') != nullptr && realpath(argv0, path) != nullptr) {
return path;
}
return "";
}
void setProcessArguments(char** argv) {
processArguments = argv;
executablePath = resolveExecutablePath(argv[0]);
}
void reboot() {
if (processArguments == nullptr) {
LOG_E(TAG, "Cannot reboot without process arguments");
return;
}
// The FreeRTOS tick timer would outlive exec() and kill the new image with SIGALRM
itimerval previousTimer = {};
itimerval stopTimer = {};
setitimer(ITIMER_REAL, &stopTimer, &previousTimer);
if (!executablePath.empty()) {
execv(executablePath.c_str(), processArguments);
} else {
execvp(processArguments[0], processArguments);
}
LOG_E(TAG, "Failed to reboot: %s", strerror(errno));
// This process keeps running, so it needs its ticks again
setitimer(ITIMER_REAL, &previousTimer, nullptr);
}
}

View File

@ -0,0 +1,64 @@
#pragma once
#include <app/manifest.h>
#include <cstddef>
#include <cstdint>
namespace simulator {
extern const ::AppManifest profileAppManifest;
struct Profile {
const char* name;
uint16_t width;
uint16_t height;
/** The default rotation: the display is taller than wide. The width and height values may be given in either order. */
bool portrait;
/** Compact UI density */
bool compact;
/** Greyscale display, which makes the monochrome theme apply */
bool mono;
bool keyboard;
bool pointer;
};
constexpr size_t PROFILE_COUNT = 6;
/** Overrides the default orientation of the selected profile */
enum class OrientationOverride {
/** The orientation of the profile */
Default,
Landscape,
Portrait
};
const Profile& getProfile(size_t index);
/** Reads the selected profile and makes it the active one. Call once at startup. */
void initActiveProfile();
/** The profile that this run uses */
size_t getActiveProfileIndex();
/** The profile that the next run uses */
size_t loadSelectedProfileIndex();
/** The orientation override that this run uses */
OrientationOverride getActiveOrientation();
/** The orientation override that the next run uses */
OrientationOverride loadSelectedOrientation();
/**
* Saves the profile and orientation for the next run. A change also sets the saved orientation of the Display settings:
* the override, or none for the default so that the profile's own orientation applies.
*/
bool saveSelection(size_t profileIndex, OrientationOverride orientation);
void setProcessArguments(char** argv);
/** Restarts the process. Returns only when that failed. */
void reboot();
}

View File

@ -0,0 +1,292 @@
#include "SimulatorProfile.h"
#include <app/event.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <atomic>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/text_button.h>
#include <lvgl/widgets/toolbar.h>
namespace simulator {
namespace {
constexpr auto* SELECTION_DIALOG_ID = "tactility.selectiondialog";
constexpr const char* ORIENTATION_LABELS[] = { "Default", "Landscape", "Portrait" };
constexpr int ORIENTATION_COUNT = 3;
struct Context {
uint32_t appInstanceId = 0;
TaskEventGroup eventGroup {};
uint32_t selectProfileBit = 0;
uint32_t rebootBit = 0;
uint32_t orientationBit = 0;
/** Set by the pills on the LVGL task, taken by the app's task. -1 when there is none. */
std::atomic<int> requestedOrientation { -1 };
AppInstanceId dialogId = 0;
/** Last read from the profile file, which is not on the LVGL thread */
size_t selectedIndex = 0;
OrientationOverride selectedOrientation = OrientationOverride::Default;
bool saveFailed = false;
lv_obj_t* profileLabel = nullptr;
lv_obj_t* rebootButton = nullptr;
lv_obj_t* orientationChips = nullptr;
lv_obj_t* errorLabel = nullptr;
};
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId);
}
void onSelectProfilePressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(&ctx->eventGroup, ctx->selectProfileBit);
}
void onOrientationPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->requestedOrientation.store(static_cast<int>(lv_obj_get_index(lv_event_get_target_obj(event))));
task_event_group_signal(&ctx->eventGroup, ctx->orientationBit);
}
void onRebootPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(&ctx->eventGroup, ctx->rebootBit);
}
lv_obj_t* createRow(lv_obj_t* parent) {
auto* row = lv_obj_create(parent);
lv_obj_remove_style_all(row);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_scrollable(row, false);
return row;
}
void updateWidgets(Context* ctx) {
if (ctx->profileLabel == nullptr) {
return;
}
lv_label_set_text(ctx->profileLabel, getProfile(ctx->selectedIndex).name);
const bool unchanged = ctx->selectedIndex == getActiveProfileIndex() && ctx->selectedOrientation == getActiveOrientation();
lv_obj_set_state(ctx->rebootButton, LV_STATE_DISABLED, unchanged);
for (int i = 0; i < ORIENTATION_COUNT; i++) {
lv_obj_set_state(lv_obj_get_child(ctx->orientationChips, i), LV_STATE_CHECKED, i == static_cast<int>(ctx->selectedOrientation));
}
lv_obj_set_hidden(ctx->errorLabel, !ctx->saveFailed);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* toolbar = lvgl_toolbar_create(parent, "Simulator");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
auto* card = lvgl_card_create(wrapper);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
auto* profile_row = createRow(card);
auto* profile_column = lv_obj_create(profile_row);
lv_obj_remove_style_all(profile_column);
lv_obj_set_height(profile_column, LV_SIZE_CONTENT);
lv_obj_set_flex_grow(profile_column, 1);
lv_obj_set_flex_flow(profile_column, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scrollable(profile_column, false);
auto* title_label = lv_label_create(profile_column);
lv_label_set_text(title_label, "Profile");
ctx->profileLabel = lv_label_create(profile_column);
lv_obj_set_style_text_font(ctx->profileLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT);
auto* list_button = lvgl_icon_button_create(profile_row);
auto* list_icon = lv_label_create(list_button);
lv_label_set_text(list_icon, LV_SYMBOL_LIST);
lv_obj_add_event_cb(list_button, onSelectProfilePressed, LV_EVENT_SHORT_CLICKED, ctx);
auto* reboot_row = createRow(card);
auto* reboot_label = lv_label_create(reboot_row);
lv_label_set_text(reboot_label, "Reboot to apply");
lv_obj_set_flex_grow(reboot_label, 1);
ctx->rebootButton = lvgl_text_button_create(reboot_row);
auto* reboot_button_label = lv_label_create(ctx->rebootButton);
lv_label_set_text(reboot_button_label, "Reboot");
lv_obj_add_event_cb(ctx->rebootButton, onRebootPressed, LV_EVENT_SHORT_CLICKED, ctx);
ctx->errorLabel = lv_label_create(card);
lv_label_set_text(ctx->errorLabel, "Failed to save the profile");
lv_obj_set_style_text_color(ctx->errorLabel, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT);
lv_obj_set_style_text_font(ctx->errorLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT);
auto* orientation_title = lv_label_create(wrapper);
lv_label_set_text(orientation_title, "Orientation");
auto* orientation_card = lvgl_card_create(wrapper);
lv_obj_set_size(orientation_card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(orientation_card, LV_FLEX_FLOW_COLUMN);
// The pills wrap onto the next row when they don't fit
ctx->orientationChips = lv_obj_create(orientation_card);
lv_obj_set_size(ctx->orientationChips, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(ctx->orientationChips, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_style_bg_opa(ctx->orientationChips, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ctx->orientationChips, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(ctx->orientationChips, 0, LV_STATE_DEFAULT);
lv_obj_set_scrollable(ctx->orientationChips, false);
for (const auto* label : ORIENTATION_LABELS) {
auto* chip = lvgl_chip_create(ctx->orientationChips);
lv_obj_add_event_cb(chip, onOrientationPressed, LV_EVENT_SHORT_CLICKED, ctx);
lv_label_set_text(lv_label_create(chip), label);
}
// Only the container is in the input group: left and right move between the pills
lvgl_grid_navigation_add(ctx->orientationChips);
updateWidgets(ctx);
}
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->profileLabel = nullptr;
ctx->rebootButton = nullptr;
ctx->orientationChips = nullptr;
ctx->errorLabel = nullptr;
}
void startProfileDialog(Context* ctx) {
if (ctx->dialogId != 0) {
return;
}
const char* argv[PROFILE_COUNT + 1] = { "Profile" };
for (size_t i = 0; i < PROFILE_COUNT; i++) {
argv[i + 1] = getProfile(i).name;
}
AppStartContext start_context;
if (app_start_context_from_id(SELECTION_DIALOG_ID, &start_context) != ERROR_NONE) {
return;
}
app_start_context_set_arguments_ext(&start_context, PROFILE_COUNT + 1, argv);
app_start_context_set_parent(&start_context, ctx->appInstanceId);
app_start_with_context(&start_context, &ctx->dialogId);
}
/** A failed save keeps the selection that the next start loads */
void saveAndShow(Context* ctx, size_t profileIndex, OrientationOverride orientation) {
ctx->saveFailed = !saveSelection(profileIndex, orientation);
if (!ctx->saveFailed) {
ctx->selectedIndex = profileIndex;
ctx->selectedOrientation = orientation;
}
lvgl_lock();
updateWidgets(ctx);
lvgl_unlock();
}
// Other results than an index mean that the dialog was dismissed
void onDialogResult(Context* ctx, int32_t selected) {
ctx->dialogId = 0;
if (selected < 0 || selected >= static_cast<int32_t>(PROFILE_COUNT)) {
return;
}
// A new profile starts with its own orientation
saveAndShow(ctx, selected, OrientationOverride::Default);
}
void onOrientationRequest(Context* ctx, int requested) {
if (requested < 0 || requested >= ORIENTATION_COUNT) {
return;
}
saveAndShow(ctx, ctx->selectedIndex, static_cast<OrientationOverride>(requested));
}
int32_t appMain(int argc, char* argv[]) {
Context ctx;
ctx.appInstanceId = app_scheduler_current_app_id();
ctx.selectedIndex = loadSelectedProfileIndex();
ctx.selectedOrientation = loadSelectedOrientation();
task_event_group_construct(&ctx.eventGroup);
check(task_event_group_claim_bit(&ctx.eventGroup, &ctx.selectProfileBit) == ERROR_NONE);
check(task_event_group_claim_bit(&ctx.eventGroup, &ctx.rebootBit) == ERROR_NONE);
check(task_event_group_claim_bit(&ctx.eventGroup, &ctx.orientationBit) == ERROR_NONE);
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &ctx.eventGroup) == ERROR_NONE);
WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
uint32_t flags = 0;
task_event_group_wait_any(&ctx.eventGroup, &flags, portMAX_DELAY);
if (flags & ctx.selectProfileBit) {
startProfileDialog(&ctx);
}
if (flags & ctx.orientationBit) {
onOrientationRequest(&ctx, ctx.requestedOrientation.exchange(-1));
}
if ((flags & ctx.rebootBit) && (ctx.selectedIndex != getActiveProfileIndex() || ctx.selectedOrientation != getActiveOrientation())) {
reboot();
}
AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
if (event.type == APP_EVENT_CLOSE) {
shouldClose = true;
break;
}
if (event.type == APP_EVENT_RESULT) {
if (event.result.launch_id == ctx.dialogId) {
onDialogResult(&ctx, event.result.result);
}
app_manager_stop(event.result.launch_id);
}
}
}
window_manager_remove(window);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&ctx.eventGroup);
return 0;
}
} // namespace
extern const ::AppManifest profileAppManifest = {
.id = "tactility.simulator",
.name = "Simulator",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = 0,
.stack = {}
};
}

View File

@ -10,27 +10,82 @@
#include <SDL2/SDL.h> #include <SDL2/SDL.h>
#include <atomic>
#include <cstdlib> #include <cstdlib>
#include <new>
#include <vector>
constexpr auto* TAG = "SdlDisplay"; constexpr auto* TAG = "SdlDisplay";
#define GET_CONFIG(device) (static_cast<const SdlDisplayConfig*>((device)->config)) #define GET_CONFIG(device) (static_cast<const SdlDisplayConfig*>((device)->config))
struct SdlDisplayInternal { struct SdlDisplayInternal {
bool initialized; bool initialized = false;
bool init_failed; bool init_failed = false;
SDL_Window* window; SDL_Window* window = nullptr;
SDL_Renderer* renderer; SDL_Renderer* renderer = nullptr;
SDL_Texture* texture; SDL_Texture* texture = nullptr;
// The window shows the oriented view: with swap_xy its width and height are swapped.
// The requested state is set by the LVGL task when it rotates the display. The shown state is
// only touched by the real main thread, which applies the request before it draws.
std::atomic<bool> requested_swap_xy { false };
std::atomic<bool> requested_mirror_x { false };
std::atomic<bool> requested_mirror_y { false };
bool shown_swap_xy = false;
const SdlDisplayConfig* config = nullptr;
}; };
// Only one sdl-display device exists in the simulator; sdl_input.cpp uses this to map window // Only one sdl-display device exists in the simulator; sdl_input.cpp uses this to map window
// coordinates back to the fixed logical resolution SDL_RenderSetLogicalSize() scales to the window. // coordinates back to the fixed logical resolution SDL_RenderSetLogicalSize() scales to the window.
static SdlDisplayInternal* g_display_internal = nullptr; static SdlDisplayInternal* g_display_internal = nullptr;
// The size of the oriented view that the window shows
static void sdl_display_compute_view_size(const SdlDisplayConfig* config, bool swap_xy, int* width, int* height) {
*width = swap_xy ? config->vertical_resolution : config->horizontal_resolution;
*height = swap_xy ? config->horizontal_resolution : config->vertical_resolution;
}
SDL_Renderer* sdl_display_get_renderer(void) { SDL_Renderer* sdl_display_get_renderer(void) {
return g_display_internal != nullptr ? g_display_internal->renderer : nullptr; return g_display_internal != nullptr ? g_display_internal->renderer : nullptr;
} }
bool sdl_display_get_view_size(int* width, int* height) {
SdlDisplayInternal* internal = g_display_internal;
if (internal == nullptr || internal->config == nullptr) {
return false;
}
sdl_display_compute_view_size(internal->config, internal->requested_swap_xy.load(), width, height);
return true;
}
// LVGL turns a native position (px, py) into a position in the rotated view (lx, ly), with W and H
// the native size: 90 degrees is (H - 1 - py, px), 180 is (W - 1 - px, H - 1 - py) and 270 is
// (py, W - 1 - px). This is the inverse. The rotation follows from the axes that LVGL asked the
// display to swap and mirror for it (see lvgl_display_apply_rotation()).
void sdl_display_view_to_native(int32_t* x, int32_t* y) {
SdlDisplayInternal* internal = g_display_internal;
if (internal == nullptr || internal->config == nullptr) {
return;
}
const int32_t width = internal->config->horizontal_resolution;
const int32_t height = internal->config->vertical_resolution;
const int32_t view_x = *x;
const int32_t view_y = *y;
if (internal->requested_swap_xy.load()) {
if (internal->requested_mirror_x.load()) {
// 270 degrees
*x = width - 1 - view_y;
*y = view_x;
} else {
// 90 degrees
*x = view_y;
*y = height - 1 - view_x;
}
} else if (internal->requested_mirror_x.load() && internal->requested_mirror_y.load()) {
*x = width - 1 - view_x;
*y = height - 1 - view_y;
}
}
// Re-blits the already-drawn texture at the renderer's current (possibly just-resized) scale. // Re-blits the already-drawn texture at the renderer's current (possibly just-resized) scale.
// No new pixel data needed: the window resizing doesn't change what LVGL last rendered, only how // No new pixel data needed: the window resizing doesn't change what LVGL last rendered, only how
// large it should appear, and SDL only applies that until the next SDL_RenderPresent() call. // large it should appear, and SDL only applies that until the next SDL_RenderPresent() call.
@ -46,17 +101,12 @@ void sdl_display_present_now(void) {
// region Driver lifecycle // region Driver lifecycle
static error_t start(Device* device) { static error_t start(Device* device) {
auto* internal = static_cast<SdlDisplayInternal*>(malloc(sizeof(SdlDisplayInternal))); auto* internal = new(std::nothrow) SdlDisplayInternal();
if (internal == nullptr) { if (internal == nullptr) {
return ERROR_OUT_OF_MEMORY; return ERROR_OUT_OF_MEMORY;
} }
internal->initialized = false; internal->config = GET_CONFIG(device);
internal->init_failed = false;
internal->window = nullptr;
internal->renderer = nullptr;
internal->texture = nullptr;
device_set_driver_data(device, internal); device_set_driver_data(device, internal);
return ERROR_NONE; return ERROR_NONE;
} }
@ -75,7 +125,7 @@ static error_t stop(Device* device) {
g_display_internal = nullptr; g_display_internal = nullptr;
} }
free(internal); delete internal;
device_set_driver_data(device, nullptr); device_set_driver_data(device, nullptr);
return ERROR_NONE; return ERROR_NONE;
} }
@ -107,8 +157,11 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal)
// (config->horizontal_resolution/vertical_resolution, used below for the texture and // (config->horizontal_resolution/vertical_resolution, used below for the texture and
// SDL_RenderSetLogicalSize()) is unaffected, same as any other resize the user does by hand. // SDL_RenderSetLogicalSize()) is unaffected, same as any other resize the user does by hand.
const float dpi_scale = sdl_display_get_dpi_scale(); const float dpi_scale = sdl_display_get_dpi_scale();
const int initial_width = static_cast<int>(config->horizontal_resolution * dpi_scale); internal->shown_swap_xy = internal->requested_swap_xy.load();
const int initial_height = static_cast<int>(config->vertical_resolution * dpi_scale); int view_width, view_height;
sdl_display_compute_view_size(config, internal->shown_swap_xy, &view_width, &view_height);
const int initial_width = static_cast<int>(view_width * dpi_scale);
const int initial_height = static_cast<int>(view_height * dpi_scale);
internal->window = SDL_CreateWindow( internal->window = SDL_CreateWindow(
"Tactility", "Tactility",
@ -122,11 +175,10 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal)
// Lets the window be resized freely while the renderer scales/letterboxes the fixed-resolution // Lets the window be resized freely while the renderer scales/letterboxes the fixed-resolution
// texture below to fit - LVGL keeps rendering at horizontal_resolution x vertical_resolution. // texture below to fit - LVGL keeps rendering at horizontal_resolution x vertical_resolution.
if (internal->renderer != nullptr) { if (internal->renderer != nullptr) {
SDL_RenderSetLogicalSize(internal->renderer, config->horizontal_resolution, config->vertical_resolution); SDL_RenderSetLogicalSize(internal->renderer, view_width, view_height);
} }
internal->texture = internal->renderer != nullptr internal->texture = internal->renderer != nullptr
? SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, ? SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, view_width, view_height)
config->horizontal_resolution, config->vertical_resolution)
: nullptr; : nullptr;
if (internal->window == nullptr || internal->renderer == nullptr || internal->texture == nullptr) { if (internal->window == nullptr || internal->renderer == nullptr || internal->texture == nullptr) {
@ -142,6 +194,34 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal)
return true; return true;
} }
// Swaps the width and height of the view when swap_xy changed: the window turns from landscape to
// portrait or back, and keeps its zoom. Only called on the real main thread. Mirroring needs no
// pixel work: the window shows the view as the user sees it, which is already upright.
static bool sdl_display_apply_orientation(const SdlDisplayConfig* config, SdlDisplayInternal* internal) {
const bool swap_xy = internal->requested_swap_xy.load();
if (swap_xy == internal->shown_swap_xy) {
return true;
}
int view_width, view_height;
sdl_display_compute_view_size(config, swap_xy, &view_width, &view_height);
SDL_Texture* texture = SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, view_width, view_height);
if (texture == nullptr) {
LOG_E(TAG, "Failed to create the rotated texture: %s", SDL_GetError());
return false;
}
SDL_DestroyTexture(internal->texture);
internal->texture = texture;
internal->shown_swap_xy = swap_xy;
int window_width, window_height;
SDL_GetWindowSize(internal->window, &window_width, &window_height);
SDL_RenderSetLogicalSize(internal->renderer, view_width, view_height);
// Triggers SDL_WINDOWEVENT_SIZE_CHANGED, which presents the (empty) view until LVGL redraws it
SDL_SetWindowSize(internal->window, window_height, window_width);
return true;
}
// Only ever called from sdl_bridge_run_main_loop() on the real main thread - required for // Only ever called from sdl_bridge_run_main_loop() on the real main thread - required for
// SDL/Cocoa window creation and rendering on macOS. // SDL/Cocoa window creation and rendering on macOS.
error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
@ -159,9 +239,23 @@ error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int3
internal->initialized = true; internal->initialized = true;
} }
if (!sdl_display_apply_orientation(GET_CONFIG(device), internal)) {
return ERROR_RESOURCE;
}
SDL_Rect rect = { x_start, y_start, x_end - x_start, y_end - y_start }; SDL_Rect rect = { x_start, y_start, x_end - x_start, y_end - y_start };
// RGB565 = 2 bytes/pixel. // The texture is always RGB565 = 2 bytes/pixel.
if (SDL_UpdateTexture(internal->texture, &rect, color_data, (x_end - x_start) * 2) != 0) { std::vector<uint16_t> converted;
if (GET_CONFIG(device)->grayscale) {
const auto* luminance = static_cast<const uint8_t*>(color_data);
converted.resize(static_cast<size_t>(rect.w) * rect.h);
for (size_t i = 0; i < converted.size(); i++) {
const uint16_t value = luminance[i];
converted[i] = ((value >> 3) << 11) | ((value >> 2) << 5) | (value >> 3);
}
color_data = converted.data();
}
if (SDL_UpdateTexture(internal->texture, &rect, color_data, rect.w * 2) != 0) {
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
@ -179,8 +273,33 @@ static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t
return sdl_bridge_present(device, internal, x_start, y_start, x_end, y_end, color_data); return sdl_bridge_present(device, internal, x_start, y_start, x_end, y_end, color_data);
} }
static enum DisplayColorFormat sdl_display_get_color_format(Device*) { static error_t sdl_display_mirror(Device* device, bool x_axis, bool y_axis) {
return DISPLAY_COLOR_FORMAT_RGB565; auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
internal->requested_mirror_x.store(x_axis);
internal->requested_mirror_y.store(y_axis);
return ERROR_NONE;
}
static error_t sdl_display_swap_xy(Device* device, bool swap_axes) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
internal->requested_swap_xy.store(swap_axes);
return ERROR_NONE;
}
static bool sdl_display_get_swap_xy(Device* device) {
return static_cast<SdlDisplayInternal*>(device_get_driver_data(device))->requested_swap_xy.load();
}
static bool sdl_display_get_mirror_x(Device* device) {
return static_cast<SdlDisplayInternal*>(device_get_driver_data(device))->requested_mirror_x.load();
}
static bool sdl_display_get_mirror_y(Device* device) {
return static_cast<SdlDisplayInternal*>(device_get_driver_data(device))->requested_mirror_y.load();
}
static enum DisplayColorFormat sdl_display_get_color_format(Device* device) {
return GET_CONFIG(device)->grayscale ? DISPLAY_COLOR_FORMAT_GRAYSCALE8 : DISPLAY_COLOR_FORMAT_RGB565;
} }
static uint16_t sdl_display_get_resolution_x(Device* device) { static uint16_t sdl_display_get_resolution_x(Device* device) {
@ -194,15 +313,15 @@ static uint16_t sdl_display_get_resolution_y(Device* device) {
// endregion // endregion
static const DisplayApi sdl_display_api = { static const DisplayApi sdl_display_api = {
.capabilities = 0, .capabilities = DISPLAY_CAPABILITY_CAP_MIRROR | DISPLAY_CAPABILITY_CAP_SWAP_XY,
.reset = sdl_display_reset, .reset = sdl_display_reset,
.init = sdl_display_init, .init = sdl_display_init,
.draw_bitmap = sdl_display_draw_bitmap, .draw_bitmap = sdl_display_draw_bitmap,
.mirror = nullptr, .mirror = sdl_display_mirror,
.swap_xy = nullptr, .swap_xy = sdl_display_swap_xy,
.get_swap_xy = nullptr, .get_swap_xy = sdl_display_get_swap_xy,
.get_mirror_x = nullptr, .get_mirror_x = sdl_display_get_mirror_x,
.get_mirror_y = nullptr, .get_mirror_y = sdl_display_get_mirror_y,
.set_gap = nullptr, .set_gap = nullptr,
.get_gap_x = nullptr, .get_gap_x = nullptr,
.get_gap_y = nullptr, .get_gap_y = nullptr,

View File

@ -12,6 +12,8 @@ extern "C" {
struct SdlDisplayConfig { struct SdlDisplayConfig {
uint16_t horizontal_resolution; uint16_t horizontal_resolution;
uint16_t vertical_resolution; uint16_t vertical_resolution;
/** Frames are 8 bit luminance instead of RGB565 */
bool grayscale;
}; };
/** /**
@ -21,6 +23,22 @@ struct SdlDisplayConfig {
*/ */
SDL_Renderer* sdl_display_get_renderer(void); SDL_Renderer* sdl_display_get_renderer(void);
/**
* @brief Gets the size of the oriented view that the window shows: the display's resolution, with the width and
* height swapped while swap_xy is on. It follows a rotation right away, before the window is redrawn.
* @retval false when there is no display
*/
bool sdl_display_get_view_size(int* width, int* height);
/**
* @brief Converts a position in the oriented view that the window shows to the display's native position,
* the position with the display's rotation undone. LVGL rotates pointer positions from native to the
* rotated view itself, so pointers must report native positions.
* @param[in,out] x the horizontal position
* @param[in,out] y the vertical position
*/
void sdl_display_view_to_native(int32_t* x, int32_t* y);
/** /**
* @brief Re-presents the already-drawn frame at the renderer's current scale. Call this when the * @brief Re-presents the already-drawn frame at the renderer's current scale. Call this when the
* window is resized: the window size change alone doesn't make SDL re-blit the last frame at * window is resized: the window size change alone doesn't make SDL re-blit the last frame at

View File

@ -6,6 +6,7 @@
#include <SDL2/SDL.h> #include <SDL2/SDL.h>
#include <algorithm>
#include <cstdlib> #include <cstdlib>
#include <mutex> #include <mutex>
@ -17,7 +18,10 @@ constexpr size_t KEY_QUEUE_CAPACITY = 32;
// sdl_input_pop_key()/sdl_input_has_queued_key() on the lvgl task. // sdl_input_pop_key()/sdl_input_has_queued_key() on the lvgl task.
std::mutex state_mutex; std::mutex state_mutex;
// The position in window coordinates, and the size of the window then
SdlPointerState pointer_state = { 0, 0, false }; SdlPointerState pointer_state = { 0, 0, false };
int window_width = 0;
int window_height = 0;
uint32_t key_queue[KEY_QUEUE_CAPACITY]; uint32_t key_queue[KEY_QUEUE_CAPACITY];
size_t key_queue_head = 0; size_t key_queue_head = 0;
@ -42,19 +46,33 @@ void push_key(uint32_t key) {
// arrive separately via SDL_TEXTINPUT. // arrive separately via SDL_TEXTINPUT.
// The window can be freely resized (see sdl_display.cpp's SDL_WINDOW_RESIZABLE/ // The window can be freely resized (see sdl_display.cpp's SDL_WINDOW_RESIZABLE/
// SDL_RenderSetLogicalSize()), so raw SDL mouse coordinates are in window-pixel space, not the // SDL_RenderSetLogicalSize()), so raw SDL mouse coordinates are in window-pixel space, not the
// fixed logical resolution LVGL renders at. SDL_RenderWindowToLogical() is the renderer's own // resolution LVGL renders at. The view also changes shape when the display rotates. The raw
// inverse of that scaling, accounting for both the scale factor and any letterbox offset. // position is kept with the window size, and sdl_input_get_pointer_state() maps it onto the
// current view, so a rotation takes effect right away even when the mouse doesn't move.
// LVGL rotates pointer positions from the display's native orientation to the rotated view itself,
// so the position in the view is turned back to a native one at the end.
// The view is scaled to fit the window and centered, like SDL_RenderSetLogicalSize() does.
void set_pointer_position(int32_t window_x, int32_t window_y) { void set_pointer_position(int32_t window_x, int32_t window_y) {
SDL_Renderer* renderer = sdl_display_get_renderer();
if (renderer == nullptr) {
pointer_state.x = window_x; pointer_state.x = window_x;
pointer_state.y = window_y; pointer_state.y = window_y;
SDL_Renderer* renderer = sdl_display_get_renderer();
SDL_Window* window = renderer != nullptr ? SDL_RenderGetWindow(renderer) : nullptr;
if (window != nullptr) {
SDL_GetWindowSize(window, &window_width, &window_height);
}
}
void map_to_view(int32_t* x, int32_t* y) {
int view_width, view_height;
if (window_width <= 0 || window_height <= 0 || !sdl_display_get_view_size(&view_width, &view_height) || view_width <= 0 || view_height <= 0) {
return; return;
} }
float logical_x, logical_y; const float scale = std::min(static_cast<float>(window_width) / view_width, static_cast<float>(window_height) / view_height);
SDL_RenderWindowToLogical(renderer, window_x, window_y, &logical_x, &logical_y); const float offset_x = (window_width - view_width * scale) / 2.0f;
pointer_state.x = static_cast<int32_t>(logical_x); const float offset_y = (window_height - view_height * scale) / 2.0f;
pointer_state.y = static_cast<int32_t>(logical_y); *x = std::clamp(static_cast<int32_t>((*x - offset_x) / scale), 0, view_width - 1);
*y = std::clamp(static_cast<int32_t>((*y - offset_y) / scale), 0, view_height - 1);
sdl_display_view_to_native(x, y);
} }
uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) { uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) {
@ -126,6 +144,8 @@ void sdl_input_pump() {
// appear - re-present the existing frame at the new scale immediately, rather // appear - re-present the existing frame at the new scale immediately, rather
// than leaving stale-looking content on screen until the next LVGL-driven flush. // than leaving stale-looking content on screen until the next LVGL-driven flush.
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
window_width = event.window.data1;
window_height = event.window.data2;
sdl_display_present_now(); sdl_display_present_now();
} }
break; break;
@ -146,6 +166,7 @@ void sdl_input_pump() {
void sdl_input_get_pointer_state(SdlPointerState* out_state) { void sdl_input_get_pointer_state(SdlPointerState* out_state) {
std::lock_guard<std::mutex> lock(state_mutex); std::lock_guard<std::mutex> lock(state_mutex);
*out_state = pointer_state; *out_state = pointer_state;
map_to_view(&out_state->x, &out_state->y);
} }
bool sdl_input_pop_key(uint32_t* out_key) { bool sdl_input_pop_key(uint32_t* out_key) {

View File

@ -1,5 +1,10 @@
#include "SimulatorProfile.h"
#include "drivers/sdl_display.h" #include "drivers/sdl_display.h"
#include <app/manager.h>
#include <lvgl/theme.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/device_listener.h> #include <tactility/device_listener.h>
#include <tactility/driver.h> #include <tactility/driver.h>
@ -7,6 +12,7 @@
#include <tactility/log.h> #include <tactility/log.h>
#include <tactility/module.h> #include <tactility/module.h>
#include <algorithm>
#include <cstring> #include <cstring>
constexpr auto* TAG = "Simulator"; constexpr auto* TAG = "Simulator";
@ -29,7 +35,7 @@ static Driver* const simulator_drivers[] = {
// These devices have no real bus to attach to (SDL has no notion of one), but every non-root // These devices have no real bus to attach to (SDL has no notion of one), but every non-root
// device is still expected to have a parent (see Device::parent) - they're parented to root once // device is still expected to have a parent (see Device::parent) - they're parented to root once
// it's available below. // it's available below.
static const SdlDisplayConfig sdl_display_config = { 640, 320 }; static SdlDisplayConfig sdl_display_config = {};
static Device sdl_display_device {}; static Device sdl_display_device {};
static Device sdl_pointer_device {}; static Device sdl_pointer_device {};
static Device sdl_keyboard_device {}; static Device sdl_keyboard_device {};
@ -81,20 +87,40 @@ static void on_root_started(Device* device, DeviceEvent event, void* context) {
return; return;
} }
const auto& profile = simulator::getProfile(simulator::getActiveProfileIndex());
const uint16_t short_side = std::min(profile.width, profile.height);
const uint16_t long_side = std::max(profile.width, profile.height);
sdl_display_config = {
profile.portrait ? short_side : long_side,
profile.portrait ? long_side : short_side,
profile.mono
};
construct_add_start(&sdl_display_device, device, "display0", &sdl_display_config, "tactility,sdl-display"); construct_add_start(&sdl_display_device, device, "display0", &sdl_display_config, "tactility,sdl-display");
if (profile.pointer) {
construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer"); construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer");
}
if (profile.keyboard) {
construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard"); construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard");
} }
}
extern "C" { extern "C" {
static error_t start() { static error_t start() {
simulator::initActiveProfile();
lvgl_theme_set_default_compact(simulator::getProfile(simulator::getActiveProfileIndex()).compact);
error_t error = app_manager_add(&simulator::profileAppManifest);
if (error != ERROR_NONE) {
return error;
}
device_listener_add(on_root_started, nullptr); device_listener_add(on_root_started, nullptr);
return ERROR_NONE; return ERROR_NONE;
} }
static error_t stop() { static error_t stop() {
device_listener_remove(on_root_started, nullptr); device_listener_remove(on_root_started, nullptr);
app_manager_remove(simulator::profileAppManifest.id);
return ERROR_NONE; return ERROR_NONE;
} }

View File

@ -3,6 +3,7 @@
#include <tactility/bindings/root.h> #include <tactility/bindings/root.h>
#include <bindings/mock_wifi.h> #include <bindings/mock_wifi.h>
#include <bindings/mock_radio.h> #include <bindings/mock_radio.h>
#include <bindings/mock_bluetooth.h>
/ { / {
model = "Simulator"; model = "Simulator";
@ -15,4 +16,8 @@
radio0 { radio0 {
compatible = "mock,radio"; compatible = "mock,radio";
}; };
bluetooth0 {
compatible = "mock,bluetooth";
};
}; };

View File

@ -7,19 +7,11 @@
## Higher Priority ## Higher Priority
- Move EspNowRadio etc to Platforms/platform-esp32/ - Move TactilityKernelCpp project as C++ include folder into TactilityKernel. This way, the kernel itself can use the C++ templates internally. And it's a library less.
- GPIO app: fix for monochrome display by using filled vs outline box in secondary colour - GPIO app: fix for monochrome display by using filled vs outline box in secondary colour
- BlueTooth as radio backend for MeshCore/Meshtastic? - BlueTooth as radio backend for MeshCore/Meshtastic?
- Find out why the first build of an app with a clean build cache folder always fails - Find out why the first build of an app with a clean build cache folder always fails
- Fix BT: an esp32 device connecting to a peripheral isn't working
- "update all" functionality for app hub module, including commandline app. add button to app hub toolbar if there are 1 or more apps to update (does "update all").
- Improve Wi-Fi mock so it actually works with connect/disconnect/scan list. It connects to literally anything from the scan list.
- Support http/https on posix
- 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. - 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
- 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::*`
- Improve posix support: use user home path and system /tmp folder. - Improve posix support: use user home path and system /tmp folder.
- NimBLE looses pairing key after reboot - NimBLE looses pairing key after reboot
- Pick different font to improve monochrome screen readability (or change the weight). - Pick different font to improve monochrome screen readability (or change the weight).
@ -27,18 +19,22 @@
- Improve SPI kernel driver (implement read, write, transactions) - Improve SPI kernel driver (implement read, write, transactions)
- External app loading: Check the version of Tactility and check ESP target hardware to check for compatibility - 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) 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 `tt app install --app helloworld.app`
- minitar/untarFile(): "entry->metadata.path" can escape its confined path (e.g. "../something") - minitar/untarFile(): "entry->metadata.path" can escape its confined path (e.g. "../something")
- xl9555 and tca9534 drivers: support `GPIO_FLAG_ACTIVE_LOW` on outputs in software, like tca95xx does. - xl9555 and tca9534 drivers: support `GPIO_FLAG_ACTIVE_LOW` on outputs in software, like tca95xx does.
Afterwards the raw level reset handling in cst66xx and tca8418 can use ACTIVE_LOW too. Afterwards the raw level reset handling in cst66xx and tca8418 can use ACTIVE_LOW too.
## Medium Priority ## Medium Priority
- Try https://github.com/bootdsc/Fastwave-Eink-Driver with CL-32 e-paper.
- Power Off with e-paper should trigger full refresh before power off.
- Support hot-plugging SD card (note: might be problematic with shared SPI bus)
- Inspect code for memory improvements: replace `std::*` with `tt::std::*`
- Tactility/Source/bluetooth move into bluetooth module, make it work generically for all bluetooth apis
- DisplayApi: resolution x/y shouldn't be functions but struct values
- Tactility/Source/app/imageviewer/ImageViewer.cpp: Limit memory used to open an image. - Tactility/Source/app/imageviewer/ImageViewer.cpp: Limit memory used to open an image.
loadFile() now resizes fileData to the entire file size before displaying the image. start accepts a file path without a size limit, so opening a large image can exhaust memory on an embedded device before LVGL decodes it. Reject files above a supported limit or retain a bounded file-backed decoding path. loadFile() now resizes fileData to the entire file size before displaying the image. start accepts a file path without a size limit, so opening a large image can exhaust memory on an embedded device before LVGL decodes it. Reject files above a supported limit or retain a bounded file-backed decoding path.
- A device profile selection screen for Simulator: select device profiles such as T-Lora Pager, T-Deck Plus, CL-32, Cardputer and T-Embed which changes the resolution and theming defaults after restarting. - A device profile selection screen for Simulator: select device profiles such as T-Lora Pager, T-Deck Plus, CL-32, Cardputer and T-Embed which changes the resolution and theming defaults after restarting.
- A fullscreen tiny keyboard that works with single stroke gestures (similar to Palm OS Graffiti) - A fullscreen tiny keyboard that works with single stroke gestures (similar to Palm OS Graffiti)
- CL-32 display driver performance
- Move more window management code from Tactility/ to window-manager - Move more window management code from Tactility/ to window-manager
- Implement signal/raise for terminal apps (for CTRL+C and window resize signals) - Implement signal/raise for terminal apps (for CTRL+C and window resize signals)
- Build the simulator with `-fno-exceptions` to match ESP32. First make app exit() work without C++ unwinding there: - Build the simulator with `-fno-exceptions` to match ESP32. First make app exit() work without C++ unwinding there:
@ -52,8 +48,6 @@
- `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. - `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 moving certain drivers into separate modules: audio, bt, wifi, etc
- Consider using https://github.com/Graphify-Labs/graphify - 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
- Make a URL handler. Use it for handling local files. Match file types with apps. - Make a URL handler. Use it for handling local files. Match file types with apps.
Create some kind of "intent" handler like on Android. Create some kind of "intent" handler like on Android.
The intent can have an action (e.g. view), a URL and an optional bundle. The intent can have an action (e.g. view), a URL and an optional bundle.
@ -72,25 +66,20 @@
Make a separate group that is the default group. The keyboard can then use it (or use its own). Make a separate group that is the default group. The keyboard can then use it (or use its own).
The basic idea is to invert the ownership: now the keyboard group is made the default group, but it's probably more logical to have the default group used by the keyboard. The basic idea is to invert the ownership: now the keyboard group is made the default group, but it's probably more logical to have the default group used by the keyboard.
- Localize all apps - Localize all apps
- Support hot-plugging SD card (note: this is not possible if they require the CS pin hack)
- Explore LVGL9's FreeRTOS functionality - Explore LVGL9's FreeRTOS functionality
- Consider using non_null (either via MS GSL, or custom) - Fix system time to not be 1980 (use build year january 1st as a minimum).
- Fix system time to not be 1980 (use build year as a minimum). Consider keeping track of the last known time.
- Use std::span or string_view in StringUtils https://youtu.be/FRkJCvHWdwQ?t=2754 - Use std::span or string_view in StringUtils https://youtu.be/FRkJCvHWdwQ?t=2754
- Mutex: Implement give/take from ISR support (works only for non-recursive ones) - Mutex: Implement give/take from ISR support (works only for non-recursive ones)
- Show a warning screen if firmware encryption or secure boot are off when saving WiFi credentials. - Show a warning screen if firmware encryption or secure boot are off when saving WiFi credentials.
- Calculator app should show regular text input field on non-touch devices that have a keyboard (Cardputer, T-Lora Pager) - Calculator app should show regular text input field on non-touch devices that have a keyboard (Cardputer, T-Lora Pager)
- Allow for WSAD keys to navigate LVGL (this is extra nice for cardputer, but just handy in general)
- Make WiFi setup app that starts an access point and hosts a webpage to set up the device. - Make WiFi setup app that starts an access point and hosts a webpage to set up the device.
This will be useful for devices without a screen, a small screen or a non-touch screen. This will be useful for devices without a screen, a small screen or a non-touch screen.
# Nice-to-haves # Nice-to-haves
- Considering the lack of callstack debugging for external apps: allow for some debugging to be exposed during a device crash. Apps could report their state (e.g. an integer value) which can be stored during app operation and retrieve after crash. The same can be done for various OS apps and states. We can keep an array of these numbers to keep track of the last X states, to get an idea of what's going on.
- Audio player app - Audio player app
- Audio recording app - Audio recording app
- OTA updates - OTA updates
- If present, use LED to show boot/wifi status
# App Ideas # App Ideas

View File

@ -0,0 +1,8 @@
description: Mock Bluetooth driver with a fixed list of nearby peers
compatible: "mock,bluetooth"
properties:
_unused:
type: int
default: 0

View File

@ -0,0 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/bindings/bindings.h>
#include <mock/mock_bluetooth.h>
DEFINE_DEVICETREE(bluetooth, struct MockBluetoothConfig)

View File

@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
struct MockBluetoothConfig {
int _unused;
};
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,481 @@
// SPDX-License-Identifier: Apache-2.0
// Mock Bluetooth driver for the simulator: fakes a radio with a fixed list of nearby peers
// instead of talking to real hardware. Actions succeed instantly, there's no real scan, pairing
// or connection delay.
#include <tactility/concurrent/mutex.h>
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/log.h>
#include <cstring>
#include <new>
#define TAG "mock_bluetooth"
namespace {
struct MockPeerFixture {
BtAddr addr;
const char* name;
int8_t rssi;
};
constexpr MockPeerFixture MOCK_PEERS[] = {
{ { 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 }, "Mock Keyboard", -45 },
{ { 0x02, 0x00, 0x00, 0x00, 0x00, 0x02 }, "Mock Gamepad", -60 },
{ { 0x02, 0x00, 0x00, 0x00, 0x00, 0x03 }, "Mock Speaker", -75 },
};
constexpr size_t MOCK_PEER_COUNT = sizeof(MOCK_PEERS) / sizeof(MOCK_PEERS[0]);
constexpr const char* MOCK_DEFAULT_NAME = "Tactility";
struct MockPeerState {
bool paired = false;
/** Bit per BtProfileId */
uint8_t connectedProfiles = 0;
};
struct MockBluetoothCtx {
Device* device = nullptr;
Mutex mutex {};
BtRadioState radioState = BT_RADIO_STATE_OFF;
bool scanning = false;
bool hidHostActive = false;
char deviceName[BLE_DEVICE_NAME_MAX + 1] = {};
MockPeerState peers[MOCK_PEER_COUNT];
Mutex subscriptionsMutex {};
BtEventSubscription* subscriptions = nullptr;
};
#define GET_CTX(device) (static_cast<MockBluetoothCtx*>(device_get_driver_data(device)))
void fireEvent(MockBluetoothCtx* ctx, const BtEvent& event) {
mutex_lock(&ctx->subscriptionsMutex);
for (BtEventSubscription* sub = ctx->subscriptions; sub != nullptr; sub = sub->internal.next) {
mutex_lock(&sub->internal.ring_mutex);
if (sub->internal.count < BT_EVENT_QUEUE_CAPACITY) {
uint8_t tail = (sub->internal.head + sub->internal.count) % BT_EVENT_QUEUE_CAPACITY;
sub->internal.queue[tail] = event;
sub->internal.count++;
}
mutex_unlock(&sub->internal.ring_mutex);
task_event_group_signal(sub->internal.event_group, sub->bit);
}
mutex_unlock(&ctx->subscriptionsMutex);
}
// Returns the index of the peer, or MOCK_PEER_COUNT when the address isn't a mock peer
size_t findPeer(const BtAddr addr) {
for (size_t i = 0; i < MOCK_PEER_COUNT; i++) {
if (memcmp(MOCK_PEERS[i].addr, addr, BT_ADDR_LEN) == 0) {
return i;
}
}
return MOCK_PEER_COUNT;
}
// Caller holds ctx->mutex
void fillPeerRecord(const MockBluetoothCtx* ctx, size_t index, BtPeerRecord* record) {
memset(record, 0, sizeof(*record));
memcpy(record->addr, MOCK_PEERS[index].addr, BT_ADDR_LEN);
strncpy(record->name, MOCK_PEERS[index].name, BT_NAME_MAX);
record->rssi = MOCK_PEERS[index].rssi;
record->paired = ctx->peers[index].paired;
record->connected = ctx->peers[index].connectedProfiles != 0;
}
void fireRadioState(MockBluetoothCtx* ctx, BtRadioState state) {
BtEvent event = {};
event.type = BT_EVENT_RADIO_STATE_CHANGED;
event.radio_state = state;
fireEvent(ctx, event);
}
void fireProfileState(MockBluetoothCtx* ctx, size_t index, BtProfileId profile, BtProfileState state) {
BtEvent event = {};
event.type = BT_EVENT_PROFILE_STATE_CHANGED;
memcpy(event.profile_state.addr, MOCK_PEERS[index].addr, BT_ADDR_LEN);
event.profile_state.profile = profile;
event.profile_state.state = state;
fireEvent(ctx, event);
}
void fireConnectState(MockBluetoothCtx* ctx, size_t index) {
BtEvent event = {};
event.type = BT_EVENT_CONNECT_STATE_CHANGED;
mutex_lock(&ctx->mutex);
fillPeerRecord(ctx, index, &event.peer);
mutex_unlock(&ctx->mutex);
fireEvent(ctx, event);
}
// ---- BluetoothApi ----
error_t apiGetRadioState(Device* device, BtRadioState* state) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || state == nullptr) return ERROR_INVALID_ARGUMENT;
mutex_lock(&ctx->mutex);
*state = ctx->radioState;
mutex_unlock(&ctx->mutex);
return ERROR_NONE;
}
error_t apiSetRadioEnabled(Device* device, bool enabled) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr) return ERROR_INVALID_STATE;
mutex_lock(&ctx->mutex);
const bool changed = enabled != (ctx->radioState == BT_RADIO_STATE_ON);
ctx->radioState = enabled ? BT_RADIO_STATE_ON : BT_RADIO_STATE_OFF;
bool disconnected[MOCK_PEER_COUNT] = {};
if (!enabled) {
ctx->scanning = false;
for (size_t i = 0; i < MOCK_PEER_COUNT; i++) {
disconnected[i] = ctx->peers[i].connectedProfiles != 0;
ctx->peers[i].connectedProfiles = 0;
}
}
mutex_unlock(&ctx->mutex);
if (!changed) return ERROR_NONE;
for (size_t i = 0; i < MOCK_PEER_COUNT; i++) {
if (disconnected[i]) fireConnectState(ctx, i);
}
fireRadioState(ctx, enabled ? BT_RADIO_STATE_ON : BT_RADIO_STATE_OFF);
LOG_I(TAG, "Bluetooth radio %s (mock)", enabled ? "on" : "off");
return ERROR_NONE;
}
error_t apiScanStart(Device* device) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr) return ERROR_INVALID_STATE;
mutex_lock(&ctx->mutex);
if (ctx->radioState != BT_RADIO_STATE_ON || ctx->scanning) {
mutex_unlock(&ctx->mutex);
return ERROR_INVALID_STATE;
}
ctx->scanning = true;
mutex_unlock(&ctx->mutex);
BtEvent started_event = {};
started_event.type = BT_EVENT_SCAN_STARTED;
fireEvent(ctx, started_event);
for (size_t i = 0; i < MOCK_PEER_COUNT; i++) {
BtEvent found_event = {};
found_event.type = BT_EVENT_PEER_FOUND;
mutex_lock(&ctx->mutex);
fillPeerRecord(ctx, i, &found_event.peer);
mutex_unlock(&ctx->mutex);
fireEvent(ctx, found_event);
}
// No real radio, so the "scan" completes instantly.
mutex_lock(&ctx->mutex);
ctx->scanning = false;
mutex_unlock(&ctx->mutex);
BtEvent finished_event = {};
finished_event.type = BT_EVENT_SCAN_FINISHED;
fireEvent(ctx, finished_event);
return ERROR_NONE;
}
error_t apiScanStop(Device* device) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr) return ERROR_INVALID_STATE;
mutex_lock(&ctx->mutex);
ctx->scanning = false;
mutex_unlock(&ctx->mutex);
return ERROR_NONE;
}
bool apiIsScanning(Device* device) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr) return false;
mutex_lock(&ctx->mutex);
const bool scanning = ctx->scanning;
mutex_unlock(&ctx->mutex);
return scanning;
}
error_t apiPair(Device* device, const BtAddr addr) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT;
const size_t index = findPeer(addr);
mutex_lock(&ctx->mutex);
if (ctx->radioState != BT_RADIO_STATE_ON) {
mutex_unlock(&ctx->mutex);
return ERROR_INVALID_STATE;
}
if (index == MOCK_PEER_COUNT) {
mutex_unlock(&ctx->mutex);
return ERROR_NOT_FOUND;
}
ctx->peers[index].paired = true;
mutex_unlock(&ctx->mutex);
BtEvent event = {};
event.type = BT_EVENT_PAIR_RESULT;
memcpy(event.pair_result.addr, addr, BT_ADDR_LEN);
event.pair_result.result = BT_PAIR_RESULT_SUCCESS;
event.pair_result.profile = BT_PROFILE_HID_HOST;
fireEvent(ctx, event);
return ERROR_NONE;
}
error_t apiUnpair(Device* device, const BtAddr addr) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT;
const size_t index = findPeer(addr);
if (index == MOCK_PEER_COUNT) return ERROR_NOT_FOUND;
mutex_lock(&ctx->mutex);
const bool was_connected = ctx->peers[index].connectedProfiles != 0;
ctx->peers[index] = {};
mutex_unlock(&ctx->mutex);
if (was_connected) fireConnectState(ctx, index);
return ERROR_NONE;
}
error_t apiGetPairedPeers(Device* device, BtPeerRecord* out, size_t* count) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || count == nullptr) return ERROR_INVALID_ARGUMENT;
mutex_lock(&ctx->mutex);
size_t paired_count = 0;
for (size_t i = 0; i < MOCK_PEER_COUNT; i++) {
if (!ctx->peers[i].paired) continue;
if (out != nullptr && paired_count < *count) {
fillPeerRecord(ctx, i, &out[paired_count]);
}
paired_count++;
}
mutex_unlock(&ctx->mutex);
// With a buffer, the count is the number of records written
*count = out != nullptr && paired_count > *count ? *count : paired_count;
return ERROR_NONE;
}
error_t apiConnect(Device* device, const BtAddr addr, BtProfileId profile) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT;
const size_t index = findPeer(addr);
mutex_lock(&ctx->mutex);
if (ctx->radioState != BT_RADIO_STATE_ON) {
mutex_unlock(&ctx->mutex);
return ERROR_INVALID_STATE;
}
if (index == MOCK_PEER_COUNT) {
mutex_unlock(&ctx->mutex);
return ERROR_NOT_FOUND;
}
ctx->peers[index].paired = true;
ctx->peers[index].connectedProfiles |= 1 << profile;
mutex_unlock(&ctx->mutex);
// No real peer to negotiate with, so the mock always succeeds instantly.
fireProfileState(ctx, index, profile, BT_PROFILE_STATE_CONNECTING);
fireProfileState(ctx, index, profile, BT_PROFILE_STATE_CONNECTED);
fireConnectState(ctx, index);
return ERROR_NONE;
}
error_t apiDisconnect(Device* device, const BtAddr addr, BtProfileId profile) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || addr == nullptr) return ERROR_INVALID_ARGUMENT;
const size_t index = findPeer(addr);
if (index == MOCK_PEER_COUNT) return ERROR_NOT_FOUND;
mutex_lock(&ctx->mutex);
const bool was_connected = (ctx->peers[index].connectedProfiles & (1 << profile)) != 0;
ctx->peers[index].connectedProfiles &= ~(1 << profile);
mutex_unlock(&ctx->mutex);
if (!was_connected) return ERROR_NONE;
fireProfileState(ctx, index, profile, BT_PROFILE_STATE_DISCONNECTING);
fireProfileState(ctx, index, profile, BT_PROFILE_STATE_IDLE);
fireConnectState(ctx, index);
return ERROR_NONE;
}
error_t apiEventSubscribe(Device* device, BtEventSubscription* sub, TaskEventGroup* event_group) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || sub == nullptr || event_group == nullptr) return ERROR_INVALID_ARGUMENT;
uint32_t bit;
error_t claim_result = task_event_group_claim_bit(event_group, &bit);
if (claim_result != ERROR_NONE) {
return claim_result;
}
mutex_lock(&ctx->subscriptionsMutex);
// Avoid cyclic subscription list that would loop forever
for (BtEventSubscription* existing = ctx->subscriptions; existing != nullptr; existing = existing->internal.next) {
if (existing == sub) {
mutex_unlock(&ctx->subscriptionsMutex);
task_event_group_release_bit(event_group, bit);
return ERROR_INVALID_STATE;
}
}
sub->internal.event_group = event_group;
sub->bit = bit;
sub->internal.next = ctx->subscriptions;
ctx->subscriptions = sub;
mutex_unlock(&ctx->subscriptionsMutex);
return ERROR_NONE;
}
error_t apiEventUnsubscribe(Device* device, BtEventSubscription* sub) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || sub == nullptr) return ERROR_INVALID_ARGUMENT;
error_t result = ERROR_NOT_FOUND;
mutex_lock(&ctx->subscriptionsMutex);
for (BtEventSubscription** link = &ctx->subscriptions; *link != nullptr; link = &(*link)->internal.next) {
if (*link == sub) {
*link = sub->internal.next;
result = ERROR_NONE;
break;
}
}
mutex_unlock(&ctx->subscriptionsMutex);
if (result == ERROR_NONE) {
task_event_group_release_bit(sub->internal.event_group, sub->bit);
}
return result;
}
error_t apiSetDeviceName(Device* device, const char* name) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || name == nullptr || strlen(name) > BLE_DEVICE_NAME_MAX) return ERROR_INVALID_ARGUMENT;
mutex_lock(&ctx->mutex);
strncpy(ctx->deviceName, name, BLE_DEVICE_NAME_MAX);
ctx->deviceName[BLE_DEVICE_NAME_MAX] = '\0';
mutex_unlock(&ctx->mutex);
return ERROR_NONE;
}
error_t apiGetDeviceName(Device* device, char* buf, size_t buf_len) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr || buf == nullptr || buf_len == 0) return ERROR_INVALID_ARGUMENT;
mutex_lock(&ctx->mutex);
strncpy(buf, ctx->deviceName, buf_len - 1);
buf[buf_len - 1] = '\0';
mutex_unlock(&ctx->mutex);
return ERROR_NONE;
}
void apiSetHidHostActive(Device* device, bool active) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr) return;
mutex_lock(&ctx->mutex);
ctx->hidHostActive = active;
mutex_unlock(&ctx->mutex);
}
void apiFireEvent(Device* device, BtEvent event) {
auto* ctx = GET_CTX(device);
if (ctx != nullptr) fireEvent(ctx, event);
}
const BluetoothApi mock_bluetooth_api = {
.get_radio_state = apiGetRadioState,
.set_radio_enabled = apiSetRadioEnabled,
.scan_start = apiScanStart,
.scan_stop = apiScanStop,
.is_scanning = apiIsScanning,
.pair = apiPair,
.unpair = apiUnpair,
.get_paired_peers = apiGetPairedPeers,
.connect = apiConnect,
.disconnect = apiDisconnect,
.event_subscribe = apiEventSubscribe,
.event_unsubscribe = apiEventUnsubscribe,
.set_device_name = apiSetDeviceName,
.get_device_name = apiGetDeviceName,
.set_hid_host_active = apiSetHidHostActive,
.fire_event = apiFireEvent,
};
// ---- Driver lifecycle ----
// startDevice()/stopDevice() only allocate/free this driver's bookkeeping (subscriber list
// included), so event subscribers can stay subscribed across radio on/off toggles.
error_t startDevice(Device* device) {
auto* ctx = new(std::nothrow) MockBluetoothCtx();
if (ctx == nullptr) return ERROR_OUT_OF_MEMORY;
ctx->device = device;
strncpy(ctx->deviceName, MOCK_DEFAULT_NAME, BLE_DEVICE_NAME_MAX);
mutex_construct(&ctx->mutex);
mutex_construct(&ctx->subscriptionsMutex);
device_set_driver_data(device, ctx);
return ERROR_NONE;
}
error_t stopDevice(Device* device) {
auto* ctx = GET_CTX(device);
if (ctx == nullptr) return ERROR_NONE;
if (ctx->radioState == BT_RADIO_STATE_ON) {
apiSetRadioEnabled(device, false);
}
// Release any subscribers that never unsubscribed: device_stop() doesn't wait for apps still
// using this device, so a later bluetooth_event_unsubscribe() would find no ctx and skip
// releasing the bit.
mutex_lock(&ctx->subscriptionsMutex);
for (BtEventSubscription* sub = ctx->subscriptions; sub != nullptr;) {
BtEventSubscription* next = sub->internal.next;
// Force-close, don't destruct: a concurrent bluetooth_event_poll() may hold/await this
// same lock (see BtEventSubscription::internal::closed).
mutex_lock(&sub->internal.ring_mutex);
sub->internal.closed = true;
mutex_unlock(&sub->internal.ring_mutex);
task_event_group_release_bit(sub->internal.event_group, sub->bit);
sub = next;
}
ctx->subscriptions = nullptr;
mutex_unlock(&ctx->subscriptionsMutex);
device_set_driver_data(device, nullptr);
mutex_destruct(&ctx->subscriptionsMutex);
mutex_destruct(&ctx->mutex);
delete ctx;
return ERROR_NONE;
}
} // namespace
extern "C" {
Driver mock_bluetooth_driver = {
.name = "mock_bluetooth",
.compatible = (const char*[]) { "mock,bluetooth", nullptr },
.start_device = startDevice,
.stop_device = stopDevice,
.api = (const void*)&mock_bluetooth_api,
.device_type = &BLUETOOTH_TYPE,
.internal = nullptr
};
} // extern "C"

View File

@ -6,10 +6,12 @@ extern "C" {
extern Driver mock_wifi_driver; extern Driver mock_wifi_driver;
extern Driver mock_radio_driver; extern Driver mock_radio_driver;
extern Driver mock_bluetooth_driver;
static Driver* const mock_drivers[] = { static Driver* const mock_drivers[] = {
&mock_wifi_driver, &mock_wifi_driver,
&mock_radio_driver, &mock_radio_driver,
&mock_bluetooth_driver,
nullptr nullptr
}; };

View File

@ -154,7 +154,7 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) {
if (esp_elf_relocate(&runtime->elf, runtime->file_data) != 0) { if (esp_elf_relocate(&runtime->elf, runtime->file_data) != 0) {
// esp_elf_relocate() already frees elf->pdata/ptext itself on a relocation failure // esp_elf_relocate() already frees elf->pdata/ptext itself on a relocation failure
delete runtime; delete runtime;
LOG_E(TAG, "Failed to map elf. Possibly out of memory."); LOG_E(TAG, "Failed to map elf. Common causes: missing symbol or out of memory.");
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }

View File

@ -36,6 +36,13 @@ void lvgl_theme_get_settings(LvglThemeSettings* settings);
*/ */
void lvgl_theme_get_default_settings(LvglThemeSettings* settings); void lvgl_theme_get_default_settings(LvglThemeSettings* settings);
/**
* @brief Overrides the compile-time default UI density, for devices that only know it at runtime.
* Call before the theme settings are read.
* @param[in] is_compact true for the compact density
*/
void lvgl_theme_set_default_compact(bool is_compact);
/** /**
* @brief Checks whether the active theme is the monochrome theme: on monochrome and greyscale displays, or when selected in the settings. * @brief Checks whether the active theme is the monochrome theme: on monochrome and greyscale displays, or when selected in the settings.
* @return true when the monochrome theme is active * @return true when the monochrome theme is active

View File

@ -31,6 +31,14 @@ lv_obj_t* lvgl_list_add_text(lv_obj_t* list, const char* text);
*/ */
lv_obj_t* lvgl_list_add_button(lv_obj_t* list, const void* icon, const char* text); lv_obj_t* lvgl_list_add_button(lv_obj_t* list, const void* icon, const char* text);
/**
* @brief Removes the divider below the last item of a list.
* Every item has a divider below it. The last one lies on the border of the card or the container that holds the list.
* Call this after the last item was added.
* @param[in] list the list, or the container that holds the list items
*/
void lvgl_list_hide_last_divider(lv_obj_t* list);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif

View File

@ -151,6 +151,7 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_list_create), DEFINE_MODULE_SYMBOL(lvgl_list_create),
DEFINE_MODULE_SYMBOL(lvgl_list_add_text), DEFINE_MODULE_SYMBOL(lvgl_list_add_text),
DEFINE_MODULE_SYMBOL(lvgl_list_add_button), DEFINE_MODULE_SYMBOL(lvgl_list_add_button),
DEFINE_MODULE_SYMBOL(lvgl_list_hide_last_divider),
// lvgl_qr // lvgl_qr
DEFINE_MODULE_SYMBOL(lvgl_qr_create), DEFINE_MODULE_SYMBOL(lvgl_qr_create),
DEFINE_MODULE_SYMBOL(lvgl_qr_set_data), DEFINE_MODULE_SYMBOL(lvgl_qr_set_data),
@ -272,6 +273,7 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lv_obj_set_layout), DEFINE_MODULE_SYMBOL(lv_obj_set_layout),
DEFINE_MODULE_SYMBOL(lv_obj_set_grid_dsc_array), DEFINE_MODULE_SYMBOL(lv_obj_set_grid_dsc_array),
DEFINE_MODULE_SYMBOL(lv_obj_set_grid_cell), DEFINE_MODULE_SYMBOL(lv_obj_set_grid_cell),
DEFINE_MODULE_SYMBOL(lv_obj_set_press_lock),
DEFINE_MODULE_SYMBOL(lv_obj_is_layout_positioned), DEFINE_MODULE_SYMBOL(lv_obj_is_layout_positioned),
DEFINE_MODULE_SYMBOL(lv_obj_mark_layout_as_dirty), DEFINE_MODULE_SYMBOL(lv_obj_mark_layout_as_dirty),
DEFINE_MODULE_SYMBOL(lv_obj_get_style_layout), DEFINE_MODULE_SYMBOL(lv_obj_get_style_layout),

View File

@ -18,6 +18,7 @@ enum class ActiveTheme {
static LvglThemeSettings theme_settings; static LvglThemeSettings theme_settings;
static bool theme_settings_set = false; static bool theme_settings_set = false;
static bool default_compact = TT_LVGL_UI_DENSITY == LVGL_UI_DENSITY_COMPACT;
static ActiveTheme active_theme = ActiveTheme::None; static ActiveTheme active_theme = ActiveTheme::None;
void lvgl_theme_get_default_settings(LvglThemeSettings* settings) { void lvgl_theme_get_default_settings(LvglThemeSettings* settings) {
@ -29,11 +30,15 @@ void lvgl_theme_get_default_settings(LvglThemeSettings* settings) {
settings->color_primary = material_config.color_primary; settings->color_primary = material_config.color_primary;
settings->color_secondary = material_config.color_secondary; settings->color_secondary = material_config.color_secondary;
settings->color_error = material_config.color_error; settings->color_error = material_config.color_error;
settings->is_compact = TT_LVGL_UI_DENSITY == LVGL_UI_DENSITY_COMPACT; settings->is_compact = default_compact;
settings->surface_override = material_config.surface_override; settings->surface_override = material_config.surface_override;
settings->color_surface = material_config.color_surface; settings->color_surface = material_config.color_surface;
} }
void lvgl_theme_set_default_compact(bool is_compact) {
default_compact = is_compact;
}
void lvgl_theme_get_settings(LvglThemeSettings* settings) { void lvgl_theme_get_settings(LvglThemeSettings* settings) {
if (theme_settings_set) { if (theme_settings_set) {
*settings = theme_settings; *settings = theme_settings;

View File

@ -82,3 +82,10 @@ lv_obj_t* lvgl_list_add_button(lv_obj_t* list, const void* icon, const char* tex
return obj; return obj;
} }
void lvgl_list_hide_last_divider(lv_obj_t* list) {
const uint32_t count = lv_obj_get_child_count(list);
if (count > 0) {
lv_obj_set_style_border_width(lv_obj_get_child(list, static_cast<int32_t>(count) - 1), 0, LV_STATE_DEFAULT);
}
}

View File

@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20)
file(GLOB_RECURSE SOURCES "source/*.c**") file(GLOB_RECURSE SOURCES "source/*.c**")
set(PRIV_REQUIRES_LIST "") set(PRIV_REQUIRES_LIST TactilityFreeRtos)
if ("${IDF_TARGET}" STREQUAL "esp32s3" OR "${IDF_TARGET}" STREQUAL "esp32p4") if ("${IDF_TARGET}" STREQUAL "esp32s3" OR "${IDF_TARGET}" STREQUAL "esp32p4")
list(APPEND PRIV_REQUIRES_LIST esp_tinyusb) list(APPEND PRIV_REQUIRES_LIST esp_tinyusb)
endif () endif ()

View File

@ -43,7 +43,7 @@ extern "C" {
* enforce that themselves based on the reported version, same as the non-bridged backend). */ * enforce that themselves based on the reported version, same as the non-bridged backend). */
#define ESPNOW_BRIDGE_MAX_DATA_LEN 1470 #define ESPNOW_BRIDGE_MAX_DATA_LEN 1470
/* req_init mode: matches tt::service::espnow::Mode / WIFI_MODE_STA vs WIFI_MODE_AP on the slave */ /* req_init mode: matches espnow::Mode / WIFI_MODE_STA vs WIFI_MODE_AP on the slave */
#define ESPNOW_BRIDGE_MODE_STATION 0U #define ESPNOW_BRIDGE_MODE_STATION 0U
#define ESPNOW_BRIDGE_MODE_ACCESS_POINT 1U #define ESPNOW_BRIDGE_MODE_ACCESS_POINT 1U

View File

@ -6,9 +6,39 @@
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) #if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
#include <Tactility/service/espnow/EspNow.h> #include <cstddef>
#include <cstdint>
#include <cstring>
#include <esp_now.h>
namespace tt::service::espnow::backend { namespace espnow {
enum class Mode {
Station,
AccessPoint
};
struct EspNowConfig {
uint8_t masterKey[ESP_NOW_KEY_LEN];
Mode mode;
uint8_t channel;
bool longRange;
bool encrypt;
EspNowConfig(
const uint8_t masterKey[ESP_NOW_KEY_LEN],
Mode mode,
uint8_t channel,
bool longRange,
bool encrypt
) : mode(mode), channel(channel), longRange(longRange), encrypt(encrypt) {
memcpy(this->masterKey, masterKey, ESP_NOW_KEY_LEN);
}
};
}
namespace espnow::backend {
/** /**
* Bring up the backend (WiFi if needed + ESP-NOW init + recv/send callbacks + PMK). * Bring up the backend (WiFi if needed + ESP-NOW init + recv/send callbacks + PMK).

View File

@ -8,14 +8,14 @@
#include <cstdint> #include <cstdint>
namespace tt::service::espnow::backend { namespace espnow::backend {
/** /**
* The esp_hosted SDIO transport to the co-processor is brought up by esp_hosted_init() * The esp_hosted SDIO transport to the co-processor is brought up by esp_hosted_init()
* (auto-called at process startup via a constructor attribute - always runs regardless of * (auto-called at process startup via a constructor attribute - always runs regardless of
* WiFi/BT state), but the actual slave handshake only happens via esp_hosted_connect_to_slave(), * WiFi/BT state), but the actual slave handshake only happens via esp_hosted_connect_to_slave(),
* which today is only called as a side effect of the WiFi or BT stack starting * which today is only called as a side effect of the WiFi or BT stack starting
* (esp32_wifi.cpp / vhci_drv.c). Nothing in Tactility's boot sequence calls it on its own, so * (esp32_wifi.cpp / vhci_drv.c). Nothing in the boot sequence calls it on its own, so
* without this, ESP-NOW-over-bridge would require WiFi or BT to already be enabled - unlike * without this, ESP-NOW-over-bridge would require WiFi or BT to already be enabled - unlike
* native ESP32 chips where ESP-NOW works standalone. * native ESP32 chips where ESP-NOW works standalone.
* *

View File

@ -4,9 +4,9 @@
#if defined(CONFIG_ESP_HOSTED_ENABLED) #if defined(CONFIG_ESP_HOSTED_ENABLED)
#include <Tactility/service/espnow/EspNowBackend.h> #include <espnow/espnow_backend.h>
#include <Tactility/service/espnow/EspNowHostedTransport.h> #include <espnow/espnow_hosted_transport.h>
#include <Tactility/service/espnow/esp_hosted_espnow_bridge_proto.h> #include <espnow/esp_hosted_espnow_bridge_proto.h>
// esp_hosted_misc.h lacks its own extern "C" guard, so its declarations get C++ name-mangled // esp_hosted_misc.h lacks its own extern "C" guard, so its declarations get C++ name-mangled
// when included from a .cpp file, causing "undefined reference" at link time against the // when included from a .cpp file, causing "undefined reference" at link time against the
@ -30,13 +30,13 @@ extern "C" {
#include <cstring> #include <cstring>
#include <tactility/log.h> #include <tactility/log.h>
namespace tt::service::espnow::backend { namespace espnow::backend {
constexpr auto* TAG = "EspNowBackendHosted"; constexpr auto* TAG = "EspNowBackendHosted";
constexpr TickType_t REQUEST_TIMEOUT = 2000U / portTICK_PERIOD_MS; constexpr TickType_t REQUEST_TIMEOUT = 2000U / portTICK_PERIOD_MS;
static RecursiveMutex requestMutex; static tt::RecursiveMutex requestMutex;
static Semaphore responseSemaphore(1, 0); static tt::Semaphore responseSemaphore(1, 0);
// Callbacks (onRespGeneric/onRespInit) run from esp_hosted's own dispatch task, concurrently // Callbacks (onRespGeneric/onRespInit) run from esp_hosted's own dispatch task, concurrently
// with doRequest() running on the caller's task - std::atomic instead of requestMutex here // with doRequest() running on the caller's task - std::atomic instead of requestMutex here
// because taking requestMutex from the callback would deadlock: doRequest() holds it for its // because taking requestMutex from the callback would deadlock: doRequest() holds it for its

View File

@ -4,7 +4,7 @@
#if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_ESP_HOSTED_ENABLED) #if defined(CONFIG_SOC_WIFI_SUPPORTED) && !defined(CONFIG_ESP_HOSTED_ENABLED)
#include <Tactility/service/espnow/EspNowBackend.h> #include <espnow/espnow_backend.h>
#include <esp_now.h> #include <esp_now.h>
#include <esp_wifi.h> #include <esp_wifi.h>
@ -14,7 +14,7 @@
#include <tactility/drivers/wifi.h> #include <tactility/drivers/wifi.h>
#include <tactility/log.h> #include <tactility/log.h>
namespace tt::service::espnow::backend { namespace espnow::backend {
constexpr auto* TAG = "EspNowBackendNative"; constexpr auto* TAG = "EspNowBackendNative";
static bool wifiAcquired = false; static bool wifiAcquired = false;

View File

@ -4,12 +4,12 @@
#if defined(CONFIG_ESP_HOSTED_ENABLED) #if defined(CONFIG_ESP_HOSTED_ENABLED)
#include <Tactility/service/espnow/EspNowHostedTransport.h> #include <espnow/espnow_hosted_transport.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/wifi.h> #include <tactility/drivers/wifi.h>
namespace tt::service::espnow::backend { namespace espnow::backend {
// Thin wrapper: the actual CP_INIT wait/generation-tracking logic lives in the kernel driver // Thin wrapper: the actual CP_INIT wait/generation-tracking logic lives in the kernel driver
// (Platforms/platform-esp32/source/drivers/esp32_esp_hosted_ota.cpp, reached via the generic // (Platforms/platform-esp32/source/drivers/esp32_esp_hosted_ota.cpp, reached via the generic

View File

@ -4,8 +4,7 @@
#if (defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)) #if (defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED))
#include <Tactility/service/espnow/EspNow.h> #include <espnow/espnow_backend.h>
#include <Tactility/service/espnow/EspNowRadio.h>
#include <TactilityCpp/Memory.h> #include <TactilityCpp/Memory.h>
#include <TactilityCpp/std/deque.h> #include <TactilityCpp/std/deque.h>
@ -21,12 +20,13 @@
#include <tactility/log.h> #include <tactility/log.h>
#include <algorithm> #include <algorithm>
#include <atomic>
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include <esp_wifi.h> #include <esp_wifi.h>
namespace tt::service::espnow { namespace espnow {
constexpr auto* TAG = "EspNowRadio"; constexpr auto* TAG = "EspNowRadio";
@ -38,8 +38,10 @@ constexpr int32_t CHANNEL_MIN = 1;
constexpr int32_t CHANNEL_MAX = 14; constexpr int32_t CHANNEL_MAX = 14;
constexpr int32_t POWER_MIN = 2; constexpr int32_t POWER_MIN = 2;
constexpr int32_t POWER_MAX = 20; constexpr int32_t POWER_MAX = 20;
constexpr uint32_t ENABLE_TIMEOUT_MS = 5000;
constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
// ESP-NOW protocol version 1 and 2 payload limits
constexpr size_t MAX_DATA_LENGTH_V1 = 250;
constexpr size_t MAX_DATA_LENGTH_V2 = 1470;
struct Frame { struct Frame {
RadioTxId id; RadioTxId id;
@ -58,10 +60,14 @@ struct Radio {
int32_t power = POWER_MAX; int32_t power = POWER_MAX;
RadioTxId nextTxId = 1; RadioTxId nextTxId = 1;
tt::std::deque<Frame> queue; tt::std::deque<Frame> queue;
ReceiverSubscription subscription = NO_SUBSCRIPTION; /** Written by the radio thread while ESP-NOW is up */
uint32_t version = 0;
bool stopRequested = false; bool stopRequested = false;
}; };
/** ESP-NOW delivers received frames to a plain callback without a context */
std::atomic<Radio*> activeRadio { nullptr };
Radio* getRadio(Device* device) { Radio* getRadio(Device* device) {
return static_cast<Radio*>(device_get_driver_data(device)); return static_cast<Radio*>(device_get_driver_data(device));
} }
@ -80,22 +86,9 @@ bool isStopRequested(Radio* radio) {
return stop; return stop;
} }
/** Waits until the ESP-NOW service has brought ESP-NOW up. The service does that on another task. */ void onReceive(const esp_now_recv_info_t* info, const uint8_t* data, int length) {
bool waitUntilEnabled(Radio* radio) { auto* radio = activeRadio.load();
for (uint32_t waited = 0; waited < ENABLE_TIMEOUT_MS; waited += 20) { if (radio == nullptr || length <= 0) {
if (isEnabled()) {
return true;
}
if (isStopRequested(radio)) {
return false;
}
delay_millis(20);
}
return isEnabled();
}
void onReceive(Radio* radio, const esp_now_recv_info_t* info, const uint8_t* data, int length) {
if (length <= 0) {
return; return;
} }
const float rssi = (info != nullptr && info->rx_ctrl != nullptr) ? (float)info->rx_ctrl->rssi : 0.0f; const float rssi = (info != nullptr && info->rx_ctrl != nullptr) ? (float)info->rx_ctrl->rssi : 0.0f;
@ -115,11 +108,14 @@ void transmitQueued(Radio* radio) {
mutex_unlock(&radio->mutex); mutex_unlock(&radio->mutex);
radio_tx_event_emit(radio->device, frame.id, RADIO_TRANSMISSION_STATE_TRANSMIT_PENDING); radio_tx_event_emit(radio->device, frame.id, RADIO_TRANSMISSION_STATE_TRANSMIT_PENDING);
const bool fits = frame.data.size() <= getMaxDataLength(); const size_t protocolMaxLength = radio->version >= 2 ? MAX_DATA_LENGTH_V2 : MAX_DATA_LENGTH_V1;
// Receivers hand out at most RADIO_RX_MAX_PACKET_LENGTH bytes, so a longer frame would arrive truncated
const size_t maxLength = ::std::min<size_t>(protocolMaxLength, RADIO_RX_MAX_PACKET_LENGTH);
const bool fits = frame.data.size() <= maxLength;
// The frame is handed to the Wi-Fi stack, which doesn't report when it's out // The frame is handed to the Wi-Fi stack, which doesn't report when it's out
const bool sent = fits && send(BROADCAST_ADDRESS, frame.data.data(), frame.data.size()); const bool sent = fits && backend::send(BROADCAST_ADDRESS, frame.data.data(), frame.data.size());
if (!sent) { if (!sent) {
LOG_W(TAG, "Failed to send %u bytes (the maximum is %u)", (unsigned)frame.data.size(), (unsigned)getMaxDataLength()); LOG_W(TAG, "Failed to send %u bytes (the maximum is %u)", (unsigned)frame.data.size(), (unsigned)maxLength);
} }
radio_tx_event_emit(radio->device, frame.id, sent ? RADIO_TRANSMISSION_STATE_TRANSMITTED : RADIO_TRANSMISSION_STATE_ERROR); radio_tx_event_emit(radio->device, frame.id, sent ? RADIO_TRANSMISSION_STATE_TRANSMITTED : RADIO_TRANSMISSION_STATE_ERROR);
} }
@ -135,26 +131,23 @@ int32_t radioThread(void* context) {
// The same settings as MeshCore's ESP-NOW transport: a station with the long range protocol, unencrypted broadcasts // The same settings as MeshCore's ESP-NOW transport: a station with the long range protocol, unencrypted broadcasts
uint8_t key[ESP_NOW_KEY_LEN] = {}; uint8_t key[ESP_NOW_KEY_LEN] = {};
enable(EspNowConfig(key, Mode::Station, (uint8_t)channel, true, false)); if (!backend::init(EspNowConfig(key, Mode::Station, (uint8_t)channel, true, false), onReceive)) {
if (!waitUntilEnabled(radio)) {
// ESP-NOW may still finish starting on its own task, so it's turned off in both cases
disable();
if (!isStopRequested(radio)) {
LOG_E(TAG, "ESP-NOW didn't start"); LOG_E(TAG, "ESP-NOW didn't start");
setState(radio, RADIO_STATE_ERROR); setState(radio, RADIO_STATE_ERROR);
return 1; return 1;
} }
setState(radio, RADIO_STATE_OFF); radio->version = backend::getVersion();
return 0;
esp_now_peer_info_t broadcastPeer = {};
memcpy(broadcastPeer.peer_addr, BROADCAST_ADDRESS, sizeof(BROADCAST_ADDRESS));
if (!backend::addPeer(broadcastPeer)) {
LOG_E(TAG, "Failed to add the broadcast peer");
} }
#ifdef CONFIG_SOC_WIFI_SUPPORTED #ifdef CONFIG_SOC_WIFI_SUPPORTED
// In units of 0.25 dBm // In units of 0.25 dBm
esp_wifi_set_max_tx_power((int8_t)(power * 4)); esp_wifi_set_max_tx_power((int8_t)(power * 4));
#endif #endif
radio->subscription = subscribeReceiver([radio](const esp_now_recv_info_t* info, const uint8_t* data, int length) { activeRadio.store(radio);
onReceive(radio, info, data, length);
});
setState(radio, RADIO_STATE_ON); setState(radio, RADIO_STATE_ON);
while (!isStopRequested(radio)) { while (!isStopRequested(radio)) {
@ -164,9 +157,10 @@ int32_t radioThread(void* context) {
} }
setState(radio, RADIO_STATE_OFF_PENDING); setState(radio, RADIO_STATE_OFF_PENDING);
unsubscribeReceiver(radio->subscription); activeRadio.store(nullptr);
radio->subscription = NO_SUBSCRIPTION; if (!backend::deinit()) {
disable(); LOG_E(TAG, "Failed to stop ESP-NOW");
}
setState(radio, RADIO_STATE_OFF); setState(radio, RADIO_STATE_OFF);
return 0; return 0;
} }
@ -214,7 +208,7 @@ error_t apiSetEnabled(Device* device, bool enabled) {
mutex_unlock(&radio->mutex); mutex_unlock(&radio->mutex);
event_group_clear(radio->events, TX_BIT | STOP_BIT); event_group_clear(radio->events, TX_BIT | STOP_BIT);
setState(radio, RADIO_STATE_ON_PENDING); setState(radio, RADIO_STATE_ON_PENDING);
radio->thread = thread_alloc_full("espnow_radio", 4096, radioThread, radio, -1); radio->thread = thread_alloc_full("espnow_radio", 8192, radioThread, radio, -1);
if (radio->thread == nullptr || thread_start(radio->thread) != ERROR_NONE) { if (radio->thread == nullptr || thread_start(radio->thread) != ERROR_NONE) {
if (radio->thread != nullptr) { if (radio->thread != nullptr) {
thread_free(radio->thread); thread_free(radio->thread);
@ -371,14 +365,16 @@ Device device {
} // namespace } // namespace
void registerRadio() {
if (driver_construct_add(&driver) != ERROR_NONE ||
device_construct_add(&device, "tactility,espnow-radio") != ERROR_NONE ||
device_start(&device) != ERROR_NONE) {
LOG_E(TAG, "Failed to add the ESP-NOW radio");
}
} }
error_t esp32_espnow_radio_start() {
if (driver_construct_add(&espnow::driver) != ERROR_NONE ||
device_construct_add(&espnow::device, "tactility,espnow-radio") != ERROR_NONE ||
device_start(&espnow::device) != ERROR_NONE) {
LOG_E(espnow::TAG, "Failed to add the ESP-NOW radio");
return ERROR_RESOURCE;
}
return ERROR_NONE;
} }
#endif #endif

View File

@ -1,7 +1,4 @@
#ifdef ESP_PLATFORM
#include <sdkconfig.h> #include <sdkconfig.h>
#endif
#include <assert.h> #include <assert.h>
#include <driver/gpio.h> #include <driver/gpio.h>
#include <driver/i2s_common.h> #include <driver/i2s_common.h>
@ -126,6 +123,22 @@ extern "C" {
} }
error_t platform_esp32_start_partitions(); error_t platform_esp32_start_partitions();
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
error_t esp32_espnow_radio_start();
#endif
static error_t platform_esp32_start() {
error_t error = platform_esp32_start_partitions();
if (error != ERROR_NONE) {
return error;
}
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
// Lets mesh networking run over ESP-NOW
return esp32_espnow_radio_start();
#else
return ERROR_NONE;
#endif
}
extern "C" { extern "C" {
@ -436,7 +449,7 @@ static Driver* const platform_esp32_drivers[] = {
Module platform_esp32_module = { Module platform_esp32_module = {
.name = "platform-esp32", .name = "platform-esp32",
.start = platform_esp32_start_partitions, .start = platform_esp32_start,
.stop = nullptr, .stop = nullptr,
.drivers = platform_esp32_drivers, .drivers = platform_esp32_drivers,
.symbols = platform_esp32_symbols, .symbols = platform_esp32_symbols,

View File

@ -92,11 +92,6 @@ if (DEFINED ENV{ESP_IDF_VERSION})
list(APPEND REQUIRES_LIST esp_tinyusb) list(APPEND REQUIRES_LIST esp_tinyusb)
endif () endif ()
if ("${IDF_TARGET}" STREQUAL "esp32p4")
# EspNowBackendHosted.cpp needs esp_hosted.h / esp_hosted_misc.h.
list(APPEND REQUIRES_LIST espressif__esp_hosted)
endif ()
else () else ()
list(APPEND REQUIRES_LIST list(APPEND REQUIRES_LIST

View File

@ -26,10 +26,29 @@ inline ::std::string getNotReadyText() {
return ::std::string("Mesh isn't running: ") + mesh_state_to_string(mesh_get_state()) + ". Check the Mesh settings."; return ::std::string("Mesh isn't running: ") + mesh_state_to_string(mesh_get_state()) + ". Check the Mesh settings.";
} }
/** Whether mesh is running, and the network and the kind of radio that it uses. Those are empty while mesh isn't running. */
struct NetworkInfo {
bool running = false;
char network[MESH_BACKEND_ID_MAX_LENGTH + 1] = {};
char modulation[16] = {};
};
/** Queries the service thread, so it's called without the LVGL lock */
NetworkInfo loadNetworkInfo();
/**
* A text line under the toolbar with the values in the secondary theme colour:
* "Using [network] via [modulation]" while mesh is running, and "Mesh is OFF" when it isn't
*/
lv_obj_t* createNetworkHeader(lv_obj_t* parent);
/** Requires the LVGL lock. Hides the header while mesh is running without a known network. */
void updateNetworkHeader(lv_obj_t* header, const NetworkInfo& info);
/** Starts an app by id with the given arguments */ /** Starts an app by id with the given arguments */
void startApp(const char* id, int argc, const char* const argv[]); void startApp(const char* id, int argc, const char* const argv[]);
/** A scrollable, borderless column below the toolbar and status label that holds the lists and buttons of a screen */ /** A scrollable, borderless column below the toolbar and network header that holds the lists and buttons of a screen */
lv_obj_t* createContent(lv_obj_t* parent); lv_obj_t* createContent(lv_obj_t* parent);
/** /**

View File

@ -1,69 +0,0 @@
#pragma once
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
#include <cstdint>
#include <cstring>
#include <esp_now.h>
#include <functional>
namespace tt::service::espnow {
typedef int ReceiverSubscription;
constexpr ReceiverSubscription NO_SUBSCRIPTION = -1;
// ESP-NOW version payload limits
constexpr size_t MAX_DATA_LEN_V1 = 250; // ESP-NOW v1.0 max payload
constexpr size_t MAX_DATA_LEN_V2 = 1470; // ESP-NOW v2.0 max payload (requires ESP-IDF v5.4+)
enum class Mode {
Station,
AccessPoint
};
struct EspNowConfig {
uint8_t masterKey[ESP_NOW_KEY_LEN];
uint8_t address[ESP_NOW_ETH_ALEN];
Mode mode;
uint8_t channel;
bool longRange;
bool encrypt;
EspNowConfig(
uint8_t masterKey[16],
Mode mode,
uint8_t channel,
bool longRange,
bool encrypt
) : mode(mode), channel(channel), longRange(longRange), encrypt(encrypt) {
memcpy(this->masterKey, masterKey, 16);
}
};
void enable(const EspNowConfig& config);
void disable();
bool isEnabled();
bool addPeer(const esp_now_peer_info_t& peer);
bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength);
ReceiverSubscription subscribeReceiver(std::function<void(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length)> onReceive);
void unsubscribeReceiver(ReceiverSubscription subscription);
/** Get the ESP-NOW protocol version (1 for v1.0, 2 for v2.0). Returns 0 if service not running. */
uint32_t getVersion();
/** Get the maximum data length for current ESP-NOW version (250 for v1.0, 1470 for v2.0). Returns 0 if service not running. */
size_t getMaxDataLength();
}
#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_ESP_HOSTED_ENABLED

View File

@ -1,19 +0,0 @@
#pragma once
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#if (defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED))
namespace tt::service::espnow {
/**
* Adds a radio device (RADIO_TYPE) that sends and receives broadcast frames with ESP-NOW, so that the mesh service
* can use it like a LoRa radio. It uses the ESP-NOW service, so it must be registered after that service.
*/
void registerRadio();
}
#endif

View File

@ -1,78 +0,0 @@
#pragma once
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
#include <Tactility/service/Service.h>
#include <Tactility/service/espnow/EspNow.h>
#include <Tactility/RecursiveMutex.h>
#include <functional>
namespace tt::service::espnow {
class EspNowService final : public Service {
struct ReceiverSubscriptionData {
ReceiverSubscription id;
std::function<void(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length)> onReceive;
};
struct SendCallback {
uint8_t macAddress[ESP_NOW_ETH_ALEN];
bool success;
};
RecursiveMutex mutex;
std::vector<ReceiverSubscriptionData> subscriptions;
ReceiverSubscription lastSubscriptionId = 0;
bool enabled = false;
uint32_t espnowVersion = 0;
// Dispatcher calls this and forwards to non-static function
void enableFromDispatcher(const EspNowConfig& config);
void disableFromDispatcher();
static void receiveCallback(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length);
void onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length);
public:
// region Overrides
bool onStart(ServiceContext& service) override;
void onStop(ServiceContext& service) override;
// endregion Overrides
// region Internal API
void enable(const EspNowConfig& config);
void disable();
bool isEnabled() const;
bool addPeer(const esp_now_peer_info_t& peer);
bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength);
ReceiverSubscription subscribeReceiver(std::function<void(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length)> onReceive);
void unsubscribeReceiver(ReceiverSubscription subscription);
uint32_t getVersion() const;
// region Internal API
};
std::shared_ptr<EspNowService> findService();
}
#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_ESP_HOSTED_ENABLED

View File

@ -1,10 +1,12 @@
#include "tactility/drivers/bluetooth.h"
#include <Tactility/TactilityPrivate.h> #include <Tactility/TactilityPrivate.h>
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
#include <sdkconfig.h> #include <sdkconfig.h>
#endif #endif
#include <Tactility/MountPoints.h>
#include <Tactility/TactilityConfig.h> #include <Tactility/TactilityConfig.h>
#include <Tactility/file/File.h> #include <Tactility/file/File.h>
#include <Tactility/service/audio/Audio.h> #include <Tactility/service/audio/Audio.h>
@ -198,10 +200,10 @@ static void registerInternalApps() {
app_manager_add(&app::power::manifest); app_manager_add(&app::power::manifest);
} }
#if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED if (device_exists_of_type(&BLUETOOTH_TYPE)) {
app_manager_add(&app::btmanage::manifest); app_manager_add(&app::btmanage::manifest);
app_manager_add(&app::btpeersettings::manifest); app_manager_add(&app::btpeersettings::manifest);
#endif }
} }
// Registers every mounted filesystem's app install directory with app-module (see // Registers every mounted filesystem's app install directory with app-module (see

View File

@ -6,7 +6,6 @@
#include <Tactility/service/ServiceManifest.h> #include <Tactility/service/ServiceManifest.h>
#include <Tactility/service/ServiceRegistration.h> #include <Tactility/service/ServiceRegistration.h>
#include <Tactility/service/espnow/EspNowRadio.h>
#include <Tactility/settings/RadioSettings.h> #include <Tactility/settings/RadioSettings.h>
#include <generated_assets.h> #include <generated_assets.h>
@ -34,9 +33,6 @@ namespace tt {
namespace service { namespace service {
// Primary // Primary
namespace audio { extern const ServiceManifest manifest; } namespace audio { extern const ServiceManifest manifest; }
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
namespace espnow { extern const ServiceManifest manifest; }
#endif
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
namespace rtctime { extern const ServiceManifest manifest; } namespace rtctime { extern const ServiceManifest manifest; }
#endif #endif
@ -66,11 +62,6 @@ void registerAndStartServices() {
check(posix_services_start() == ERROR_NONE); check(posix_services_start() == ERROR_NONE);
#endif #endif
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
addService(service::espnow::manifest);
// Lets mesh networking run over ESP-NOW
service::espnow::registerRadio();
#endif
#if defined(ESP_PLATFORM) #if defined(ESP_PLATFORM)
if (device_exists_of_type(&RTC_TYPE)) { if (device_exists_of_type(&RTC_TYPE)) {
addService(service::rtctime::manifest); addService(service::rtctime::manifest);

View File

@ -32,6 +32,9 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <TactilityCpp/std/string.h>
#include <TactilityCpp/std/vector.h>
namespace tt::app::apphub { namespace tt::app::apphub {
constexpr auto* TAG = "AppHub"; constexpr auto* TAG = "AppHub";
@ -46,7 +49,7 @@ enum class Filter {
Updates Updates
}; };
constexpr auto FILTER_LABELS = std::to_array<const char*>({ "All", "Installed", "Updates" }); constexpr auto FILTER_LABELS = ::std::to_array<const char*>({ "All", "Installed", "Updates" });
struct Context { struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
@ -59,7 +62,9 @@ struct Context {
// Survives across a bury/resurface cycle // Survives across a bury/resurface cycle
Filter filter = Filter::All; Filter filter = Filter::All;
/** The app ids of the list's buttons, by index */ /** The app ids of the list's buttons, by index */
std::vector<std::string> appIds; tt::std::vector<tt::std::string> appIds;
/** Read from the catalog file on this app's own thread (see loadApps()), as the widget code can't do file operations */
tt::std::vector<AppHubApp> apps;
Mutex mutex; Mutex mutex;
// Survives across a bury/resurface cycle (e.g. opening AppHubDetailsApp and returning), // Survives across a bury/resurface cycle (e.g. opening AppHubDetailsApp and returning),
@ -74,21 +79,35 @@ struct Context {
TaskEventGroup* eventGroup = nullptr; TaskEventGroup* eventGroup = nullptr;
uint32_t refreshRequestedBit = 0; uint32_t refreshRequestedBit = 0;
std::atomic<bool> refreshRequested {false}; ::std::atomic<bool> refreshRequested {false};
/** Set when the window resurfaces: the installed apps can change without an App Hub event, e.g. through the Files app */
::std::atomic<bool> reloadRequested {false};
AppHubEventSubscription appHubSub {}; AppHubEventSubscription appHubSub {};
/** The id of the queued catalog refresh, 0 when none is pending */ /** The id of the queued catalog refresh, 0 when none is pending */
std::atomic<uint32_t> refreshOperationId {0}; ::std::atomic<uint32_t> refreshOperationId {0};
/** The id of the running update of all apps, 0 when none is running */ /** The id of the running update of all apps, 0 when none is running */
uint32_t updateAllOperationId = 0; uint32_t updateAllOperationId = 0;
/** The ids of the apps that failed to update during updateAllOperationId */ /** The ids of the apps that failed to update during updateAllOperationId */
std::vector<std::string> failedUpdates; tt::std::vector<tt::std::string> failedUpdates;
}; };
void showApps(Context* ctx); void showApps(Context* ctx);
void refresh(Context* ctx); void refresh(Context* ctx);
/** Reads the apps from App Hub for showApps(). Doesn't touch widgets, so it runs without the LVGL lock. */
void loadApps(Context* ctx) {
tt::std::vector<AppHubApp> apps;
app_hub_search(nullptr, [](const AppHubApp* app, void* context) {
static_cast<tt::std::vector<AppHubApp>*>(context)->push_back(*app);
return true;
}, &apps);
ctx->mutex.lock();
ctx->apps = ::std::move(apps);
ctx->mutex.unlock();
}
bool matchesFilter(const AppHubApp& app, Filter filter) { bool matchesFilter(const AppHubApp& app, Filter filter) {
switch (filter) { switch (filter) {
case Filter::All: case Filter::All:
@ -138,7 +157,7 @@ void onUpdateAllPressed(lv_event_t* event) {
void updateUpdatesChipText(Context* ctx, size_t updateCount) { void updateUpdatesChipText(Context* ctx, size_t updateCount) {
auto* chip = lv_obj_get_child(ctx->filterChips, static_cast<int32_t>(Filter::Updates)); auto* chip = lv_obj_get_child(ctx->filterChips, static_cast<int32_t>(Filter::Updates));
const auto text = std::format("{} {}", updateCount, updateCount == 1 ? "update" : "updates"); const auto text = ::std::format("{} {}", updateCount, updateCount == 1 ? "update" : "updates");
lv_label_set_text(lv_obj_get_child(chip, 0), text.c_str()); lv_label_set_text(lv_obj_get_child(chip, 0), text.c_str());
} }
@ -202,6 +221,11 @@ void requestRefresh(Context* ctx) {
task_event_group_signal(ctx->eventGroup, ctx->refreshRequestedBit); task_event_group_signal(ctx->eventGroup, ctx->refreshRequestedBit);
} }
void requestReload(Context* ctx) {
ctx->reloadRequested = true;
task_event_group_signal(ctx->eventGroup, ctx->refreshRequestedBit);
}
void onRefreshPressed(lv_event_t* e) { void onRefreshPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
requestRefresh(ctx); requestRefresh(ctx);
@ -250,7 +274,7 @@ void showApps(Context* ctx) {
} }
ctx->mutex.lock(); ctx->mutex.lock();
// Rebuilding deletes the focused entry button, so the same app is focused again afterwards // Rebuilding deletes the focused entry button, so the same app is focused again afterwards
std::string focused_app_id; tt::std::string focused_app_id;
bool focused_by_key = false; bool focused_by_key = false;
auto* group = lv_group_get_default(); auto* group = lv_group_get_default();
auto* focused = group != nullptr ? lv_group_get_focused(group) : nullptr; auto* focused = group != nullptr ? lv_group_get_focused(group) : nullptr;
@ -263,15 +287,11 @@ void showApps(Context* ctx) {
} }
lv_obj_clean(ctx->contentWrapper); lv_obj_clean(ctx->contentWrapper);
std::vector<AppHubApp> apps; tt::std::vector<AppHubApp> apps = ctx->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(); const bool hasApps = !apps.empty();
updateUpdatesChipText(ctx, std::ranges::count_if(apps, [](const AppHubApp& app) { return app.update_available; })); updateUpdatesChipText(ctx, ::std::ranges::count_if(apps, [](const AppHubApp& app) { return app.update_available; }));
updateToolbarActions(ctx, std::ranges::any_of(apps, [](const AppHubApp& app) { return app.update_available; })); updateToolbarActions(ctx, ::std::ranges::any_of(apps, [](const AppHubApp& app) { return app.update_available; }));
std::erase_if(apps, [ctx](const AppHubApp& app) { ::std::erase_if(apps, [ctx](const AppHubApp& app) {
return !matchesFilter(app, ctx->filter); return !matchesFilter(app, ctx->filter);
}); });
ctx->appIds.clear(); ctx->appIds.clear();
@ -331,11 +351,15 @@ void refresh(Context* ctx) {
// Buried (e.g. AppHubDetailsApp is open): destroyWidgets() already released these. A refresh // 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 // request queued just before burying could still land here, so re-check rather than assume
// requestRefresh() and refresh() always run against a live window. // requestRefresh() and refresh() always run against a live window.
if (ctx->refreshOperationId != 0 || ctx->contentWrapper == nullptr) { if (ctx->refreshOperationId != 0) {
return; return;
} }
lvgl_lock(); lvgl_lock();
if (ctx->contentWrapper == nullptr) {
lvgl_unlock();
return;
}
lv_obj_set_hidden(ctx->refreshButton, true); lv_obj_set_hidden(ctx->refreshButton, true);
lvgl_unlock(); lvgl_unlock();
@ -364,8 +388,21 @@ void refresh(Context* ctx) {
ctx->refreshOperationId = operation_id; ctx->refreshOperationId = operation_id;
// Shows the cached catalog while the new one downloads // Shows the cached catalog while the new one downloads
loadApps(ctx);
lvgl_lock(); lvgl_lock();
if (ctx->contentWrapper != nullptr) {
showApps(ctx); showApps(ctx);
}
lvgl_unlock();
}
// Runs on appMain()'s own task, after the window resurfaced
void reload(Context* ctx) {
loadApps(ctx);
lvgl_lock();
if (ctx->contentWrapper != nullptr) {
showApps(ctx);
}
lvgl_unlock(); lvgl_unlock();
} }
@ -389,6 +426,7 @@ void onOperationEvent(Context* ctx, const AppHubEvent& event) {
} }
} }
loadApps(ctx);
lvgl_lock(); lvgl_lock();
if (ctx->contentWrapper == nullptr) { if (ctx->contentWrapper == nullptr) {
// Buried (e.g. AppHubDetailsApp or a dialog is open): createWidgets() rebuilds the list on resurface // Buried (e.g. AppHubDetailsApp or a dialog is open): createWidgets() rebuilds the list on resurface
@ -432,6 +470,8 @@ void createWidgets(lv_obj_t* parent, void* userData) {
// instead of hitting the network again. window_manager calls createWidgets() with the // instead of hitting the network again. window_manager calls createWidgets() with the
// LVGL lock already held, so this can touch widgets directly. // LVGL lock already held, so this can touch widgets directly.
showApps(ctx); showApps(ctx);
// The cache can be stale, so the app's task reloads it and shows the list again
requestReload(ctx);
} }
} }
@ -455,7 +495,7 @@ void onUpdateAllEvent(Context* ctx, const AppHubEvent& event) {
} else if (event.type == APP_HUB_EVENT_UPDATED && event.operation_id == ctx->updateAllOperationId) { } else if (event.type == APP_HUB_EVENT_UPDATED && event.operation_id == ctx->updateAllOperationId) {
ctx->updateAllOperationId = 0; ctx->updateAllOperationId = 0;
if (!ctx->failedUpdates.empty()) { if (!ctx->failedUpdates.empty()) {
std::string names; tt::std::string names;
for (const auto& id : ctx->failedUpdates) { for (const auto& id : ctx->failedUpdates) {
AppHubApp app {}; AppHubApp app {};
if (!names.empty()) { if (!names.empty()) {
@ -464,7 +504,7 @@ void onUpdateAllEvent(Context* ctx, const AppHubEvent& event) {
names += (app_hub_get_app(id.c_str(), &app) == ERROR_NONE) ? app.name : id.c_str(); names += (app_hub_get_app(id.c_str(), &app) == ERROR_NONE) ? app.name : id.c_str();
} }
const auto count = ctx->failedUpdates.size(); const auto count = ctx->failedUpdates.size();
alertdialog::start(ctx->appInstanceId, "Update failed", std::format("{} {} failed to update: {}", count, count == 1 ? "app" : "apps", names)); alertdialog::start(ctx->appInstanceId, "Update failed", ::std::format("{} {} failed to update: {}", count, count == 1 ? "app" : "apps", names));
ctx->failedUpdates.clear(); ctx->failedUpdates.clear();
} }
} }
@ -486,6 +526,7 @@ int32_t appMain(int argc, char* argv[]) {
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
check(app_hub_event_subscribe(&ctx.appHubSub, &event_group) == ERROR_NONE); check(app_hub_event_subscribe(&ctx.appHubSub, &event_group) == ERROR_NONE);
loadApps(&ctx);
WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false; bool shouldClose = false;
@ -509,6 +550,9 @@ int32_t appMain(int argc, char* argv[]) {
onOperationEvent(&ctx, app_hub_event); onOperationEvent(&ctx, app_hub_event);
} }
if (!shouldClose && ctx.reloadRequested.exchange(false)) {
reload(&ctx);
}
if (!shouldClose && ctx.refreshRequested.exchange(false)) { if (!shouldClose && ctx.refreshRequested.exchange(false)) {
refresh(&ctx); refresh(&ctx);
} }

View File

@ -23,6 +23,9 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <TactilityCpp/std/string.h>
#include <TactilityCpp/std/vector.h>
namespace tt::app::apphubdetails { namespace tt::app::apphubdetails {
constexpr auto* TAG = "AppHubDetails"; constexpr auto* TAG = "AppHubDetails";
@ -38,24 +41,37 @@ constexpr size_t DESCRIPTION_MAX_SIZE = 2048;
struct Context { struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
std::string appId; tt::std::string appId;
lv_obj_t* toolbar = nullptr; lv_obj_t* toolbar = nullptr;
lv_obj_t* spinner = nullptr; lv_obj_t* spinner = nullptr;
lv_obj_t* updateButton = nullptr; lv_obj_t* updateButton = nullptr;
lv_obj_t* updateLabel = nullptr; lv_obj_t* updateLabel = nullptr;
lv_obj_t* descriptionLabel = nullptr;
lv_obj_t* versionLabel = nullptr;
/** Whether createWidgets() ran before, so the next call is a resurface */
bool created = false;
// Read from the catalog file on this app's own thread (see loadState()), as the widget code
// can't do file operations. Written with the LVGL lock held, read by the widget code.
AppHubApp app {};
bool anotherAppBusy = false;
tt::std::string description;
// Set from the LVGL task (button press), read from this app's own thread (event loop) - // Set from the LVGL task (button press), read from this app's own thread (event loop) -
// both directions cross threads, hence atomic. // both directions cross threads, hence atomic.
std::atomic<uint32_t> installDialogId = 0; ::std::atomic<uint32_t> installDialogId = 0;
std::atomic<uint32_t> uninstallDialogId = 0; ::std::atomic<uint32_t> uninstallDialogId = 0;
std::atomic<uint32_t> updateDialogId = 0; ::std::atomic<uint32_t> updateDialogId = 0;
TaskEventGroup* eventGroup = nullptr; TaskEventGroup* eventGroup = nullptr;
uint32_t reloadBit = 0;
/** Set when the window resurfaces: the catalog can change while a dialog covers it */
::std::atomic<bool> reloadRequested { false };
AppHubEventSubscription appHubSub {}; AppHubEventSubscription appHubSub {};
}; };
AppHubApp getApp(Context* ctx) { AppHubApp readApp(Context* ctx) {
AppHubApp app {}; AppHubApp app {};
if (app_hub_get_app(ctx->appId.c_str(), &app) != ERROR_NONE) { 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.id, ctx->appId.c_str(), sizeof(app.id) - 1);
@ -81,11 +97,48 @@ bool isAnotherAppBusy(Context* ctx) {
return search.busy; return search.busy;
} }
tt::std::string readDescription(Context* ctx) {
tt::std::vector<char> buffer(DESCRIPTION_MAX_SIZE);
if (app_hub_get_description(ctx->appId.c_str(), buffer.data(), buffer.size()) != ERROR_NONE || buffer[0] == '\0') {
return "This app has no description yet.";
}
tt::std::string description = buffer.data();
for (size_t pos = 0; (pos = description.find("\\n", pos)) != tt::std::string::npos;) {
description.replace(pos, 2, "\n");
}
return description;
}
/** Reads the state from App Hub. Doesn't touch widgets, so it runs without the LVGL lock. */
void loadState(Context* ctx, bool includeDescription) {
const auto app = readApp(ctx);
const bool anotherAppBusy = isAnotherAppBusy(ctx);
tt::std::string description;
if (includeDescription) {
description = readDescription(ctx);
}
lvgl_lock();
ctx->app = app;
ctx->anotherAppBusy = anotherAppBusy;
if (includeDescription) {
ctx->description = ::std::move(description);
}
lvgl_unlock();
}
void updateViews(Context* ctx); void updateViews(Context* ctx);
/** Loads the state and shows it. Runs on this app's own thread. */
void refreshViews(Context* ctx, bool includeDescription = false) {
loadState(ctx, includeDescription);
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
}
uint32_t showConfirmDialog(Context* ctx, const char* action) { uint32_t showConfirmDialog(Context* ctx, const char* action) {
const auto message = std::format("{} {}?", action, getApp(ctx).name); const auto message = ::std::format("{} {}?", action, ctx->app.name);
return alertdialog::start(ctx->appInstanceId, CONFIRM_TEXT, message, std::vector<std::string> { CONFIRM_TEXT, CANCEL_TEXT }); return alertdialog::start(ctx->appInstanceId, CONFIRM_TEXT, message, ::std::vector<::std::string> { CONFIRM_TEXT, CANCEL_TEXT });
} }
void onBackPressed(lv_event_t* e) { void onBackPressed(lv_event_t* e) {
@ -117,9 +170,7 @@ void queueOperation(Context* ctx, error_t (*operation)(const char* id, uint32_t*
alertdialog::start(ctx->appInstanceId, "Error", failure); alertdialog::start(ctx->appInstanceId, "Error", failure);
return; return;
} }
lvgl_lock(); refreshViews(ctx);
updateViews(ctx);
lvgl_unlock();
} }
void onOperationEvent(Context* ctx, const AppHubEvent& event) { void onOperationEvent(Context* ctx, const AppHubEvent& event) {
@ -130,9 +181,7 @@ void onOperationEvent(Context* ctx, const AppHubEvent& event) {
const char* message = (event.type == APP_HUB_EVENT_REMOVED) ? "Failed to uninstall app" : "Failed to install app"; const char* message = (event.type == APP_HUB_EVENT_REMOVED) ? "Failed to uninstall app" : "Failed to install app";
alertdialog::start(ctx->appInstanceId, "Error", message); alertdialog::start(ctx->appInstanceId, "Error", message);
} }
lvgl_lock(); refreshViews(ctx);
updateViews(ctx);
lvgl_unlock();
} }
void updateViews(Context* ctx) { void updateViews(Context* ctx) {
@ -140,14 +189,16 @@ void updateViews(Context* ctx) {
if (ctx->toolbar == nullptr) { if (ctx->toolbar == nullptr) {
return; return;
} }
lv_label_set_text(ctx->descriptionLabel, ctx->description.c_str());
lv_label_set_text_fmt(ctx->versionLabel, "Version %s", ctx->app.in_catalog ? ctx->app.version_name : ctx->app.installed_version_name);
lvgl_toolbar_clear_actions(ctx->toolbar); lvgl_toolbar_clear_actions(ctx->toolbar);
ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar); ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar);
lv_obj_set_hidden(ctx->updateLabel, true); lv_obj_set_hidden(ctx->updateLabel, true);
// This app's install or removal continues after the window closes, so it can already be running when the window opens. // 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. // Another app's install or removal blocks this app's buttons until it's done.
const auto app = getApp(ctx); const auto& app = ctx->app;
const bool busy = app.operation_pending || ((app.installed || app.in_catalog) && isAnotherAppBusy(ctx)); const bool busy = app.operation_pending || ((app.installed || app.in_catalog) && ctx->anotherAppBusy);
lv_obj_set_hidden(ctx->spinner, !busy); lv_obj_set_hidden(ctx->spinner, !busy);
if (busy) { if (busy) {
return; return;
@ -170,7 +221,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
const auto app = getApp(ctx); const auto& app = ctx->app;
ctx->toolbar = lvgl_toolbar_create(parent, app.name); ctx->toolbar = lvgl_toolbar_create(parent, app.name);
// The global toolbar nav callback only knows how to stop old-model apps. // 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); lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
@ -184,28 +235,27 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_label_set_text(ctx->updateLabel, "Update available!"); lv_label_set_text(ctx->updateLabel, "Update available!");
lv_obj_set_style_text_color(ctx->updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT); lv_obj_set_style_text_color(ctx->updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT);
auto* description_label = lv_label_create(wrapper); ctx->descriptionLabel = lv_label_create(wrapper);
lv_obj_set_width(description_label, LV_PCT(100)); lv_obj_set_width(ctx->descriptionLabel, LV_PCT(100));
lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP); lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_MODE_WRAP);
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");
}
lv_label_set_text(description_label, description.c_str());
} else {
lv_label_set_text(description_label, "This app has no description yet.");
}
auto* version_label = lv_label_create(wrapper); ctx->versionLabel = lv_label_create(wrapper);
lv_label_set_text_fmt(version_label, "Version %s", app.in_catalog ? app.version_name : app.installed_version_name);
updateViews(ctx); updateViews(ctx);
// The cached state shows right away. The app's task then reloads it, as the catalog can have
// changed while a dialog covered this window.
if (ctx->created) {
ctx->reloadRequested = true;
task_event_group_signal(ctx->eventGroup, ctx->reloadBit);
}
ctx->created = true;
} }
void destroyWidgets(void* userData) { void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
ctx->descriptionLabel = nullptr;
ctx->versionLabel = nullptr;
ctx->toolbar = nullptr; ctx->toolbar = nullptr;
ctx->spinner = nullptr; ctx->spinner = nullptr;
ctx->updateButton = nullptr; ctx->updateButton = nullptr;
@ -228,8 +278,10 @@ int32_t appMain(int argc, char* argv[]) {
AppEventSubscription sub {}; AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
check(task_event_group_claim_bit(&event_group, &ctx.reloadBit) == ERROR_NONE);
check(app_hub_event_subscribe(&ctx.appHubSub, &event_group) == ERROR_NONE); check(app_hub_event_subscribe(&ctx.appHubSub, &event_group) == ERROR_NONE);
loadState(&ctx, true);
WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false; bool shouldClose = false;
@ -262,10 +314,17 @@ int32_t appMain(int argc, char* argv[]) {
AppHubEvent app_hub_event {}; AppHubEvent app_hub_event {};
while (!shouldClose && app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) { 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) { if (app_hub_event.type == APP_HUB_EVENT_REFRESHED) {
// A new catalog can change the version, description and actions
ctx.reloadRequested = true;
} else if (app_hub_event.type != APP_HUB_EVENT_REFRESHING) {
onOperationEvent(&ctx, app_hub_event); onOperationEvent(&ctx, app_hub_event);
} }
} }
if (!shouldClose && ctx.reloadRequested.exchange(false)) {
refreshViews(&ctx, true);
}
} }
// Queued operations continue in the background after the window closes // Queued operations continue in the background after the window closes

View File

@ -3,10 +3,11 @@
#include <Tactility/app/btmanage/BtManagePrivate.h> #include <Tactility/app/btmanage/BtManagePrivate.h>
#include <Tactility/app/btmanage/View.h> #include <Tactility/app/btmanage/View.h>
#include <Tactility/bluetooth/BluetoothSettings.h> #include <Tactility/bluetooth/BluetoothSettings.h>
#include <Tactility/Tactility.h> #include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/MainDispatcher.h>
#include <app/event.h> #include <app/event.h>
#include <app/manager.h> #include <app/location.h>
#include <app/manifest.h> #include <app/manifest.h>
#include <app/scheduler.h> #include <app/scheduler.h>
#include <app/start.h> #include <app/start.h>
@ -14,6 +15,7 @@
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h> #include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/log.h> #include <tactility/log.h>
@ -23,7 +25,6 @@ constexpr auto* TAG = "BtManage";
extern const ::AppManifest manifest; extern const ::AppManifest manifest;
static void onBtToggled(void* /*context*/, bool requestOn) { static void onBtToggled(void* /*context*/, bool requestOn) {
#if defined(CONFIG_BT_NIMBLE_ENABLED) #if defined(CONFIG_BT_NIMBLE_ENABLED)
// Dispatched, so file IO doesn't block the UI // Dispatched, so file IO doesn't block the UI
@ -257,9 +258,9 @@ extern const ::AppManifest manifest = {
.id = "tactility.btmanage", .id = "tactility.btmanage",
.name = "Bluetooth", .name = "Bluetooth",
.category = APP_CATEGORY_SETTINGS, .category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
.flags = 0, .flags = 0,
.stack = {} .stack = {},
}; };
} // namespace tt::app::btmanage } // namespace tt::app::btmanage

View File

@ -63,20 +63,14 @@ struct Context {
::std::string pendingChannelName; ::std::string pendingChannelName;
tt::std::vector<MeshChannel> channels; tt::std::vector<MeshChannel> channels;
// Valid while the window is shown // Valid while the window is shown
lv_obj_t* statusLabel = nullptr; lv_obj_t* networkHeader = nullptr;
lv_obj_t* content = nullptr; lv_obj_t* content = nullptr;
NetworkInfo network;
}; };
bool isMeshOn() {
return mesh_get_state() == MESH_STATE_ON;
}
::std::string getStatusText() {
return ::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state());
}
/** Queries the service thread, so it's called without the LVGL lock */ /** Queries the service thread, so it's called without the LVGL lock */
void loadData(Context* ctx) { void loadData(Context* ctx) {
ctx->network = loadNetworkInfo();
ctx->channels.assign(CHANNEL_LIMIT, MeshChannel {}); ctx->channels.assign(CHANNEL_LIMIT, MeshChannel {});
size_t count = 0; size_t count = 0;
if (mesh_get_channels(ctx->channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) { if (mesh_get_channels(ctx->channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) {
@ -118,14 +112,14 @@ void updateWidgets(Context* ctx) {
if (ctx->content == nullptr) { if (ctx->content == nullptr) {
return; return;
} }
lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); updateNetworkHeader(ctx->networkHeader, ctx->network);
lv_obj_set_hidden(ctx->statusLabel, isMeshOn());
lv_obj_clean(ctx->content); lv_obj_clean(ctx->content);
auto* channelList = createCard(ctx->content); auto* channelList = createCard(ctx->content);
for (size_t i = 0; i < ctx->channels.size(); i++) { for (size_t i = 0; i < ctx->channels.size(); i++) {
addListButton(channelList, ctx->channels[i].name, onChannelPressed, ctx, (intptr_t)i); addListButton(channelList, ctx->channels[i].name, onChannelPressed, ctx, (intptr_t)i);
} }
lvgl_list_hide_last_divider(channelList);
} }
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
@ -139,9 +133,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx); lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx); lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx);
ctx->statusLabel = lv_label_create(parent); ctx->networkHeader = createNetworkHeader(parent);
lv_obj_set_width(ctx->statusLabel, LV_PCT(100));
lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT);
ctx->content = createContent(parent); ctx->content = createContent(parent);
@ -150,7 +142,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
void destroyWidgets(void* userData) { void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
ctx->statusLabel = nullptr; ctx->networkHeader = nullptr;
ctx->content = nullptr; ctx->content = nullptr;
} }

View File

@ -10,6 +10,9 @@ namespace tt::app::meshchat {
constexpr auto* TAG = "MeshChat"; constexpr auto* TAG = "MeshChat";
/** The padding of the content area that holds the cards */
#define CONTENT_PADDING LV_DPX(4)
void startApp(const char* id, int argc, const char* const argv[]) { void startApp(const char* id, int argc, const char* const argv[]) {
AppStartContext context; AppStartContext context;
if (app_start_context_from_id(id, &context) != ERROR_NONE) { if (app_start_context_from_id(id, &context) != ERROR_NONE) {
@ -23,6 +26,73 @@ void startApp(const char* id, int argc, const char* const argv[]) {
} }
} }
static const char* getNetworkLabel(const char* id) {
if (strcmp(id, "meshcore") == 0) return "MeshCore";
if (strcmp(id, "meshtastic") == 0) return "Meshtastic";
return id;
}
NetworkInfo loadNetworkInfo() {
NetworkInfo info;
// A backend and a radio stay selected while mesh is off
if (mesh_get_state() != MESH_STATE_ON) {
return info;
}
info.running = true;
char id[MESH_BACKEND_ID_MAX_LENGTH + 1];
if (mesh_get_backend(id, sizeof(id)) != ERROR_NONE) {
return info;
}
char radio[MESH_NAME_MAX_LENGTH + 1];
if (mesh_get_radio(radio, sizeof(radio)) != ERROR_NONE) {
return info;
}
const size_t count = mesh_get_radio_count();
for (size_t i = 0; i < count; i++) {
char name[MESH_NAME_MAX_LENGTH + 1];
MeshRadioKind kind;
if (mesh_get_radio_name(i, name, sizeof(name)) == ERROR_NONE && strcmp(name, radio) == 0 && mesh_get_radio_kind(i, &kind) == ERROR_NONE) {
snprintf(info.network, sizeof(info.network), "%s", getNetworkLabel(id));
snprintf(info.modulation, sizeof(info.modulation), "%s", kind == MESH_RADIO_LORA ? "LoRa" : "ESP-NOW");
break;
}
}
return info;
}
// The spans: "Using ", the network, " via ", the modulation. While mesh is off: "Mesh is ", "OFF", "", ""
constexpr int32_t NETWORK_SPAN = 1;
constexpr int32_t MODULATION_SPAN = 3;
lv_obj_t* createNetworkHeader(lv_obj_t* parent) {
auto* header = lv_spangroup_create(parent);
lv_obj_set_width(header, LV_PCT(100));
lv_obj_set_height(header, LV_SIZE_CONTENT);
// Lines up with the cards: the content's padding plus the card's margin, which is the theme's container padding
auto* probe = lv_obj_create(parent);
const int32_t cardMargin = lv_obj_get_style_pad_left(probe, LV_PART_MAIN);
lv_obj_delete(probe);
lv_obj_set_style_pad_hor(header, CONTENT_PADDING + cardMargin, LV_STATE_DEFAULT);
const auto secondary = lv_theme_get_color_secondary(header);
for (const char* text : { "Using ", "", " via ", "" }) {
auto* span = lv_spangroup_add_span(header);
lv_span_set_text_static(span, text);
}
lv_style_set_text_color(lv_span_get_style(lv_spangroup_get_child(header, NETWORK_SPAN)), secondary);
lv_style_set_text_color(lv_span_get_style(lv_spangroup_get_child(header, MODULATION_SPAN)), secondary);
return header;
}
void updateNetworkHeader(lv_obj_t* header, const NetworkInfo& info) {
lv_obj_set_hidden(header, info.running && info.network[0] == '\0');
lv_span_set_text(lv_spangroup_get_child(header, 0), info.running ? "Using " : "Mesh is ");
lv_span_set_text(lv_spangroup_get_child(header, NETWORK_SPAN), info.running ? info.network : "OFF");
lv_span_set_text(lv_spangroup_get_child(header, 2), info.running ? " via " : "");
lv_span_set_text(lv_spangroup_get_child(header, MODULATION_SPAN), info.modulation);
lv_spangroup_refresh(header);
}
lv_obj_t* createContent(lv_obj_t* parent) { lv_obj_t* createContent(lv_obj_t* parent) {
auto* content = lv_obj_create(parent); auto* content = lv_obj_create(parent);
// The theme's container padding is the margin of the cards, read before it is overridden below // The theme's container padding is the margin of the cards, read before it is overridden below
@ -30,8 +100,8 @@ lv_obj_t* createContent(lv_obj_t* parent) {
lv_obj_set_width(content, LV_PCT(100)); lv_obj_set_width(content, LV_PCT(100));
lv_obj_set_flex_grow(content, 1); lv_obj_set_flex_grow(content, 1);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(content, LV_DPX(4), LV_STATE_DEFAULT); lv_obj_set_style_pad_all(content, CONTENT_PADDING, LV_STATE_DEFAULT);
lv_obj_set_style_pad_row(content, LV_DPX(4), LV_STATE_DEFAULT); lv_obj_set_style_pad_row(content, CONTENT_PADDING, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT);
return content; return content;
} }
@ -46,7 +116,10 @@ lv_obj_t* createCard(lv_obj_t* parent) {
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_row(card, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_row(card, 0, LV_STATE_DEFAULT);
// The items' backgrounds and dividers follow the card's rounded corners
lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT);
return card; return card;
} }
} }

View File

@ -58,20 +58,14 @@ struct Context {
::std::string pendingContactName; ::std::string pendingContactName;
tt::std::vector<MeshContact> contacts; tt::std::vector<MeshContact> contacts;
// Valid while the window is shown // Valid while the window is shown
lv_obj_t* statusLabel = nullptr; lv_obj_t* networkHeader = nullptr;
lv_obj_t* content = nullptr; lv_obj_t* content = nullptr;
NetworkInfo network;
}; };
bool isMeshOn() {
return mesh_get_state() == MESH_STATE_ON;
}
::std::string getStatusText() {
return ::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state());
}
/** Queries the service thread, so it's called without the LVGL lock */ /** Queries the service thread, so it's called without the LVGL lock */
void loadData(Context* ctx) { void loadData(Context* ctx) {
ctx->network = loadNetworkInfo();
ctx->contacts.assign(CONTACT_LIMIT, MeshContact {}); ctx->contacts.assign(CONTACT_LIMIT, MeshContact {});
size_t count = 0; size_t count = 0;
if (mesh_get_contacts(ctx->contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { if (mesh_get_contacts(ctx->contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) {
@ -109,8 +103,7 @@ void updateWidgets(Context* ctx) {
if (ctx->content == nullptr) { if (ctx->content == nullptr) {
return; return;
} }
lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); updateNetworkHeader(ctx->networkHeader, ctx->network);
lv_obj_set_hidden(ctx->statusLabel, isMeshOn());
lv_obj_clean(ctx->content); lv_obj_clean(ctx->content);
auto* contactList = createCard(ctx->content); auto* contactList = createCard(ctx->content);
@ -120,6 +113,7 @@ void updateWidgets(Context* ctx) {
if (ctx->contacts.empty()) { if (ctx->contacts.empty()) {
lvgl_list_add_text(contactList, "No contacts yet. Add a node that you've heard, or wait for a direct message."); lvgl_list_add_text(contactList, "No contacts yet. Add a node that you've heard, or wait for a direct message.");
} }
lvgl_list_hide_last_divider(contactList);
} }
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
@ -133,9 +127,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* menuButton = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_MORE_VERT, onMenuPressed, ctx); auto* menuButton = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_MORE_VERT, onMenuPressed, ctx);
lv_obj_set_style_text_font(menuButton, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); lv_obj_set_style_text_font(menuButton, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
ctx->statusLabel = lv_label_create(parent); ctx->networkHeader = createNetworkHeader(parent);
lv_obj_set_width(ctx->statusLabel, LV_PCT(100));
lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT);
ctx->content = createContent(parent); ctx->content = createContent(parent);
@ -144,7 +136,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
void destroyWidgets(void* userData) { void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
ctx->statusLabel = nullptr; ctx->networkHeader = nullptr;
ctx->content = nullptr; ctx->content = nullptr;
} }

View File

@ -1,100 +0,0 @@
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
#include <Tactility/service/espnow/EspNow.h>
#include <Tactility/service/espnow/EspNowService.h>
#include <tactility/log.h>
namespace tt::service::espnow {
constexpr auto* TAG = "EspNow";
void enable(const EspNowConfig& config) {
auto service = findService();
if (service != nullptr) {
service->enable(config);
} else {
LOG_E(TAG, "Service not found");
}
}
void disable() {
auto service = findService();
if (service != nullptr) {
service->disable();
} else {
LOG_E(TAG, "Service not found");
}
}
bool isEnabled() {
auto service = findService();
if (service != nullptr) {
return service->isEnabled();
} else {
LOG_E(TAG, "Service not found");
return false;
}
}
bool addPeer(const esp_now_peer_info_t& peer) {
auto service = findService();
if (service != nullptr) {
return service->addPeer(peer);
} else {
LOG_E(TAG, "Service not found");
return false;
}
}
bool send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength) {
auto service = findService();
if (service != nullptr) {
return service->send(address, buffer, bufferLength);
} else {
LOG_E(TAG, "Service not found");
return false;
}
}
ReceiverSubscription subscribeReceiver(std::function<void(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length)> onReceive) {
auto service = findService();
if (service != nullptr) {
return service->subscribeReceiver(onReceive);
} else {
LOG_E(TAG, "Service not found");
return -1;
}
}
void unsubscribeReceiver(ReceiverSubscription subscription) {
auto service = findService();
if (service != nullptr) {
service->unsubscribeReceiver(subscription);
} else {
LOG_E(TAG, "Service not found");
}
}
uint32_t getVersion() {
auto service = findService();
if (service != nullptr) {
return service->getVersion();
}
LOG_E(TAG, "Service not found");
return 0;
}
size_t getMaxDataLength() {
auto v = getVersion();
if (v == 0) return 0;
return v >= 2 ? MAX_DATA_LEN_V2 : MAX_DATA_LEN_V1;
}
}
#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_ESP_HOSTED_ENABLED

View File

@ -1,202 +0,0 @@
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)
#include <Tactility/Tactility.h>
#include <Tactility/service/espnow/EspNowService.h>
#include <Tactility/service/ServiceManifest.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/service/espnow/EspNowBackend.h>
#include <cstring>
#include <esp_now.h>
#include <esp_random.h>
#include <tactility/log.h>
namespace tt::service::espnow {
extern const ServiceManifest manifest;
constexpr auto* TAG = "EspNowService";
static uint8_t BROADCAST_MAC[ESP_NOW_ETH_ALEN];
constexpr TickType_t MAX_DELAY = 1000U / portTICK_PERIOD_MS;
constexpr bool isBroadcastAddress(uint8_t address[ESP_NOW_ETH_ALEN]) { return memcmp(address, BROADCAST_MAC, ESP_NOW_ETH_ALEN) == 0; }
bool EspNowService::onStart(ServiceContext& service) {
auto lock = mutex.asScopedLock();
lock.lock();
memset(BROADCAST_MAC, 0xFF, sizeof(BROADCAST_MAC));
return true;
}
void EspNowService::onStop(ServiceContext& service) {
auto lock = mutex.asScopedLock();
lock.lock();
if (isEnabled()) {
disable();
}
}
// region Enable
void EspNowService::enable(const EspNowConfig& config) {
getMainDispatcher().dispatch([this, config] {
enableFromDispatcher(config);
});
}
void EspNowService::enableFromDispatcher(const EspNowConfig& config) {
auto lock = mutex.asScopedLock();
lock.lock();
if (enabled) {
return;
}
if (!backend::init(config, receiveCallback)) {
LOG_E(TAG, "backend::init() failed");
return;
}
espnowVersion = backend::getVersion();
if (espnowVersion != 0) {
LOG_I(TAG, "ESP-NOW version: %u.0", (unsigned)espnowVersion);
} else {
LOG_W(TAG, "Failed to get ESP-NOW version");
}
// Add default unencrypted broadcast peer
esp_now_peer_info_t broadcast_peer;
memset(&broadcast_peer, 0, sizeof(esp_now_peer_info_t));
memcpy(broadcast_peer.peer_addr, BROADCAST_MAC, sizeof(BROADCAST_MAC));
service::espnow::addPeer(broadcast_peer);
enabled = true;
}
// endregion Enable
// region Disable
void EspNowService::disable() {
getMainDispatcher().dispatch([this]() {
disableFromDispatcher();
});
}
void EspNowService::disableFromDispatcher() {
auto lock = mutex.asScopedLock();
lock.lock();
if (!enabled) {
return;
}
if (!backend::deinit()) {
LOG_E(TAG, "backend::deinit() failed");
}
espnowVersion = 0;
enabled = false;
}
// region Disable
// region Callbacks
void EspNowService::receiveCallback(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
auto service = findService();
if (service == nullptr) {
LOG_E(TAG,"Service not running");
return;
}
service->onReceive(receiveInfo, data, length);
}
void EspNowService::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
auto lock = mutex.asScopedLock();
lock.lock();
LOG_D(TAG, "Received %d bytes", length);
for (const auto& item: subscriptions) {
item.onReceive(receiveInfo, data, length);
}
}
// endregion Callbacks
bool EspNowService::isEnabled() const {
auto lock = mutex.asScopedLock();
lock.lock();
return enabled;
}
bool EspNowService::addPeer(const esp_now_peer_info_t& peer) {
if (!backend::addPeer(peer)) {
LOG_E(TAG,"Failed to add peer");
return false;
} else {
LOG_I(TAG, "Peer added");
return true;
}
}
bool EspNowService::send(const uint8_t* address, const uint8_t* buffer, size_t bufferLength) {
auto lock = mutex.asScopedLock();
lock.lock();
if (!isEnabled()) {
return false;
} else {
return backend::send(address, buffer, bufferLength);
}
}
ReceiverSubscription EspNowService::subscribeReceiver(std::function<void(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length)> onReceive) {
auto lock = mutex.asScopedLock();
lock.lock();
auto id = lastSubscriptionId++;
subscriptions.push_back(ReceiverSubscriptionData {
.id = id,
.onReceive = onReceive
});
return id;
}
void EspNowService::unsubscribeReceiver(ReceiverSubscription subscriptionId) {
auto lock = mutex.asScopedLock();
lock.lock();
std::erase_if(subscriptions, [subscriptionId](auto& subscription) { return subscription.id == subscriptionId; });
}
uint32_t EspNowService::getVersion() const {
auto lock = mutex.asScopedLock();
lock.lock();
return espnowVersion;
}
std::shared_ptr<EspNowService> findService() {
return std::static_pointer_cast<EspNowService>(
findServiceById(manifest.id)
);
}
extern const ServiceManifest manifest = {
.id = "tactility.espnow",
.createService = create<EspNowService>
};
}
#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_ESP_HOSTED_ENABLED

View File

@ -89,7 +89,7 @@ class TtTest(unittest.TestCase):
with open(os.path.join(directory, "manifest.properties"), "w") as file: with open(os.path.join(directory, "manifest.properties"), "w") as file:
file.write(f"[target]\ntarget.sdk={version}\ntarget.platforms={platforms}\n") file.write(f"[target]\ntarget.sdk={version}\ntarget.platforms={platforms}\n")
def run_tt(self, *arguments, tt_path=TT_PATH, environment=None): def run_tt(self, *arguments, tt_path=TT_PATH, environment=None, input_text=""):
extra_environment = environment or {} extra_environment = environment or {}
environment = dict(os.environ) environment = dict(os.environ)
environment.pop("TACTILITY_SDK_PATH", None) environment.pop("TACTILITY_SDK_PATH", None)
@ -97,7 +97,7 @@ class TtTest(unittest.TestCase):
environment["HOME"] = self.home environment["HOME"] = self.home
environment["TT_CDN"] = f"http://127.0.0.1:{self.server.server_address[1]}" environment["TT_CDN"] = f"http://127.0.0.1:{self.server.server_address[1]}"
environment.pop("TACTILITY_SDK_VERSION", None) environment.pop("TACTILITY_SDK_VERSION", None)
return subprocess.run([sys.executable, tt_path, *arguments], cwd=self.work, env=environment, capture_output=True, text=True, timeout=60) return subprocess.run([sys.executable, tt_path, *arguments], cwd=self.work, env=environment, capture_output=True, text=True, timeout=60, input=input_text)
def sdk_path(self, *parts): def sdk_path(self, *parts):
return os.path.join(self.home, ".tactility", "sdk", VERSION, *parts) return os.path.join(self.home, ".tactility", "sdk", VERSION, *parts)
@ -147,6 +147,94 @@ class TtTest(unittest.TestCase):
self.assertEqual(result.returncode, 1) self.assertEqual(result.returncode, 1)
self.assertIn("no manifest.properties found", result.stderr) self.assertIn("no manifest.properties found", result.stderr)
def publish_new_project(self, files=None, checksum=None):
files = files or {
"gui/manifest.properties": "target.sdk=@SDK_VERSION@\nid=@PACKAGE_ID@\napp.0.id=@APP_ID@\napp.0.name=@APP_NAME@\n",
"gui/main/Source/main.c": "// gui\n",
"terminal/manifest.properties": "target.sdk=@SDK_VERSION@\nid=@PACKAGE_ID@\napp.0.id=@APP_ID@\napp.0.name=@APP_NAME@\napp.0.headless=true\n",
"terminal/main/Source/main.c": "// terminal\n",
}
version_directory = os.path.join(self.cdn, "sdk", VERSION)
archive = make_zip(files)
with open(os.path.join(version_directory, "new-project.zip"), "wb") as file:
file.write(archive)
index_path = os.path.join(version_directory, "index.json")
with open(index_path) as file:
index = json.load(file)
index["newProject"] = {"file": "new-project.zip", "sha256": checksum or hashlib.sha256(archive).hexdigest()}
with open(index_path, "w") as file:
json.dump(index, file)
def read_work_file(self, *parts):
with open(os.path.join(self.work, *parts)) as file:
return file.read()
def test_new_project_uses_defaults_and_the_gui_variant(self):
self.publish_new_project()
result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n")
self.assertEqual(result.returncode, 3, result.stderr)
self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\nid=example.helloworld\napp.0.id=helloworld\napp.0.name=Hello World\n")
self.assertEqual(self.read_work_file("main", "Source", "main.c"), "// gui\n")
self.assertEqual(json.loads(result.stdout)["version"], VERSION)
def test_new_project_with_answers_and_the_terminal_variant(self):
self.publish_new_project()
result = self.run_tt("echo", input_text=f"yes\n{VERSION}\nmy.package\nmyapp\nMy App\ny\n")
self.assertEqual(result.returncode, 3, result.stderr)
self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\nid=my.package\napp.0.id=myapp\napp.0.name=My App\napp.0.headless=true\n")
self.assertEqual(self.read_work_file("main", "Source", "main.c"), "// terminal\n")
def test_new_project_asks_again_for_invalid_answers(self):
self.publish_new_project()
result = self.run_tt("echo", input_text=f"y\n..\n{VERSION}\nab\nmy.package\n\nM\nMy App\n\n")
self.assertEqual(result.returncode, 3, result.stderr)
self.assertIn("invalid SDK version", result.stderr)
self.assertIn("use 5 to 32 letters, digits and dots", result.stderr)
self.assertIn("use 2 to 32 letters, digits, spaces and dashes", result.stderr)
self.assertIn("id=my.package", self.read_work_file("manifest.properties"))
def test_new_project_rejects_a_wrong_checksum(self):
self.publish_new_project(checksum="0" * 64)
result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n")
self.assertEqual(result.returncode, 1)
self.assertIn("doesn't match its checksum", result.stderr)
self.assertFalse(os.path.exists(os.path.join(self.work, "manifest.properties")))
def test_new_project_needs_the_sdk_to_have_one(self):
result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n")
self.assertEqual(result.returncode, 1)
self.assertIn("has no new project", result.stderr)
def test_new_project_does_not_overwrite(self):
self.publish_new_project()
os.makedirs(os.path.join(self.work, "main"))
result = self.run_tt("echo", input_text=f"y\n{VERSION}\n\n\n\n\n")
self.assertEqual(result.returncode, 1)
self.assertIn("already has", result.stderr)
def test_new_project_from_the_local_sdk(self):
local = os.path.join(self.root, "local")
os.makedirs(os.path.join(local, "sdk", VERSION, "tools"))
with open(os.path.join(local, "sdk", VERSION, "tools", "echo.py"), "w") as file:
file.write(ECHO_TOOL)
shutil.copy(os.path.join(TOOLS_PATH, "_bootstrap.py"), os.path.join(local, "sdk", VERSION, "tools"))
with open(os.path.join(local, "sdk", VERSION, "new-project.zip"), "wb") as file:
file.write(make_zip({"gui/manifest.properties": "target.sdk=@SDK_VERSION@\n"}))
result = self.run_tt("echo", environment={"TACTILITY_SDK_PATH": local}, input_text=f"y\n{VERSION}\n\n\n\n\n")
self.assertEqual(result.returncode, 3, result.stderr)
self.assertEqual(self.read_work_file("manifest.properties"), f"target.sdk={VERSION}\n")
def test_without_a_project_the_sdk_version_can_be_given(self):
result = self.run_tt("echo", "a", input_text=f"n\n{VERSION}\n")
self.assertEqual(result.returncode, 3, result.stderr)
self.assertEqual(json.loads(result.stdout)["version"], VERSION)
self.assertFalse(os.path.exists(os.path.join(self.work, "manifest.properties")))
def test_without_a_project_and_version_it_fails(self):
result = self.run_tt("echo", input_text="n\n\n")
self.assertEqual(result.returncode, 1)
self.assertIn("no manifest.properties found", result.stderr)
def test_unknown_and_invalid_commands_are_not_found(self): def test_unknown_and_invalid_commands_are_not_found(self):
self.write_manifest(self.work) self.write_manifest(self.work)
for command in ("nope", "../echo", "Echo"): for command in ("nope", "../echo", "Echo"):

117
tt.py
View File

@ -10,6 +10,7 @@ The SDK version is read from ./manifest.properties, or else from the first Apps/
""" """
import hashlib import hashlib
import io
import json import json
import os import os
import re import re
@ -20,11 +21,15 @@ import time
import urllib.request import urllib.request
import zipfile import zipfile
TT_VERSION = "1.1.1" TT_VERSION = "1.2.0"
CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org")
HOME_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility") HOME_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility")
INDEX_VALIDITY_SECONDS = 3600 INDEX_VALIDITY_SECONDS = 3600
COMMAND_PATTERN = re.compile(r"^[a-z0-9-]+$") COMMAND_PATTERN = re.compile(r"^[a-z0-9-]+$")
VERSION_PATTERN = re.compile(r"^[0-9A-Za-z.+-]+$")
ID_PATTERN = re.compile(r"^[0-9A-Za-z.]{5,32}$")
NAME_PATTERN = re.compile(r"^[0-9A-Za-z -]{2,32}$")
NEW_PROJECT_ARCHIVE = "new-project.zip"
def fail(message): def fail(message):
@ -78,16 +83,124 @@ def find_manifest():
return None return None
def is_valid_version(version):
return VERSION_PATTERN.match(version) is not None and ".." not in version
def ask(question, default=None, is_valid=None, description="invalid value"):
"""Asks until the answer is valid. An empty answer or the end of the input gives the default. Returns None when there is none."""
suffix = f" [{default}]" if default is not None else ""
while True:
try:
# On stderr, so the prompts don't end up in the output of the command
print(f"{question}{suffix}: ", end="", file=sys.stderr, flush=True)
answer = input().strip()
except EOFError:
answer = ""
if not answer:
return default
if is_valid is None or is_valid(answer):
return answer
print(f"tt: {description}", file=sys.stderr)
def ask_yes_no(question, default):
answer = ask(f"{question} [{'Y/n' if default else 'y/N'}]")
if answer is None:
return default
return answer.lower() in ("y", "yes")
def read_new_project_archive(version):
"""Returns the content of the new project archive of the SDK version, from the local SDK or else the CDN"""
local_path = os.environ.get("TACTILITY_SDK_PATH") or None
if local_path is not None:
archive_path = os.path.join(version_path(os.path.abspath(local_path), version), NEW_PROJECT_ARCHIVE)
if not os.path.isfile(archive_path):
fail(f"new project not found: {archive_path}")
with open(archive_path, "rb") as file:
return file.read()
version_directory = version_path(HOME_DIRECTORY, version)
os.makedirs(version_directory, exist_ok=True)
entry = load_index(version, version_directory).get("newProject")
if entry is None:
fail(f"SDK {version} has no new project")
archive_path = os.path.join(version_directory, NEW_PROJECT_ARCHIVE)
if not download(sdk_url(version, entry["file"]), archive_path):
fail(f"failed to download the new project of SDK {version}")
try:
with open(archive_path, "rb") as file:
content = file.read()
finally:
os.remove(archive_path)
if hashlib.sha256(content).hexdigest() != entry["sha256"]:
fail(f"the new project of SDK {version} doesn't match its checksum")
return content
def generate_project(version, package_id, app_id, app_name, is_terminal):
"""Extracts the new project of the SDK version into . and fills in the answers"""
variant = "terminal" if is_terminal else "gui"
root = os.path.realpath(".")
created = []
with zipfile.ZipFile(io.BytesIO(read_new_project_archive(version))) as archive:
for member in archive.infolist():
if member.is_dir() or not member.filename.startswith(f"{variant}/"):
continue
relative_path = member.filename[len(variant) + 1:]
destination = os.path.realpath(os.path.join(root, relative_path))
if not destination.startswith(root + os.sep):
fail(f"invalid entry in the new project: {member.filename}")
content = archive.read(member)
if relative_path == "manifest.properties":
text = content.decode("utf-8")
for placeholder, value in (("@SDK_VERSION@", version), ("@PACKAGE_ID@", package_id), ("@APP_ID@", app_id), ("@APP_NAME@", app_name)):
text = text.replace(placeholder, value)
content = text.encode("utf-8")
os.makedirs(os.path.dirname(destination), exist_ok=True)
with open(destination, "wb") as file:
file.write(content)
created.append(relative_path)
if "manifest.properties" not in created:
fail(f"the new project of SDK {version} has no {variant} variant")
return created
def offer_new_project():
"""Asks to generate a new app project. Returns the manifest path when it did, else None"""
if not ask_yes_no("Generate a new app project in this folder?", False):
return None
if os.path.exists("manifest.properties") or os.path.exists("main"):
fail("this folder already has a manifest.properties or main/")
version = ask("SDK version to use", None, is_valid_version, "invalid SDK version")
if version is None:
fail("no SDK version given")
package_id = ask("Package id", "example.helloworld", ID_PATTERN.match, "use 5 to 32 letters, digits and dots")
app_id = ask("App id", "helloworld", ID_PATTERN.match, "use 5 to 32 letters, digits and dots")
app_name = ask("App name", "Hello World", NAME_PATTERN.match, "use 2 to 32 letters, digits, spaces and dashes")
is_terminal = ask_yes_no("Terminal app?", False)
for path in generate_project(version, package_id, app_id, app_name, is_terminal):
print(f"Created {path}", file=sys.stderr)
return "manifest.properties"
def resolve_target(): def resolve_target():
"""Returns the SDK version and platforms of the app manifest""" """Returns the SDK version and platforms of the app manifest"""
manifest_path = find_manifest() manifest_path = find_manifest()
if manifest_path is None: if manifest_path is None:
print("tt: no manifest.properties found in . or Apps/*/ or apps/*/", file=sys.stderr)
manifest_path = offer_new_project()
if manifest_path is None:
# Commands like "tt shell" don't need an app, only the tools of an SDK version
version = ask("SDK version to use without a project (leave empty to abort)", None, is_valid_version, "invalid SDK version")
if version is None:
fail("no manifest.properties found in . or Apps/*/ or apps/*/") fail("no manifest.properties found in . or Apps/*/ or apps/*/")
return version, []
manifest = read_properties(manifest_path) manifest = read_properties(manifest_path)
version = manifest.get("target.sdk") version = manifest.get("target.sdk")
if not version: if not version:
fail(f"target.sdk not found in {manifest_path}") fail(f"target.sdk not found in {manifest_path}")
if not re.match(r"^[0-9A-Za-z.+-]+$", version) or ".." in version: if not is_valid_version(version):
fail(f"invalid target.sdk in {manifest_path}: {version}") fail(f"invalid target.sdk in {manifest_path}: {version}")
platforms = [platform.strip() for platform in manifest.get("target.platforms", "").split(",") if platform.strip()] platforms = [platform.strip() for platform in manifest.get("target.platforms", "").split(",") if platform.strip()]
return version, platforms return version, platforms