App Hub, Setup, BT, tt, posix http, and more (#697)

This commit is contained in:
Ken Van Hoeylandt 2026-10-16 23:27:30 +02:00 committed by GitHub
parent a485dbbf94
commit 76434d5bcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
99 changed files with 3985 additions and 1656 deletions

View File

@ -1,11 +1,13 @@
# Architecture: LVGL # Architecture: LVGL
User interfaces should scale well for everything between very large (e.g. 1280x720) and small (e.g. 135x240) displays. Vertical and horizontal layouts are supported. User interfaces should scale well for everything between very large (e.g. 1280x720) and small (e.g. 135x240) displays. Vertical and horizontal layouts are supported.
Margins and padding are set in density-independent pixels with `LV_DPX()`, not in raw pixels.
Two kernel modules cover LVGL: Two kernel modules cover LVGL:
- **`lvgl-module`** (`Modules/lvgl-module/`, `<lvgl/*.h>`) owns LVGL's lifecycle: init/deinit, the LVGL task loop, and `lvgl_lock()`/`lvgl_try_lock()`/`lvgl_unlock()` mutex-based locking that any task must hold before touching LVGL objects. It bridges Tactility's device model to LVGL indevs (`lvgl/devices/*.h`: `display`, `pointer`, `keyboard`, `trackball`), and provides shared fonts (`lvgl/fonts.h`: Montserrat text sizes, Material Symbols icon sets for statusbar/launcher/shared use) and a few shared widgets (`lvgl/widgets/*.h`: `toolbar`, `spinner`, `sliderbox`). - **`lvgl-module`** (`Modules/lvgl-module/`, `<lvgl/*.h>`) owns LVGL's lifecycle: init/deinit, the LVGL task loop, and `lvgl_lock()`/`lvgl_try_lock()`/`lvgl_unlock()` mutex-based locking that any task must hold before touching LVGL objects. It bridges Tactility's device model to LVGL indevs (`lvgl/devices/*.h`: `display`, `pointer`, `keyboard`, `trackball`), and provides shared fonts (`lvgl/fonts.h`: Montserrat text sizes, Material Symbols icon sets for statusbar/launcher/shared use) and a few shared widgets (`lvgl/widgets/*.h`: `toolbar`, `spinner`, `sliderbox`).
- **`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.
## Keyboard navigation ## Keyboard navigation

23
.claude/rules/memory.md Normal file
View File

@ -0,0 +1,23 @@
# Memory: Keep State Out of Internal RAM
Internal RAM is scarce, especially without PSRAM. Allocate state with external RAM as the desired capability. Without PSRAM it falls back to internal RAM automatically.
- Don't use file-scope or function-local `static` variables for state of more than ~64 bytes, or that hold buffers or containers.
- Bundle the state in a `struct` and allocate it with `memory_alloc_with_policy()` and `MEMORY_CAPABILITY_EXTERNAL`.
In C++ use `tt::newObject<T>()` and `tt::deleteObject()` (`<TactilityCpp/Memory.h>`). Check for `nullptr`.
- With a lifecycle (start and stop): allocate on start, free on stop.
- Without one: allocate once on first use and never free:
```cpp
static State& state() {
static State* instance = tt::newObject<State>();
check(instance != nullptr);
return *instance;
}
```
- Containers that live long use the `tt::std` replacements from TactilityKernelCpp: `tt::std::vector`, `tt::std::deque`, `tt::std::map` and `tt::std::string` (`<TactilityCpp/std/*.h>`).
They prefer external RAM. For other types use `tt::OptExternalAllocator<T>` (`<TactilityCpp/Allocator.h>`).
- Large buffers in functions with small stacks go on the heap. Don't share a static buffer between callers that can run at the same time.
Stays in internal RAM: `const` data (flash), DMA buffers, interrupt handler data, code that runs with the flash cache off, task stacks, and the mutex that guards lazy allocation.
Find large globals with `nm -S --size-sort -C -l <elf>`. Only `.data` and `.bss` count, `const` tables are in flash.

View File

@ -46,8 +46,23 @@ jobs:
persist-credentials: false persist-credentials: false
- name: "Build Tools" - name: "Build Tools"
uses: ./.github/actions/build-tools uses: ./.github/actions/build-tools
# Always runs, so the jobs that depend on it aren't skipped for pushes
CheckTtVersion:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
if: github.event_name == 'pull_request'
with:
persist-credentials: false
fetch-depth: 0
- name: "Check tt version"
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: python3 Buildscripts/check-tt-version.py "origin/$BASE_REF"
BuildTt: BuildTt:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ CheckTtVersion ]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:

View File

@ -55,7 +55,7 @@ if __name__ == "__main__":
if not install_tt(directory): if not install_tt(directory):
sys.exit(1) sys.exit(1)
print(f"Installed tt in {directory}") print(f"Installed tt in {directory}")
command = "tt" if os.name == "nt" else "./tt" command = "py tt.py" if os.name == "nt" else "./tt"
print_warning(f"tactility.py was replaced by tt: use tt from now on, e.g. '{command} app build'") print_warning(f"tactility.py was replaced by tt: use tt from now on, e.g. '{command} app build'")
print_warning(f"Run '{command} help' for more info") print_warning(f"Run '{command} help' for more info")
sys.exit(1) sys.exit(1)

View File

@ -1,6 +1,7 @@
idf_component_register( idf_component_register(
INCLUDE_DIRS INCLUDE_DIRS
"Libraries/TactilityKernel/include" "Libraries/TactilityKernel/include"
"Libraries/TactilityKernelCpp/Include"
"Libraries/TactilityFreeRtos/Include" "Libraries/TactilityFreeRtos/Include"
"Libraries/lvgl/include" "Libraries/lvgl/include"
"Libraries/minmea/include" "Libraries/minmea/include"

View File

@ -62,8 +62,82 @@ macro(tactility_project_post project_name)
service-module service-module
) )
_tactility_enable_sdk_ccache()
endmacro() endmacro()
# Compiles the ESP-IDF and SDK components through ccache, so apps built for the same SDK and
# platform reuse each other's objects: the SDK's fixed sdkconfig makes these objects identical
# across apps. The app's own components get no launcher, so their sources are never cached.
# Each platform uses its own cache directory: <TACTILITY_CCACHE_DIR or ~/.tactility/ccache>/<target>.
# Disable with -D TACTILITY_CCACHE=OFF (the tool's --no-ccache).
function(_tactility_enable_sdk_ccache)
set(TACTILITY_CCACHE ON CACHE BOOL "Compile ESP-IDF and SDK components through ccache")
if (NOT TACTILITY_CCACHE)
return()
endif()
find_program(TACTILITY_CCACHE_PROGRAM ccache)
if (NOT TACTILITY_CCACHE_PROGRAM)
return()
endif()
if (DEFINED ENV{TACTILITY_CCACHE_DIR})
set(cache_root "$ENV{TACTILITY_CCACHE_DIR}")
elseif (CMAKE_HOST_WIN32)
set(cache_root "$ENV{USERPROFILE}/.tactility/ccache")
else()
set(cache_root "$ENV{HOME}/.tactility/ccache")
endif()
idf_build_get_property(target IDF_TARGET)
# The settings are part of the launcher, so they also apply when idf.py runs without the tool.
# They remove the parts of an SDK compile command that differ per app:
# - CCACHE_BASEDIR: makes paths into the build directory (-I<build>/config) relative
# - CCACHE_NOHASHDIR: ignores the working directory that -g would otherwise add to the hash
# - CCACHE_IGNOREOPTIONS: ignores -fmacro-prefix-map=<app directory>=., which can't affect
# sources outside the app directory
set(launcher
${CMAKE_COMMAND} -E env
"CCACHE_DIR=${cache_root}/${target}"
"CCACHE_BASEDIR=${CMAKE_BINARY_DIR}"
"CCACHE_NOHASHDIR=1"
"CCACHE_IGNOREOPTIONS=-fmacro-prefix-map=*"
${TACTILITY_CCACHE_PROGRAM}
)
# Selected by the directory that defines the target, not by component: some components define
# extra library targets (e.g. mbedtls's mbedcrypto), and the app's own targets (main, the ELF)
# are defined in the app directory. CMake has no launcher for assembly, so .S files aren't cached.
idf_build_get_property(idf_path IDF_PATH)
set(sdk_roots "${idf_path}" "${TACTILITY_SDK_PATH}")
set(directories "${CMAKE_SOURCE_DIR}")
while (directories)
list(POP_FRONT directories directory)
get_property(subdirectories DIRECTORY "${directory}" PROPERTY SUBDIRECTORIES)
list(APPEND directories ${subdirectories})
set(is_sdk_directory FALSE)
foreach(sdk_root ${sdk_roots})
cmake_path(IS_PREFIX sdk_root "${directory}" NORMALIZE is_prefix)
if (is_prefix)
set(is_sdk_directory TRUE)
endif()
endforeach()
if (NOT is_sdk_directory)
continue()
endif()
get_property(targets DIRECTORY "${directory}" PROPERTY BUILDSYSTEM_TARGETS)
foreach(target_name ${targets})
get_target_property(target_type ${target_name} TYPE)
if (target_type MATCHES "^(STATIC_LIBRARY|SHARED_LIBRARY|MODULE_LIBRARY|OBJECT_LIBRARY|EXECUTABLE)$")
set_target_properties(${target_name} PROPERTIES
C_COMPILER_LAUNCHER "${launcher}"
CXX_COMPILER_LAUNCHER "${launcher}"
)
endif()
endforeach()
endwhile()
endfunction()
macro(tactility_component_register) macro(tactility_component_register)
cmake_parse_arguments(TT_COMPONENT "" "" "SRCS;INCLUDE_DIRS;REQUIRES;PRIV_REQUIRES" ${ARGN}) cmake_parse_arguments(TT_COMPONENT "" "" "SRCS;INCLUDE_DIRS;REQUIRES;PRIV_REQUIRES" ${ARGN})
idf_component_register( idf_component_register(

View File

@ -26,6 +26,7 @@ macro(tactility_project_post project_name)
${TACTILITY_SDK_PATH}/Modules/lvgl-window-manager-module/include ${TACTILITY_SDK_PATH}/Modules/lvgl-window-manager-module/include
${TACTILITY_SDK_PATH}/Modules/service-module/include ${TACTILITY_SDK_PATH}/Modules/service-module/include
${TACTILITY_SDK_PATH}/Libraries/TactilityKernel/include ${TACTILITY_SDK_PATH}/Libraries/TactilityKernel/include
${TACTILITY_SDK_PATH}/Libraries/TactilityKernelCpp/Include
${TACTILITY_SDK_PATH}/Libraries/lvgl/include ${TACTILITY_SDK_PATH}/Libraries/lvgl/include
${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/include ${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/include
${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix ${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix

View File

@ -56,6 +56,8 @@ def check_cdn(state, sdk_version):
index = fetch_json(f"{CDN_URL}/sdk/{sdk_version}/index.json") index = fetch_json(f"{CDN_URL}/sdk/{sdk_version}/index.json")
if isinstance(index, dict): if isinstance(index, dict):
state.setdefault("sdk", {})[sdk_version] = index.get("gitCommit") state.setdefault("sdk", {})[sdk_version] = index.get("gitCommit")
tools = index.get("tools")
state.setdefault("sdk_tools", {})[sdk_version] = tools.get("sha256") if isinstance(tools, dict) else None
write_state(state) write_state(state)
@ -75,6 +77,14 @@ def installed_commits(version_directory):
return commits return commits
def installed_tools_hash(version_directory):
try:
with open(os.path.join(version_directory, "tools.sha256")) as file:
return file.read().strip()
except OSError:
return None
def run(): def run():
tt_version = os.environ.get("TT_VERSION") tt_version = os.environ.get("TT_VERSION")
if tt_version is None: if tt_version is None:
@ -91,8 +101,14 @@ def run():
latest = state.get("version") latest = state.get("version")
if isinstance(latest, str) and parse_version(latest) > parse_version(tt_version): if isinstance(latest, str) and parse_version(latest) > parse_version(tt_version):
print(f"tt: version {latest} is available (currently {tt_version}): run 'tt update-self'", file=sys.stderr) print(f"tt: version {latest} is available (currently {tt_version}): run 'tt update-self'", file=sys.stderr)
latest_commit = state.get("sdk", {}).get(sdk_version) if sdk_version is not None else None if sdk_version is None or sdk_path is None:
if latest_commit and sdk_path is not None and any(commit != latest_commit for commit in installed_commits(os.path.join(sdk_path, "sdk", sdk_version))): return
version_directory = os.path.join(sdk_path, "sdk", sdk_version)
latest_commit = state.get("sdk", {}).get(sdk_version)
latest_tools = state.get("sdk_tools", {}).get(sdk_version)
has_newer_platforms = latest_commit and any(commit != latest_commit for commit in installed_commits(version_directory))
has_newer_tools = latest_tools and installed_tools_hash(version_directory) != latest_tools
if has_newer_platforms or has_newer_tools:
print(f"tt: a newer build of SDK {sdk_version} is available: run 'tt update-sdk'", file=sys.stderr) print(f"tt: a newer build of SDK {sdk_version} is available: run 'tt update-sdk'", file=sys.stderr)

View File

@ -12,6 +12,8 @@ import _device_host
ttbuild_version = "8.0.0" ttbuild_version = "8.0.0"
verbose = False verbose = False
# Compile the ESP-IDF and SDK components through ccache (see TactilitySDK.esp32.cmake)
use_ccache = True
# The development server client (shell.py) is next to this script # The development server client (shell.py) is next to this script
tool_directory = os.path.dirname(os.path.abspath(__file__)) tool_directory = os.path.dirname(os.path.abspath(__file__))
# The SDKs are in TACTILITY_SDK_PATH/sdk/<version>, which tt sets along with the version it selected. # The SDKs are in TACTILITY_SDK_PATH/sdk/<version>, which tt sets along with the version it selected.
@ -34,7 +36,7 @@ def print_help():
print("") print("")
print(" build Build the app. Optionally specify a platform.") print(" build Build the app. Optionally specify a platform.")
print(" Supported platforms are lower case. Example: esp32s3") print(" Supported platforms are lower case. Example: esp32s3")
print(" Supported platforms are read from manifest.properties") print(" Supported platforms are read from manifest.properties (target.platforms=all for every installed SDK platform)")
print(" Parameters:") print(" Parameters:")
print(" (optional) --architecture [input], -a [input]") print(" (optional) --architecture [input], -a [input]")
print("") print("")
@ -65,6 +67,7 @@ def print_help():
print("Options:") print("Options:")
print(" -p, --path [input] Path to the app directory (defaults to current directory)") print(" -p, --path [input] Path to the app directory (defaults to current directory)")
print(" --help Show this commandline info") print(" --help Show this commandline info")
print(" --no-ccache Don't reuse ESP-IDF/SDK objects from other app builds via ccache")
print(" --skip-build Run everything except the idf.py/CMake commands") print(" --skip-build Run everything except the idf.py/CMake commands")
print(" --verbose Show extra console output") print(" --verbose Show extra console output")
print("") print("")
@ -182,6 +185,7 @@ ARG_SPECS = {
"platform": ("-a", "--architecture", True), "platform": ("-a", "--architecture", True),
"verbose": (None, "--verbose", False), "verbose": (None, "--verbose", False),
"skip-build": (None, "--skip-build", False), "skip-build": (None, "--skip-build", False),
"no-ccache": (None, "--no-ccache", False),
"help": (None, "--help", False), "help": (None, "--help", False),
} }
@ -344,9 +348,19 @@ def get_binary_dirs(manifest):
else: else:
return [(manifest["app.id"], ".")] return [(manifest["app.id"], ".")]
# "target.platforms=all" means every platform that an SDK is installed for
def get_manifest_platforms(manifest):
platforms = [platform.strip() for platform in manifest["target.platforms"].split(",")]
if platforms != ["all"]:
return platforms
version_directory = os.path.join(sdk_root_path, "sdk", manifest["target.sdk"])
prefix = "platform-"
if not os.path.isdir(version_directory):
exit_with_error(f"No SDK {manifest['target.sdk']} installed, so 'all' platforms can't be determined")
return sorted(name[len(prefix):] for name in os.listdir(version_directory) if name.startswith(prefix))
def is_valid_manifest_platform(manifest, platform): def is_valid_manifest_platform(manifest, platform):
manifest_platforms = manifest["target.platforms"].split(",") return platform in get_manifest_platforms(manifest)
return platform in manifest_platforms
def validate_manifest_platform(manifest, platform): def validate_manifest_platform(manifest, platform):
if not is_valid_manifest_platform(manifest, platform): if not is_valid_manifest_platform(manifest, platform):
@ -354,7 +368,7 @@ def validate_manifest_platform(manifest, platform):
def get_manifest_target_platforms(manifest, requested_platform): def get_manifest_target_platforms(manifest, requested_platform):
if requested_platform == "" or requested_platform is None: if requested_platform == "" or requested_platform is None:
return manifest["target.platforms"].split(",") return get_manifest_platforms(manifest)
else: else:
validate_manifest_platform(manifest, requested_platform) validate_manifest_platform(manifest, requested_platform)
return [requested_platform] return [requested_platform]
@ -472,6 +486,10 @@ def build_all(version, platforms, skip_build):
return False return False
return True return True
# Always passed, because a -D value persists in CMakeCache.txt
def get_ccache_define():
return f"TACTILITY_CCACHE={'ON' if use_ccache else 'OFF'}"
def wait_for_process(process): def wait_for_process(process):
buffer = [] buffer = []
if sys.platform != "win32": if sys.platform != "win32":
@ -514,7 +532,7 @@ def build_first(version, platform, skip_build):
cmake_path = get_cmake_path(platform) cmake_path = get_cmake_path(platform)
print_status_busy(f"Building {platform} ELF") print_status_busy(f"Building {platform} ELF")
shell_needed = sys.platform == "win32" shell_needed = sys.platform == "win32"
build_command = ["idf.py", "-B", cmake_path, "build"] build_command = ["idf.py", "-B", cmake_path, "-D", get_ccache_define(), "build"]
if verbose: if verbose:
print(f"Running command: {' '.join(build_command)}") print(f"Running command: {' '.join(build_command)}")
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process:
@ -544,7 +562,7 @@ def build_consecutively(version, platform, skip_build):
cmake_path = get_cmake_path(platform) cmake_path = get_cmake_path(platform)
print_status_busy(f"Building {platform} ELF") print_status_busy(f"Building {platform} ELF")
shell_needed = sys.platform == "win32" shell_needed = sys.platform == "win32"
build_command = ["idf.py", "-B", cmake_path, "elf"] build_command = ["idf.py", "-B", cmake_path, "-D", get_ccache_define(), "elf"]
if verbose: if verbose:
print(f"Running command: {' '.join(build_command)}") print(f"Running command: {' '.join(build_command)}")
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process: with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process:
@ -758,6 +776,7 @@ if __name__ == "__main__":
sys.exit(1) sys.exit(1)
verbose = parsed_command.arguments.has("verbose") verbose = parsed_command.arguments.has("verbose")
use_ccache = not parsed_command.arguments.has("no-ccache")
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:

View File

@ -0,0 +1,41 @@
#!/usr/bin/env python3
# License: Apache License v2
"""Takes a screenshot of the device through its development server. Run it as "tt screenshot"."""
import argparse
import datetime
import os
import sys
import shell
# Encoding the PNG can take a while on a large display
SCREENSHOT_TIMEOUT_SECONDS = 30
def main():
shell.PROGRAM_NAME = "tt screenshot" if "TACTILITY_SDK_VERSION" in os.environ else "screenshot.py"
parser = argparse.ArgumentParser(prog=shell.PROGRAM_NAME, description="take a screenshot of the device", add_help=False)
shell.add_connection_arguments(parser)
parser.add_argument("local_path", nargs="?", help="the .png file on this computer (default: screenshot-<yyyy-mm-dd>.png)")
args = shell.parse_arguments(parser)
if args.local_path is None:
args.local_path = f"screenshot-{datetime.date.today().isoformat()}.png"
# The device saves the screenshot in its temp folder and prints the path
exit_code, output = shell.run_command(args.host, args.port, "screenshot", SCREENSHOT_TIMEOUT_SECONDS)
if exit_code != 0:
shell.fail(output.strip() or "the device failed to take a screenshot")
remote_path = output.strip().splitlines()[-1]
try:
shell.run_pull(argparse.Namespace(host=args.host, port=args.port, remote_path=remote_path, local_path=args.local_path))
finally:
shell.run_command(args.host, args.port, f"rm {remote_path}")
sys.exit(0)
if __name__ == "__main__":
import _bootstrap
_bootstrap.run()
main()

View File

@ -495,6 +495,21 @@ def run_shell(args):
return 1 return 1
return exit_code return exit_code
def run_command(host, port, command, timeout=REQUEST_TIMEOUT_SECONDS):
"""Runs a command without a terminal. @return (exit code, output text)"""
connection = open_session(host, port, "shell", command=command)
output = bytearray()
try:
while True:
frame_type, payload = connection.read_frame(timeout)
if frame_type == FRAME_DATA:
output += payload
elif frame_type == FRAME_EXIT and len(payload) == 4:
return struct.unpack(">i", payload)[0], output.decode("utf-8", errors="replace").replace("\r", "")
finally:
connection.close()
# endregion # endregion

View File

@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# License: Apache License v2 # License: Apache License v2
"""Downloads the installed platforms of the selected SDK again when the CDN has a newer build of it. Run it as "tt update-sdk".""" """Downloads the tools and installed platforms of the selected SDK again when the CDN has a newer build of it. Run it as "tt update-sdk"."""
import hashlib
import io import io
import json import json
import os import os
@ -43,27 +44,67 @@ def installed_platforms(version_directory):
) )
def install_platform(platform_directory, archive_data, commit): def read_installed_tools_hash(version_directory):
"""Extracts next to the installed SDK and then replaces it, refusing entries outside of it"""
staging_directory = os.path.join(platform_directory, "TactilitySDK.new")
shutil.rmtree(staging_directory, ignore_errors=True)
os.makedirs(staging_directory)
root = os.path.realpath(staging_directory)
try: try:
with zipfile.ZipFile(io.BytesIO(archive_data)) as archive: with open(os.path.join(version_directory, "tools.sha256")) as file:
for member in archive.infolist(): return file.read().strip()
destination = os.path.realpath(os.path.join(root, member.filename)) except OSError:
if not destination.startswith(root + os.sep): return None
fail(f"invalid entry in the SDK archive: {member.filename}")
archive.extractall(root)
except zipfile.BadZipFile: class Update:
"""Stages every component next to its installed folder, so a failed download leaves the installation untouched"""
def __init__(self):
# (target directory, staging directory, metadata path, metadata)
self.staged = []
def stage(self, archive_data, target_directory, metadata_path, metadata):
"""Extracts into a staging folder, refusing entries outside of it"""
staging_directory = f"{target_directory}.new"
shutil.rmtree(staging_directory, ignore_errors=True) shutil.rmtree(staging_directory, ignore_errors=True)
fail("the downloaded SDK archive is invalid") os.makedirs(staging_directory)
sdk_directory = os.path.join(platform_directory, "TactilitySDK") self.staged.append((target_directory, staging_directory, metadata_path, metadata))
shutil.rmtree(sdk_directory, ignore_errors=True) root = os.path.realpath(staging_directory)
os.replace(staging_directory, sdk_directory) try:
with open(os.path.join(platform_directory, "commit"), "w") as file: with zipfile.ZipFile(io.BytesIO(archive_data)) as archive:
file.write(commit) for member in archive.infolist():
destination = os.path.realpath(os.path.join(root, member.filename))
if not destination.startswith(root + os.sep):
fail(f"invalid entry in the archive: {member.filename}")
archive.extractall(root)
except zipfile.BadZipFile:
fail("the downloaded archive is invalid")
def discard(self):
for _, staging_directory, _, _ in self.staged:
shutil.rmtree(staging_directory, ignore_errors=True)
self.staged = []
def commit(self):
"""Replaces the installed folders, and puts the previous ones back when that fails"""
replaced = []
try:
for target_directory, staging_directory, _, _ in self.staged:
backup_directory = f"{target_directory}.old"
shutil.rmtree(backup_directory, ignore_errors=True)
if os.path.isdir(target_directory):
os.replace(target_directory, backup_directory)
replaced.append((target_directory, backup_directory))
os.replace(staging_directory, target_directory)
except OSError as error:
for target_directory, backup_directory in reversed(replaced):
shutil.rmtree(target_directory, ignore_errors=True)
if os.path.isdir(backup_directory):
os.replace(backup_directory, target_directory)
self.discard()
fail(f"failed to install the update: {error}")
for _, _, metadata_path, metadata in self.staged:
with open(metadata_path, "w") as file:
file.write(metadata)
for _, backup_directory in replaced:
shutil.rmtree(backup_directory, ignore_errors=True)
self.staged = []
def main(): def main():
@ -81,14 +122,28 @@ def main():
fail("the downloaded index.json is invalid") fail("the downloaded index.json is invalid")
commit = index.get("gitCommit", "") commit = index.get("gitCommit", "")
available = index.get("platforms", {}) available = index.get("platforms", {})
updated = False update = Update()
for platform in installed_platforms(version_directory): try:
platform_directory = os.path.join(version_directory, f"platform-{platform}") tools = index.get("tools")
if read_commit(platform_directory) == commit or platform not in available: if tools is not None and read_installed_tools_hash(version_directory) != tools.get("sha256"):
continue print(f"Downloading the tools of SDK {version}", flush=True)
print(f"Updating SDK {version} for {platform}") archive_data = fetch(f"{CDN_URL}/sdk/{version}/{tools['file']}")
install_platform(platform_directory, fetch(f"{CDN_URL}/sdk/{version}/{available[platform]}"), commit) if hashlib.sha256(archive_data).hexdigest() != tools["sha256"]:
updated = True fail("the downloaded tools don't match their checksum")
update.stage(archive_data, os.path.join(version_directory, "tools"), os.path.join(version_directory, "tools.sha256"), tools["sha256"])
for platform in installed_platforms(version_directory):
platform_directory = os.path.join(version_directory, f"platform-{platform}")
if read_commit(platform_directory) == commit or platform not in available:
continue
print(f"Downloading SDK {version} for {platform}", flush=True)
archive_data = fetch(f"{CDN_URL}/sdk/{version}/{available[platform]}")
update.stage(archive_data, os.path.join(platform_directory, "TactilitySDK"), os.path.join(platform_directory, "commit"), commit)
except SystemExit:
update.discard()
print(f"SDK {version} was not changed", file=sys.stderr)
raise
updated = len(update.staged) > 0
update.commit()
with open(os.path.join(version_directory, "index.json"), "wb") as file: with open(os.path.join(version_directory, "index.json"), "wb") as file:
file.write(index_data) file.write(index_data)
print(f"SDK {version} was updated" if updated else f"SDK {version} is up to date") print(f"SDK {version} was updated" if updated else f"SDK {version} is up to date")

View File

@ -11,6 +11,8 @@ import zipfile
CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org") CDN_URL = os.environ.get("TT_CDN", "https://cdn.tactilityproject.org")
FILE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]*$") FILE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]*$")
# Files of older versions of tt: tt.cmd made cmd.exe parse the arguments a second time, tt.ps1 replaces it
OBSOLETE_FILE_NAMES = ["tt.cmd"]
def fail(message): def fail(message):
@ -44,16 +46,15 @@ def main():
fail("the downloaded tt.py is invalid") fail("the downloaded tt.py is invalid")
for file_name, content in contents.items(): for file_name, content in contents.items():
path = os.path.join(directory, file_name) path = os.path.join(directory, file_name)
# A running tt.cmd is read while it executes, so an unchanged file is left alone
if os.path.isfile(path):
with open(path, "rb") as file:
if file.read() == content:
continue
temporary_path = f"{path}.new" temporary_path = f"{path}.new"
with open(temporary_path, "wb") as file: with open(temporary_path, "wb") as file:
file.write(content) file.write(content)
os.chmod(temporary_path, 0o755) os.chmod(temporary_path, 0o755)
os.replace(temporary_path, path) os.replace(temporary_path, path)
for file_name in OBSOLETE_FILE_NAMES:
path = os.path.join(directory, file_name)
if file_name not in contents and os.path.isfile(path):
os.remove(path)
print("tt was updated") print("tt was updated")

View File

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Fails when the files of tt or its packaging changed since a base revision, but TT_VERSION in tt.py wasn't increased.
Usage: check-tt-version.py <base revision>, e.g. origin/main"""
import importlib.util
import os
import re
import subprocess
import sys
ROOT_PATH = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
VERSION_PATTERN = re.compile(r'^TT_VERSION = "([^"]+)"', re.MULTILINE)
# Changes to what tt.zip contains, e.g. an added or removed file
PACKAGING_FILE_NAME = "Buildscripts/release-tt.py"
def load_file_names():
"""The files that make up tt, as release-tt.py packages them"""
spec = importlib.util.spec_from_file_location("release_tt", os.path.join(ROOT_PATH, "Buildscripts", "release-tt.py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.FILE_NAMES
def git(*arguments):
return subprocess.run(["git", *arguments], cwd=ROOT_PATH, capture_output=True, text=True)
def read_version(source):
match = VERSION_PATTERN.search(source)
return match.group(1) if match else None
def parse_version(version):
return tuple(int(part) for part in re.findall(r"\d+", version)[:3])
def main():
if len(sys.argv) != 2:
print("Usage: check-tt-version.py <base revision>", file=sys.stderr)
sys.exit(2)
base = sys.argv[1]
file_names = load_file_names()
diff = git("diff", "--name-only", f"{base}...HEAD", "--", *file_names, PACKAGING_FILE_NAME)
if diff.returncode != 0:
print(f"git diff failed: {diff.stderr.strip()}", file=sys.stderr)
sys.exit(2)
changed = diff.stdout.split()
if not changed:
print("tt is unchanged")
return
base_source = git("show", f"{base}:tt.py")
if base_source.returncode != 0:
print("tt is new")
return
with open(os.path.join(ROOT_PATH, "tt.py")) as file:
current_version = read_version(file.read())
base_version = read_version(base_source.stdout)
if current_version is None or base_version is None:
print("TT_VERSION not found in tt.py", file=sys.stderr)
sys.exit(1)
if parse_version(current_version) <= parse_version(base_version):
print(f"{', '.join(changed)} changed, but TT_VERSION is still {current_version}: increase TT_VERSION in tt.py", file=sys.stderr)
sys.exit(1)
print(f"tt changed and its version increased from {base_version} to {current_version}")
if __name__ == "__main__":
main()

View File

@ -101,6 +101,9 @@ def main():
{'src': 'TactilityKernel/include/**', 'dst': 'Libraries/TactilityKernel/include/'}, {'src': 'TactilityKernel/include/**', 'dst': 'Libraries/TactilityKernel/include/'},
{'src': 'TactilityKernel/CMakeLists.txt', 'dst': 'Libraries/TactilityKernel/'}, {'src': 'TactilityKernel/CMakeLists.txt', 'dst': 'Libraries/TactilityKernel/'},
{'src': 'TactilityKernel/*.md', 'dst': 'Libraries/TactilityKernel/'}, {'src': 'TactilityKernel/*.md', 'dst': 'Libraries/TactilityKernel/'},
# TactilityKernelCpp (header-only)
{'src': 'TactilityKernelCpp/Include/**', 'dst': 'Libraries/TactilityKernelCpp/Include/'},
{'src': 'TactilityKernelCpp/*.md', 'dst': 'Libraries/TactilityKernelCpp/'},
# lvgl (basics) # lvgl (basics)
{'src': 'build/esp-idf/lvgl__lvgl/liblvgl__lvgl.a', 'dst': 'Libraries/lvgl/binary/liblvgl.a'}, {'src': 'build/esp-idf/lvgl__lvgl/liblvgl__lvgl.a', 'dst': 'Libraries/lvgl/binary/liblvgl.a'},
{'src': 'Libraries/lvgl/lvgl.h', 'dst': 'Libraries/lvgl/include/'}, {'src': 'Libraries/lvgl/lvgl.h', 'dst': 'Libraries/lvgl/include/'},

View File

@ -58,6 +58,9 @@ def main():
{'src': 'TactilityKernel/include/**', 'dst': 'Libraries/TactilityKernel/include/'}, {'src': 'TactilityKernel/include/**', 'dst': 'Libraries/TactilityKernel/include/'},
{'src': 'TactilityKernel/CMakeLists.txt', 'dst': 'Libraries/TactilityKernel/'}, {'src': 'TactilityKernel/CMakeLists.txt', 'dst': 'Libraries/TactilityKernel/'},
{'src': 'TactilityKernel/*.md', 'dst': 'Libraries/TactilityKernel/'}, {'src': 'TactilityKernel/*.md', 'dst': 'Libraries/TactilityKernel/'},
# TactilityKernelCpp (header-only)
{'src': 'TactilityKernelCpp/Include/**', 'dst': 'Libraries/TactilityKernelCpp/Include/'},
{'src': 'TactilityKernelCpp/*.md', 'dst': 'Libraries/TactilityKernelCpp/'},
# FreeRTOS-Kernel - TactilityKernel's public headers (tactility/freertos/*.h) include the # FreeRTOS-Kernel - TactilityKernel's public headers (tactility/freertos/*.h) include the
# real FreeRTOS.h/task.h/etc directly, unlike ESP32 where ESP-IDF's own "freertos" # real FreeRTOS.h/task.h/etc directly, unlike ESP32 where ESP-IDF's own "freertos"
# component and Kconfig-generated FreeRTOSConfig.h are already part of every project. # component and Kconfig-generated FreeRTOSConfig.h are already part of every project.

View File

@ -10,8 +10,8 @@ import zipfile
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__)), ".."))
LAUNCHER_PATH = os.path.join(ROOT_PATH, "tt.py") LAUNCHER_PATH = os.path.join(ROOT_PATH, "tt.py")
# tt.py and the wrappers that run it: tt (Linux, macOS) and tt.cmd (Windows) # tt.py and the wrappers that run it: tt (Linux, macOS) and tt.ps1 (Windows PowerShell)
FILE_NAMES = ["tt.py", "tt", "tt.cmd"] FILE_NAMES = ["tt.py", "tt", "tt.ps1"]
CDN_OUTPUT_PATH = os.path.join(ROOT_PATH, "release", "tt") CDN_OUTPUT_PATH = os.path.join(ROOT_PATH, "release", "tt")

View File

@ -48,6 +48,8 @@ CONFIG_LV_USE_BUILTIN_MALLOC=n
# Routes lv_malloc/realloc/free through Modules/lvgl-module/source/lv_mem_custom.c, which prefers # Routes lv_malloc/realloc/free through Modules/lvgl-module/source/lv_mem_custom.c, which prefers
# PSRAM (falls back to internal RAM automatically) instead of a fixed-size pool or plain malloc. # PSRAM (falls back to internal RAM automatically) instead of a fixed-size pool or plain malloc.
CONFIG_LV_USE_CUSTOM_MALLOC=y CONFIG_LV_USE_CUSTOM_MALLOC=y
# A failed lv_malloc() stops in the assert instead of writing to NULL. The task watchdog then reports where.
CONFIG_LV_USE_ASSERT_MALLOC=y
CONFIG_LV_USE_MSGBOX=n CONFIG_LV_USE_MSGBOX=n
CONFIG_LV_USE_SPINNER=n CONFIG_LV_USE_SPINNER=n
CONFIG_LV_USE_WIN=n CONFIG_LV_USE_WIN=n

View File

@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi
QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR
HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D
9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8
p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD
-----END CERTIFICATE-----

View File

@ -1,17 +0,0 @@
-----BEGIN CERTIFICATE-----
MIICnzCCAiWgAwIBAgIQf/MZd5csIkp2FV0TttaF4zAKBggqhkjOPQQDAzBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjQwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIwMTQw
MDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZp
Y2VzMQwwCgYDVQQDEwNXRTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARvzTr+
Z1dHTCEDhUDCR127WEcPQMFcF4XGGTfn1XzthkubgdnXGhOlCgP4mMTG6J7/EFmP
LCaY9eYmJbsPAvpWo4H+MIH7MA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr
BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU
kHeSNWfE/6jMqeZ72YB5e8yT+TgwHwYDVR0jBBgwFoAUgEzW63T/STaj1dj8tT7F
avCUHYwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzAChhhodHRwOi8vaS5wa2ku
Z29vZy9yNC5jcnQwKwYDVR0fBCQwIjAgoB6gHIYaaHR0cDovL2MucGtpLmdvb2cv
ci9yNC5jcmwwEwYDVR0gBAwwCjAIBgZngQwBAgEwCgYIKoZIzj0EAwMDaAAwZQIx
AOcCq1HW90OVznX+0RGU1cxAQXomvtgM8zItPZCuFQ8jSBJSjz5keROv9aYsAm5V
sQIwJonMaAFi54mrfhfoFNZEfuNMSQ6/bIBiNLiyoX46FohQvKeIoJ99cx7sUkFN
7uJW
-----END CERTIFICATE-----

View File

@ -42,6 +42,7 @@ forum e8af
gamepad e30f gamepad e30f
hard_disk f3da hard_disk f3da
help e8fd help e8fd
hourglass ebff
hub e9f4 hub e9f4
image e3f4 image e3f4
info e88e info e88e

View File

@ -16,3 +16,4 @@ touch.calibrationRequired=false
cdn.warningMessage=There are 3 hardware variants of this board. This build works on the original variant only ("v1"). cdn.warningMessage=There are 3 hardware variants of this board. This build works on the original variant only ("v1").
lvgl.colorDepth=16 lvgl.colorDepth=16
lvgl.fsCacheSize=512

View File

@ -7,24 +7,18 @@
## Higher Priority ## Higher Priority
- Adding GPS: Crash when clicking "+" - Experiment with default malloc() to SPIRAM
- Update FreeRTOS-Kernel to 11.3.1 to fix hanging tests, then re-release apps. - GPIO app: fix for monochrome display by using filled vs outline box in secondary colour
- App Hub needs a commandline app
- BlueTooth as radio backend for MeshCore/Meshtastic? - BlueTooth as radio backend for MeshCore/Meshtastic?
- ESP-NOW as radio backend. Use it for MeshCore/Meshtastic? - ESP-NOW as radio backend. Use it 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
- Export TactilityKernelCpp with SDK as optional library
- Fix BT: an esp32 device connecting to a peripheral isn't working - 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"). - "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. - Improve Wi-Fi mock so it actually works with connect/disconnect/scan list. It connects to literally anything from the scan list.
- Fix posix Wi-Fi so it actually connects to something
- Support http/https on posix - Support http/https on posix
- Vi apps are both named "Vi" in the App Hub
- 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 - 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 - Cursor is hard-coded, needs size based on DPI
- Improve Setup: Show progress of some kind. Put skip/next buttons closer? And add outline button style (update docs!)
- Files app should have a page indicator like Launcher.
- Move Bluetooth code from kernel to Modules/bluetooth-module, and have includes in path bluetooth/ then make a bluetooth_command.cpp like wifi_command.cpp - 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::*` - Inspect code for memory improvements: replace `std::*` with `tt::std::*`
- esp_lvgl_port settings has a large stack size (~9kB) to fix stackoverflow when LVGL events (e.g. button click) do actions like file operations do actions like file operations. Can we reduce the callstack? - esp_lvgl_port settings has a large stack size (~9kB) to fix stackoverflow when LVGL events (e.g. button click) do actions like file operations do actions like file operations. Can we reduce the callstack?

View File

@ -3,6 +3,9 @@
#include <sdkconfig.h> #include <sdkconfig.h>
#endif #endif
#include "tactility/memory.h"
#include <app/elf_check.h> #include <app/elf_check.h>
#include <app/loader.h> #include <app/loader.h>
#include <app/location.h> #include <app/location.h>
@ -47,7 +50,8 @@ error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) {
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
auto* data = static_cast<uint8_t*>(malloc(static_cast<size_t>(size))); static constexpr MemoryPolicy policy = { .required = 0, .desired = 0, .alignment = 0};
auto* data = static_cast<uint8_t*>(memory_alloc_with_policy(static_cast<size_t>(size), &policy));
if (data == nullptr) { if (data == nullptr) {
fclose(file); fclose(file);
return ERROR_OUT_OF_MEMORY; return ERROR_OUT_OF_MEMORY;
@ -57,7 +61,7 @@ error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) {
fclose(file); fclose(file);
if (read != static_cast<size_t>(size)) { if (read != static_cast<size_t>(size)) {
free(data); memory_free(data);
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
@ -141,7 +145,7 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) {
} }
if (esp_elf_init(&runtime->elf) != ESP_OK) { if (esp_elf_init(&runtime->elf) != ESP_OK) {
free(runtime->file_data); memory_free(runtime->file_data);
delete runtime; delete runtime;
LOG_E(TAG, "Failed to init elf"); LOG_E(TAG, "Failed to init elf");
return ERROR_RESOURCE; return ERROR_RESOURCE;
@ -149,14 +153,13 @@ 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
free(runtime->file_data);
delete runtime; delete runtime;
LOG_E(TAG, "Failed to map elf. Possibly out of memory."); LOG_E(TAG, "Failed to map elf. Possibly out of memory.");
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
// Relocation copied everything it needs (segments, symbol names) out of the file buffer // Relocation copied everything it needs (segments, symbol names) out of the file buffer
free(runtime->file_data); memory_free(runtime->file_data);
runtime->file_data = nullptr; runtime->file_data = nullptr;
*out_runtime = runtime; *out_runtime = runtime;

View File

@ -10,3 +10,8 @@ tactility_add_module(app-hub-module
INCLUDE_DIRS include/ INCLUDE_DIRS include/
REQUIRES TactilityKernel TactilityKernelCpp app-module service-module http-module cjson-module REQUIRES TactilityKernel TactilityKernelCpp app-module service-module http-module cjson-module
) )
if (NOT DEFINED ENV{ESP_IDF_VERSION})
# Matches the app binary folder name of app-posix-module, e.g. "posix-x86_64"
target_compile_definitions(app-hub-module PRIVATE "TACTILITY_POSIX_ARCH=\"${CMAKE_SYSTEM_PROCESSOR}\"")
endif ()

View File

@ -4,10 +4,12 @@ App Hub service (`tactility.apphub`): finds, installs and removes apps from the
The C API is in `<app_hub/app_hub.h>`. The C API is in `<app_hub/app_hub.h>`.
- `app_hub_search()` lists the catalog apps that are compatible with the device, merged with all installed apps. - `app_hub_search()` lists the catalog apps that are compatible with the device, merged with all installed apps.
- `app_hub_refresh()`, `app_hub_install()` and `app_hub_remove()` queue an operation. Operations run one at a time - `app_hub_refresh()`, `app_hub_install()`, `app_hub_update_all()` and `app_hub_remove()` queue an operation. Operations run one at a time
on a background task, which stops after 5 seconds without operations and starts again for the next one. on a background task, which stops after 5 seconds without operations and starts again for the next one.
- Subscribe with `app_hub_event_subscribe()`, then wait with `app_hub_event_await()` and read with `app_hub_event_poll()`. - Subscribe with `app_hub_event_subscribe()`, then wait with `app_hub_event_await()` and read with `app_hub_event_poll()`.
The events are `REFRESHING`/`REFRESHED`, `INSTALLING`/`INSTALLED` and `REMOVING`/`REMOVED`, with the app id and the result. The events are `REFRESHING`/`REFRESHED`, `INSTALLING`/`INSTALLED`, `REMOVING`/`REMOVED` and `UPDATING`/`UPDATED`, with the app id and the result.
The `apphub` shell command uses the same API: `refresh`, `search [query]`, `updates`, `info <id>`, `install <id>`, `update` and `remove <id>`.
The catalog is stored in the service's user data directory, so it's available offline after the first refresh. The catalog is stored in the service's user data directory, so it's available offline after the first refresh.

View File

@ -28,6 +28,8 @@ enum AppHubOperationType {
APP_HUB_OPERATION_INSTALL, APP_HUB_OPERATION_INSTALL,
/** Uninstall an app */ /** Uninstall an app */
APP_HUB_OPERATION_REMOVE, APP_HUB_OPERATION_REMOVE,
/** Install the newer catalog version of every installed app that has one */
APP_HUB_OPERATION_UPDATE_ALL,
}; };
/** An app from the catalog, an installed app, or both */ /** An app from the catalog, an installed app, or both */
@ -89,6 +91,9 @@ error_t app_hub_get_description(const char* id, char* description, size_t descri
// region Operations // region Operations
/** @return true while an operation is queued or running */
bool app_hub_is_busy(void);
/** /**
* Queue a download of the app catalog. * Queue a download of the app catalog.
* Only one refresh is queued or running at a time: while one is pending, its id is returned instead. * Only one refresh is queued or running at a time: while one is pending, its id is returned instead.
@ -105,6 +110,17 @@ error_t app_hub_refresh(uint32_t* operation_id);
*/ */
error_t app_hub_install(const char* id, uint32_t* operation_id); error_t app_hub_install(const char* id, uint32_t* operation_id);
/**
* Queue the update of every installed app that has a newer version in the catalog. The apps are installed one at a time,
* with an INSTALLING and INSTALLED event each. A failed update doesn't stop the updates of the other apps.
* While the operation is pending, the apps with an update report it as their pending operation.
* The operation takes the place of an install or remove: only one of them is queued or running at a time.
* @param[out] operation_id the id of the queued operation (optional)
* @retval ERROR_NONE the operation was queued, or it was already pending and its id is returned
* @retval ERROR_RESOURCE_BUSY an install or remove is pending
*/
error_t app_hub_update_all(uint32_t* operation_id);
/** /**
* Queue the removal of an installed app. * Queue the removal of an installed app.
* Only one install or remove is queued or running at a time. A refresh can be pending next to it. * Only one install or remove is queued or running at a time. A refresh can be pending next to it.
@ -131,12 +147,16 @@ enum AppHubEventType {
APP_HUB_EVENT_REMOVING, APP_HUB_EVENT_REMOVING,
/** The removal of AppHubEvent.app_id finished, see AppHubEvent.result */ /** The removal of AppHubEvent.app_id finished, see AppHubEvent.result */
APP_HUB_EVENT_REMOVED, APP_HUB_EVENT_REMOVED,
/** Updating all apps started. INSTALLING and INSTALLED events with the same operation id follow for each app. */
APP_HUB_EVENT_UPDATING,
/** Updating all apps finished. AppHubEvent.result is the error of the last failed app, ERROR_NONE when all succeeded. */
APP_HUB_EVENT_UPDATED,
}; };
struct AppHubEvent { struct AppHubEvent {
enum AppHubEventType type; enum AppHubEventType type;
uint32_t operation_id; uint32_t operation_id;
/** Empty for the refresh events */ /** Empty for the refresh, UPDATING and UPDATED events */
PackageId app_id; PackageId app_id;
/** The outcome of the operation for the REFRESHED, INSTALLED and REMOVED events, ERROR_NONE otherwise */ /** The outcome of the operation for the REFRESHED, INSTALLED and REMOVED events, ERROR_NONE otherwise */
error_t result; error_t result;

View File

@ -1,13 +1,10 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
#pragma once #pragma once
#include <TactilityCpp/Allocator.h>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
constexpr auto* APP_HUB_CERTIFICATE_PATH = "/system/certificates/WE1.pem";
struct AppHubCatalogEntry { struct AppHubCatalogEntry {
std::string id; std::string id;
@ -25,14 +22,43 @@ struct AppHubCatalogEntry {
std::string file; std::string file;
}; };
using AppHubCatalog = std::vector<AppHubCatalogEntry, tt::OptExternalAllocator<AppHubCatalogEntry>>; /**
* The catalog isn't kept in memory. A downloaded apps.json is converted once into a catalog file with one line per app,
* which is read one entry at a time.
*/
/** Parse an apps.json file */ /**
bool app_hub_catalog_parse(const char* path, AppHubCatalog& catalog); * Convert an apps.json file into a catalog file
* @param[in] json_path the apps.json file
* @param[in] catalog_path the catalog file to write, which is removed when the conversion fails
* @return false when the JSON is invalid or the catalog file can't be written
*/
bool app_hub_catalog_convert(const char* json_path, const char* catalog_path);
/** @return true to continue, false to stop */
using AppHubCatalogVisitor = bool (*)(const AppHubCatalogEntry& entry, void* context);
/**
* Visit the entries of a catalog file in order
* @return false when the file can't be read or is malformed
*/
bool app_hub_catalog_for_each(const char* catalog_path, AppHubCatalogVisitor visitor, void* context);
/**
* @param[out] entry the entry with the id
* @return false when the entry isn't in the catalog file
*/
bool app_hub_catalog_find(const char* catalog_path, const char* id, AppHubCatalogEntry& entry);
/** @return the platform name of this device in the catalog's target platforms, e.g. "esp32s3" or "posix-x86_64" */
const char* app_hub_catalog_get_platform();
/** @return true when this device can install and run the entry's app (target platform, device and RAM requirements) */ /** @return true when this device can install and run the entry's app (target platform, device and RAM requirements) */
bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry); bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry);
/** @return the path of the certificate that verifies the catalog server, in the "system" file system, or an empty string when it isn't available */
std::string app_hub_catalog_get_certificate_path();
std::string app_hub_catalog_get_url(); std::string app_hub_catalog_get_url();
std::string app_hub_catalog_get_download_url(const std::string& file); std::string app_hub_catalog_get_download_url(const std::string& file);

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
#pragma once #pragma once
#include <app/manifest.h>
#include <service/manifest.h> #include <service/manifest.h>
/** The service id, which also determines where the catalog is stored */ /** The service id, which also determines where the catalog is stored */
@ -8,5 +9,8 @@
extern const ServiceManifest app_hub_service_manifest; extern const ServiceManifest app_hub_service_manifest;
/** The "apphub" command-line app */
extern "C" const AppManifest app_hub_command_manifest;
/** @return true while the background task that runs the operations exists */ /** @return true while the background task that runs the operations exists */
bool app_hub_is_worker_running(); bool app_hub_is_worker_running();

View File

@ -3,10 +3,14 @@
#include <app/package_manifest.h> #include <app/package_manifest.h>
#include <tactility/filesystem/file_system.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <tactility/paths.h>
#include <cJSON.h> #include <cJSON.h>
#include <new>
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
#include <sdkconfig.h> #include <sdkconfig.h>
#endif #endif
@ -15,6 +19,7 @@
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <format> #include <format>
#include <cstdlib>
#include <memory> #include <memory>
#include <string_view> #include <string_view>
@ -27,6 +32,16 @@ static std::string get_version_without_postfix() {
return (index == std::string::npos) ? version : version.substr(0, index); return (index == std::string::npos) ? version : version.substr(0, index);
} }
std::string app_hub_catalog_get_certificate_path() {
// The system file system is mounted at "/system" on ESP32, but it's a folder elsewhere on the simulator
FileSystem* system = file_system_find_by_name("system");
char path[FILE_MAX_PATH_STRING_LENGTH];
if (system == nullptr || file_system_get_path(system, path, sizeof(path)) != ERROR_NONE) {
return "";
}
return std::string(path) + "/certificates/GTS_Root_R4.pem";
}
std::string app_hub_catalog_get_url() { std::string app_hub_catalog_get_url() {
return std::format("{}/{}/apps.json", BASE_URL, get_version_without_postfix()); return std::format("{}/{}/apps.json", BASE_URL, get_version_without_postfix());
} }
@ -100,8 +115,9 @@ static std::unique_ptr<char[]> read_file(const char* path) {
if (fseek(file, 0, SEEK_END) == 0) { if (fseek(file, 0, SEEK_END) == 0) {
const long size = ftell(file); const long size = ftell(file);
if (size >= 0 && fseek(file, 0, SEEK_SET) == 0) { if (size >= 0 && fseek(file, 0, SEEK_SET) == 0) {
data = std::make_unique<char[]>(size + 1); // Exceptions are disabled, so a failed allocation must not abort
if (fread(data.get(), 1, size, file) == static_cast<size_t>(size)) { data.reset(new (std::nothrow) char[size + 1]);
if (data != nullptr && fread(data.get(), 1, size, file) == static_cast<size_t>(size)) {
data[size] = '\0'; data[size] = '\0';
} else { } else {
data = nullptr; data = nullptr;
@ -112,19 +128,114 @@ static std::unique_ptr<char[]> read_file(const char* path) {
return data; return data;
} }
bool app_hub_catalog_parse(const char* path, AppHubCatalog& catalog) { namespace {
auto data = read_file(path);
constexpr char FIELD_SEPARATOR = '\t';
constexpr char LIST_SEPARATOR = ',';
constexpr size_t FIELD_COUNT = 10;
// Bounds the stack buffer of a reader. A longer entry makes the conversion fail.
constexpr size_t LINE_MAX_SIZE = 1024;
/** Control characters, such as the field separator, can't be in a catalog line */
void append_text(std::string& line, const std::string& text) {
for (const char character : text) {
line += (static_cast<unsigned char>(character) < ' ') ? ' ' : character;
}
line += FIELD_SEPARATOR;
}
bool append_list(std::string& line, const std::vector<std::string>& items) {
for (size_t i = 0; i < items.size(); i++) {
if (items[i].find(LIST_SEPARATOR) != std::string::npos) {
return false;
}
if (i > 0) {
line += LIST_SEPARATOR;
}
append_text(line, items[i]);
line.pop_back();
}
line += FIELD_SEPARATOR;
return true;
}
bool format_entry(const AppHubCatalogEntry& entry, std::string& line) {
line.clear();
append_text(line, entry.id);
append_text(line, entry.version_name);
line += std::to_string(entry.version_code) + FIELD_SEPARATOR;
append_text(line, entry.name);
append_text(line, entry.description);
append_text(line, entry.target_sdk);
if (!append_list(line, entry.target_platforms) || !append_list(line, entry.requires_device_id)) {
return false;
}
line += std::to_string(entry.requires_ram) + FIELD_SEPARATOR;
append_text(line, entry.file);
line.back() = '\n';
return line.size() < LINE_MAX_SIZE;
}
std::vector<std::string> split(std::string_view text, char separator) {
std::vector<std::string> parts;
size_t start = 0;
while (true) {
const size_t end = text.find(separator, start);
if (end == std::string_view::npos) {
parts.emplace_back(text.substr(start));
return parts;
}
parts.emplace_back(text.substr(start, end - start));
start = end + 1;
}
}
std::vector<std::string> split_list(const std::string& text) {
return text.empty() ? std::vector<std::string> {} : split(text, LIST_SEPARATOR);
}
bool parse_line(std::string_view line, AppHubCatalogEntry& entry) {
const auto fields = split(line, FIELD_SEPARATOR);
if (fields.size() != FIELD_COUNT) {
return false;
}
char* end = nullptr;
const long version_code = strtol(fields[2].c_str(), &end, 10);
if (fields[2].empty() || *end != '\0') {
return false;
}
const long requires_ram = strtol(fields[8].c_str(), &end, 10);
if (fields[8].empty() || *end != '\0') {
return false;
}
entry.id = fields[0];
entry.version_name = fields[1];
entry.version_code = static_cast<int32_t>(version_code);
entry.name = fields[3];
entry.description = fields[4];
entry.target_sdk = fields[5];
entry.target_platforms = split_list(fields[6]);
entry.requires_device_id = split_list(fields[7]);
entry.requires_ram = static_cast<int32_t>(requires_ram);
entry.file = fields[9];
return true;
}
} // namespace
bool app_hub_catalog_convert(const char* json_path, const char* catalog_path) {
auto data = read_file(json_path);
if (data == nullptr) { if (data == nullptr) {
LOG_E(TAG, "Failed to read %s", path); LOG_E(TAG, "Failed to read %s", json_path);
return false; return false;
} }
cJSON* json = cJSON_Parse(data.get()); cJSON* json = cJSON_Parse(data.get());
data = nullptr;
if (json == nullptr) { if (json == nullptr) {
LOG_E(TAG, "Failed to parse %s", path); LOG_E(TAG, "Failed to parse %s", json_path);
return false; return false;
} }
const cJSON* apps = cJSON_GetObjectItemCaseSensitive(json, "apps"); const cJSON* apps = cJSON_GetObjectItemCaseSensitive(json, "apps");
if (!cJSON_IsArray(apps)) { if (!cJSON_IsArray(apps)) {
LOG_E(TAG, "apps is not an array"); LOG_E(TAG, "apps is not an array");
@ -132,31 +243,97 @@ bool app_hub_catalog_parse(const char* path, AppHubCatalog& catalog) {
return false; return false;
} }
AppHubCatalog parsed; FILE* file = fopen(catalog_path, "wb");
parsed.resize(cJSON_GetArraySize(apps)); if (file == nullptr) {
size_t index = 0; LOG_E(TAG, "Failed to open %s", catalog_path);
cJSON_Delete(json);
return false;
}
bool success = true;
std::string line;
const cJSON* item; const cJSON* item;
cJSON_ArrayForEach(item, apps) { cJSON_ArrayForEach(item, apps) {
if (!parse_entry(item, parsed[index++])) { AppHubCatalogEntry entry;
if (!parse_entry(item, entry)) {
LOG_E(TAG, "Failed to read entry"); LOG_E(TAG, "Failed to read entry");
cJSON_Delete(json); success = false;
return false; break;
}
if (!format_entry(entry, line)) {
LOG_E(TAG, "%s can't be stored", entry.id.c_str());
success = false;
break;
}
if (fwrite(line.data(), 1, line.size(), file) != line.size()) {
LOG_E(TAG, "Failed to write %s", catalog_path);
success = false;
break;
} }
} }
cJSON_Delete(json); cJSON_Delete(json);
catalog = std::move(parsed); success = (fclose(file) == 0) && success;
return true; if (!success) {
remove(catalog_path);
}
return success;
}
bool app_hub_catalog_for_each(const char* catalog_path, AppHubCatalogVisitor visitor, void* context) {
FILE* file = fopen(catalog_path, "rb");
if (file == nullptr) {
return false;
}
bool success = true;
char line[LINE_MAX_SIZE];
while (fgets(line, sizeof(line), file) != nullptr) {
const size_t length = strlen(line);
AppHubCatalogEntry entry;
if (length == 0 || line[length - 1] != '\n' || !parse_line(std::string_view(line, length - 1), entry)) {
LOG_E(TAG, "Malformed catalog %s", catalog_path);
success = false;
break;
}
if (!visitor(entry, context)) {
break;
}
}
fclose(file);
return success;
}
bool app_hub_catalog_find(const char* catalog_path, const char* id, AppHubCatalogEntry& entry) {
struct Search {
const char* id;
AppHubCatalogEntry& entry;
bool found;
} search { id, entry, false };
app_hub_catalog_for_each(catalog_path, [](const AppHubCatalogEntry& candidate, void* context) {
auto* search = static_cast<Search*>(context);
if (candidate.id != search->id) {
return true;
}
search->entry = candidate;
search->found = true;
return false;
}, &search);
return search.found;
}
const char* app_hub_catalog_get_platform() {
#ifdef ESP_PLATFORM
return CONFIG_IDF_TARGET;
#else
// Same name as the folder of the app binary that the POSIX app loader looks for
return "posix-" TACTILITY_POSIX_ARCH;
#endif
} }
bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry) { bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry) {
// The simulator isn't a real MCU target, so it has no platform to match against // An empty list means that the app supports every platform
#ifdef ESP_PLATFORM
if (!entry.target_platforms.empty() && if (!entry.target_platforms.empty() &&
std::ranges::find(entry.target_platforms, std::string_view(CONFIG_IDF_TARGET)) == entry.target_platforms.end()) { std::ranges::find(entry.target_platforms, std::string_view(app_hub_catalog_get_platform())) == entry.target_platforms.end()) {
return false; return false;
} }
#endif
// Same device and RAM rules as for installed packages // Same device and RAM rules as for installed packages
PackageManifest package {}; PackageManifest package {};

View File

@ -0,0 +1,296 @@
// SPDX-License-Identifier: Apache-2.0
#include <app_hub/app_hub.h>
#include <app_hub/app_hub_service.h>
#include <app/manifest.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/time.h>
#include <cstdio>
#include <cstring>
#include <memory>
#include <vector>
constexpr TickType_t OPERATION_TIMEOUT = pdMS_TO_TICKS(180000);
constexpr size_t DESCRIPTION_MAX_SIZE = 2048;
static void print_usage() {
printf(
"usage: apphub <command>\n"
" refresh\n"
" search [query]\n"
" updates\n"
" info <id>\n"
" install <id>\n"
" update\n"
" remove <id>\n"
);
}
static int report_error(const char* action, error_t error) {
if (error == ERROR_RESOURCE_BUSY) {
printf("apphub: %s failed: another app is being installed or removed\n", action);
} else {
printf("apphub: %s failed: %s\n", action, error_to_string(error));
}
return 1;
}
/**
* Queues an operation and waits for its result.
* The subscription is made before queueing, so the operation can't finish unnoticed.
* It times out after OPERATION_TIMEOUT without events of the operation, so a long update of many apps doesn't.
* @param[in] finished_type the event that ends the operation and carries its result
* @param[in] on_event called for the other events of the operation (optional)
*/
static error_t run_and_wait(
const char* id,
error_t (*operation)(const char* id, uint32_t* operation_id),
AppHubEventType finished_type,
void (*on_event)(const AppHubEvent& event) = nullptr
) {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppHubEventSubscription subscription {};
error_t error = app_hub_event_subscribe(&subscription, &event_group);
if (error != ERROR_NONE) {
task_event_group_destruct(&event_group);
return error;
}
uint32_t operation_id = 0;
error = operation(id, &operation_id);
if (error == ERROR_NONE) {
error = ERROR_TIMEOUT;
TickType_t start = get_ticks();
TickType_t elapsed = 0;
bool finished = false;
while (!finished && elapsed < OPERATION_TIMEOUT) {
if (app_hub_event_await(&subscription, OPERATION_TIMEOUT - elapsed) == ERROR_NONE) {
AppHubEvent event {};
while (!finished && app_hub_event_poll(&subscription, &event) == ERROR_NONE) {
if (event.operation_id != operation_id) {
continue;
}
start = get_ticks();
if (event.type == finished_type) {
error = event.result;
finished = true;
} else if (on_event != nullptr) {
on_event(event);
}
}
}
elapsed = get_ticks() - start;
}
}
app_hub_event_unsubscribe(&subscription);
task_event_group_destruct(&event_group);
return error;
}
static error_t refresh(const char* /*id*/, uint32_t* operation_id) {
return app_hub_refresh(operation_id);
}
/** Collects the apps first, as printing can block while app_hub_search() holds the service's lock */
static std::vector<AppHubApp> search(const char* query) {
std::vector<AppHubApp> apps;
app_hub_search(query, [](const AppHubApp* app, void* context) {
static_cast<std::vector<AppHubApp>*>(context)->push_back(*app);
return true;
}, &apps);
return apps;
}
static void print_app(const AppHubApp& app) {
const char marker = app.update_available ? '^' : (app.installed ? '*' : ' ');
const char* version = app.in_catalog ? app.version_name : app.installed_version_name;
printf("%c %-32s %-24s %s\n", marker, app.id, app.name, version);
}
static int command_refresh() {
printf("Downloading the catalog...\n");
const error_t error = run_and_wait(nullptr, refresh, APP_HUB_EVENT_REFRESHED);
if (error != ERROR_NONE) {
return report_error("refresh", error);
}
printf("Catalog updated\n");
return 0;
}
static int command_search(int argc, char* argv[], bool updates_only) {
if (argc > 3 || (updates_only && argc > 2)) {
print_usage();
return 1;
}
const auto apps = search(argc == 3 ? argv[2] : nullptr);
size_t count = 0;
for (const auto& app : apps) {
if (!updates_only || app.update_available) {
print_app(app);
count++;
}
}
if (count == 0) {
printf(updates_only ? "No updates available\n" : "No apps found. Run 'apphub refresh' to download the catalog.\n");
} else if (!updates_only) {
printf("(* = installed, ^ = update available)\n");
}
return 0;
}
static int command_info(int argc, char* argv[]) {
if (argc != 3) {
printf("usage: apphub info <id>\n");
return 1;
}
AppHubApp app {};
if (app_hub_get_app(argv[2], &app) != ERROR_NONE) {
printf("apphub: unknown app: %s\n", argv[2]);
return 1;
}
printf("id: %s\n", app.id);
printf("name: %s\n", app.name);
if (app.in_catalog) {
printf("version: %s\n", app.version_name);
}
if (app.installed) {
printf("installed: %s%s\n", app.installed_version_name, app.update_available ? " (update available)" : "");
} else {
printf("installed: no\n");
}
auto description = std::make_unique<char[]>(DESCRIPTION_MAX_SIZE);
if (app_hub_get_description(app.id, description.get(), DESCRIPTION_MAX_SIZE) == ERROR_NONE && description[0] != '\0') {
printf("\n%s\n", description.get());
}
return 0;
}
static int command_install(int argc, char* argv[]) {
if (argc != 3) {
printf("usage: apphub install <id>\n");
return 1;
}
printf("Installing %s...\n", argv[2]);
const error_t error = run_and_wait(argv[2], app_hub_install, APP_HUB_EVENT_INSTALLED);
if (error == ERROR_NOT_FOUND) {
printf("apphub: %s is not in the catalog\n", argv[2]);
return 1;
} else if (error != ERROR_NONE) {
return report_error("install", error);
}
printf("Installed %s\n", argv[2]);
return 0;
}
static error_t update_all(const char* /*id*/, uint32_t* operation_id) {
return app_hub_update_all(operation_id);
}
static void print_update_progress(const AppHubEvent& event) {
if (event.type == APP_HUB_EVENT_INSTALLING) {
printf("Updating %s...\n", event.app_id);
} else if (event.type == APP_HUB_EVENT_INSTALLED) {
if (event.result == ERROR_NONE) {
printf("Updated %s\n", event.app_id);
} else {
printf("apphub: update of %s failed: %s\n", event.app_id, error_to_string(event.result));
}
}
}
static int command_update(int argc) {
if (argc != 2) {
printf("usage: apphub update\n");
return 1;
}
// Counted first, as the update reports nothing when there are no updates
size_t count = 0;
for (const auto& app : search(nullptr)) {
if (app.update_available) {
count++;
}
}
if (count == 0) {
printf("No updates available\n");
return 0;
}
const error_t error = run_and_wait(nullptr, update_all, APP_HUB_EVENT_UPDATED, print_update_progress);
if (error == ERROR_RESOURCE_BUSY || error == ERROR_TIMEOUT) {
return report_error("update", error);
} else if (error != ERROR_NONE) {
printf("apphub: some apps failed to update\n");
return 1;
}
printf("All apps are up to date\n");
return 0;
}
static int command_remove(int argc, char* argv[]) {
if (argc != 3) {
printf("usage: apphub remove <id>\n");
return 1;
}
printf("Removing %s...\n", argv[2]);
const error_t error = run_and_wait(argv[2], app_hub_remove, APP_HUB_EVENT_REMOVED);
if (error == ERROR_NOT_FOUND) {
printf("apphub: %s is not installed\n", argv[2]);
return 1;
} else if (error != ERROR_NONE) {
return report_error("remove", error);
}
printf("Removed %s\n", argv[2]);
return 0;
}
static int32_t app_hub_command_main(int argc, char* argv[]) {
if (argc < 2 || strcmp(argv[1], "help") == 0) {
print_usage();
return 0;
}
const char* command = argv[1];
if (strcmp(command, "refresh") == 0) {
return command_refresh();
} else if (strcmp(command, "search") == 0) {
return command_search(argc, argv, false);
} else if (strcmp(command, "updates") == 0) {
return command_search(argc, argv, true);
} else if (strcmp(command, "info") == 0) {
return command_info(argc, argv);
} else if (strcmp(command, "install") == 0) {
return command_install(argc, argv);
} else if (strcmp(command, "update") == 0) {
return command_update(argc);
} else if (strcmp(command, "remove") == 0) {
return command_remove(argc, argv);
}
printf("apphub: unknown command: %s\n", command);
print_usage();
return 1;
}
extern "C" {
const AppManifest app_hub_command_manifest = {
.id = "apphub",
.name = "apphub",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(app_hub_command_main) },
.flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 5120, .desired_memory_capability = 0 },
};
}

View File

@ -15,10 +15,14 @@
#include <tactility/concurrent/thread.h> #include <tactility/concurrent/thread.h>
#include <tactility/filesystem/fs.h> #include <tactility/filesystem/fs.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <tactility/memory.h>
#include <tactility/paths.h> #include <tactility/paths.h>
#include <tactility/time.h> #include <tactility/time.h>
#include <TactilityCpp/Allocator.h>
#include <algorithm> #include <algorithm>
#include <new>
#include <cctype> #include <cctype>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@ -30,8 +34,11 @@
namespace { namespace {
constexpr auto* TAG = "app_hub"; constexpr auto* TAG = "app_hub";
constexpr auto* CATALOG_FILE_NAME = "apps.json"; constexpr auto* CATALOG_FILE_NAME = "catalog.txt";
constexpr auto* CATALOG_TEMP_FILE_NAME = "catalog.txt.tmp";
constexpr auto* CATALOG_DOWNLOAD_FILE_NAME = "apps.json.tmp"; constexpr auto* CATALOG_DOWNLOAD_FILE_NAME = "apps.json.tmp";
// Older versions kept the downloaded apps.json as the catalog
constexpr auto* LEGACY_CATALOG_FILE_NAME = "apps.json";
constexpr size_t WORKER_STACK_SIZE = 8192; constexpr size_t WORKER_STACK_SIZE = 8192;
constexpr TickType_t WORKER_IDLE_TIMEOUT = pdMS_TO_TICKS(5000); constexpr TickType_t WORKER_IDLE_TIMEOUT = pdMS_TO_TICKS(5000);
@ -61,21 +68,36 @@ void unlock() { mutex_unlock(&globalMutex.handle); }
// region State, guarded by the global lock // region State, guarded by the global lock
AppHubCatalog catalog; struct State {
bool catalogLoaded = false; std::deque<Operation, tt::OptExternalAllocator<Operation>> queue;
std::deque<Operation> queue; Operation runningOperation {};
Operation runningOperation {}; bool operationRunning = false;
bool operationRunning = false; uint32_t nextOperationId = 1;
uint32_t nextOperationId = 1; AppHubEventSubscription* subscriptions = nullptr;
AppHubEventSubscription* subscriptions = nullptr;
bool serviceRunning = false; bool serviceRunning = false;
bool stopping = false; bool stopping = false;
Thread* worker = nullptr; Thread* worker = nullptr;
bool workerRunning = false; bool workerRunning = false;
TaskEventGroup workerEvents {}; TaskEventGroup workerEvents {};
uint32_t queueBit = 0; uint32_t queueBit = 0;
uint32_t stopBit = 0; uint32_t stopBit = 0;
};
constexpr MemoryPolicy STATE_MEMORY_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = alignof(State) };
/** Exists while the service does */
State* state = nullptr;
/** Locks, and unlocks again when the service doesn't exist */
bool lockState() {
lock();
if (state != nullptr) {
return true;
}
unlock();
return false;
}
// endregion // endregion
@ -86,6 +108,7 @@ AppHubEventType getStartedEventType(AppHubOperationType operation) {
case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHING; case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHING;
case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLING; case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLING;
case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVING; case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVING;
case APP_HUB_OPERATION_UPDATE_ALL: return APP_HUB_EVENT_UPDATING;
} }
return APP_HUB_EVENT_REFRESHING; return APP_HUB_EVENT_REFRESHING;
} }
@ -95,18 +118,22 @@ AppHubEventType getFinishedEventType(AppHubOperationType operation) {
case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHED; case APP_HUB_OPERATION_REFRESH: return APP_HUB_EVENT_REFRESHED;
case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLED; case APP_HUB_OPERATION_INSTALL: return APP_HUB_EVENT_INSTALLED;
case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVED; case APP_HUB_OPERATION_REMOVE: return APP_HUB_EVENT_REMOVED;
case APP_HUB_OPERATION_UPDATE_ALL: return APP_HUB_EVENT_UPDATED;
} }
return APP_HUB_EVENT_REFRESHED; return APP_HUB_EVENT_REFRESHED;
} }
/** Requires the global lock */ /**
void emitEvent(AppHubEventType type, const Operation& operation, error_t result) { * Requires the global lock
* @param[in] appId the app the event is about, which an update of all apps reports per app
*/
void emitEvent(AppHubEventType type, const Operation& operation, const char* appId, error_t result) {
AppHubEvent event {}; AppHubEvent event {};
event.type = type; event.type = type;
event.operation_id = operation.id; event.operation_id = operation.id;
strcpy(event.app_id, operation.app_id); strcpy(event.app_id, appId);
event.result = result; event.result = result;
for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) { for (auto* sub = state->subscriptions; sub != nullptr; sub = sub->internal.next) {
if (sub->internal.count >= APP_HUB_EVENT_QUEUE_CAPACITY) { if (sub->internal.count >= APP_HUB_EVENT_QUEUE_CAPACITY) {
LOG_W(TAG, "A subscriber's event queue is full: dropped an event of operation %lu", static_cast<unsigned long>(event.operation_id)); LOG_W(TAG, "A subscriber's event queue is full: dropped an event of operation %lu", static_cast<unsigned long>(event.operation_id));
continue; continue;
@ -126,26 +153,32 @@ bool getCatalogPath(const char* fileName, char* path, size_t pathSize) {
return service_paths_get_user_data_path(APP_HUB_SERVICE_ID, fileName, path, pathSize) == ERROR_NONE; return service_paths_get_user_data_path(APP_HUB_SERVICE_ID, fileName, path, pathSize) == ERROR_NONE;
} }
/** Requires the global lock */ /**
void ensureCatalogLoaded() { * Converts the apps.json of an older version into a catalog file
if (catalogLoaded) { * @return false when there is no catalog
return; */
} bool migrateLegacyCatalog(const char* catalogPath) {
char path[FILE_MAX_PATH_STRING_LENGTH]; char legacyPath[FILE_MAX_PATH_STRING_LENGTH];
if (getCatalogPath(CATALOG_FILE_NAME, path, sizeof(path)) && path_exists(path)) { if (!getCatalogPath(LEGACY_CATALOG_FILE_NAME, legacyPath, sizeof(legacyPath)) || !path_exists(legacyPath)) {
catalogLoaded = app_hub_catalog_parse(path, catalog); return false;
} }
const bool converted = app_hub_catalog_convert(legacyPath, catalogPath);
file_remove(legacyPath);
return converted;
}
/**
* @param[out] path the path of the catalog file
* @return false when there is no catalog
*/
bool getCatalogFile(char* path, size_t pathSize) {
return getCatalogPath(CATALOG_FILE_NAME, path, pathSize) && (path_exists(path) || migrateLegacyCatalog(path));
} }
/** Requires the global lock */ /** Requires the global lock */
const AppHubCatalogEntry* findCatalogEntry(const char* id) { bool findCatalogEntry(const char* id, AppHubCatalogEntry& entry) {
ensureCatalogLoaded(); char path[FILE_MAX_PATH_STRING_LENGTH];
for (const auto& entry : catalog) { return getCatalogFile(path, sizeof(path)) && app_hub_catalog_find(path, id, entry);
if (entry.id == id) {
return &entry;
}
}
return nullptr;
} }
// endregion // endregion
@ -174,10 +207,10 @@ std::vector<InstalledPackage> getInstalledPackages() {
/** Requires the global lock. Only one refresh and one install or remove can be queued or running at a time. */ /** Requires the global lock. Only one refresh and one install or remove can be queued or running at a time. */
const Operation* findPendingOperationOfKind(bool refresh) { const Operation* findPendingOperationOfKind(bool refresh) {
if (operationRunning && (runningOperation.type == APP_HUB_OPERATION_REFRESH) == refresh) { if (state->operationRunning && (state->runningOperation.type == APP_HUB_OPERATION_REFRESH) == refresh) {
return &runningOperation; return &state->runningOperation;
} }
for (const auto& operation : queue) { for (const auto& operation : state->queue) {
if ((operation.type == APP_HUB_OPERATION_REFRESH) == refresh) { if ((operation.type == APP_HUB_OPERATION_REFRESH) == refresh) {
return &operation; return &operation;
} }
@ -187,10 +220,10 @@ const Operation* findPendingOperationOfKind(bool refresh) {
/** Requires the global lock */ /** Requires the global lock */
const Operation* findPendingOperation(const char* id) { const Operation* findPendingOperation(const char* id) {
if (operationRunning && runningOperation.type != APP_HUB_OPERATION_REFRESH && strcmp(runningOperation.app_id, id) == 0) { if (state->operationRunning && state->runningOperation.type != APP_HUB_OPERATION_REFRESH && strcmp(state->runningOperation.app_id, id) == 0) {
return &runningOperation; return &state->runningOperation;
} }
for (const auto& operation : queue) { for (const auto& operation : state->queue) {
if (operation.type != APP_HUB_OPERATION_REFRESH && strcmp(operation.app_id, id) == 0) { if (operation.type != APP_HUB_OPERATION_REFRESH && strcmp(operation.app_id, id) == 0) {
return &operation; return &operation;
} }
@ -200,19 +233,21 @@ const Operation* findPendingOperation(const char* id) {
/** Requires the global lock */ /** Requires the global lock */
AppList collectApps(const std::vector<InstalledPackage>& installedPackages) { AppList collectApps(const std::vector<InstalledPackage>& installedPackages) {
ensureCatalogLoaded();
AppList apps; AppList apps;
for (const auto& entry : catalog) { char catalogPath[FILE_MAX_PATH_STRING_LENGTH];
if (!app_hub_catalog_is_compatible(entry)) { if (getCatalogFile(catalogPath, sizeof(catalogPath))) {
continue; app_hub_catalog_for_each(catalogPath, [](const AppHubCatalogEntry& entry, void* context) {
} if (app_hub_catalog_is_compatible(entry)) {
AppHubApp app {}; AppHubApp app {};
copyString(app.id, sizeof(app.id), entry.id.c_str()); copyString(app.id, sizeof(app.id), entry.id.c_str());
copyString(app.name, sizeof(app.name), entry.name.c_str()); copyString(app.name, sizeof(app.name), entry.name.c_str());
copyString(app.version_name, sizeof(app.version_name), entry.version_name.c_str()); copyString(app.version_name, sizeof(app.version_name), entry.version_name.c_str());
app.version_code = static_cast<uint64_t>(entry.version_code); app.version_code = static_cast<uint64_t>(entry.version_code);
app.in_catalog = true; app.in_catalog = true;
apps.push_back(app); static_cast<AppList*>(context)->push_back(app);
}
return true;
}, &apps);
} }
for (const auto& installed : installedPackages) { for (const auto& installed : installedPackages) {
@ -235,8 +270,13 @@ AppList collectApps(const std::vector<InstalledPackage>& installedPackages) {
app->update_available = app->in_catalog && app->installed_version_code < app->version_code; app->update_available = app->in_catalog && app->installed_version_code < app->version_code;
} }
const Operation* pendingAppOperation = findPendingOperationOfKind(false);
const bool updatingAll = pendingAppOperation != nullptr && pendingAppOperation->type == APP_HUB_OPERATION_UPDATE_ALL;
for (auto& app : apps) { for (auto& app : apps) {
const Operation* pending = findPendingOperation(app.id); const Operation* pending = findPendingOperation(app.id);
if (pending == nullptr && updatingAll && app.update_available) {
pending = pendingAppOperation;
}
app.operation_pending = pending != nullptr; app.operation_pending = pending != nullptr;
if (pending != nullptr) { if (pending != nullptr) {
app.pending_operation = pending->type; app.pending_operation = pending->type;
@ -265,18 +305,18 @@ bool lessIgnoringCase(const AppHubApp& left, const AppHubApp& right) {
error_t download(const char* url, const char* targetPath) { error_t download(const char* url, const char* targetPath) {
HttpDownloadSubscription sub {}; HttpDownloadSubscription sub {};
if (http_download_subscribe(&sub, &workerEvents) != ERROR_NONE) { if (http_download_subscribe(&sub, &state->workerEvents) != ERROR_NONE) {
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
if (http_download_start(url, APP_HUB_CERTIFICATE_PATH, targetPath, &sub) != ERROR_NONE) { if (http_download_start(url, app_hub_catalog_get_certificate_path().c_str(), targetPath, &sub) != ERROR_NONE) {
http_download_unsubscribe(&sub); http_download_unsubscribe(&sub);
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
while (true) { while (true) {
uint32_t flags = 0; uint32_t flags = 0;
task_event_group_wait(&workerEvents, sub.bit | stopBit, false, &flags, portMAX_DELAY); task_event_group_wait(&state->workerEvents, sub.bit | state->stopBit, false, &flags, portMAX_DELAY);
if (flags & stopBit) { if (flags & state->stopBit) {
http_download_cancel(&sub); http_download_cancel(&sub);
http_download_unsubscribe(&sub); http_download_unsubscribe(&sub);
return ERROR_INVALID_STATE; return ERROR_INVALID_STATE;
@ -302,9 +342,11 @@ error_t executeRefresh() {
char directory[FILE_MAX_PATH_STRING_LENGTH]; char directory[FILE_MAX_PATH_STRING_LENGTH];
char downloadPath[FILE_MAX_PATH_STRING_LENGTH]; char downloadPath[FILE_MAX_PATH_STRING_LENGTH];
char catalogPath[FILE_MAX_PATH_STRING_LENGTH]; char catalogPath[FILE_MAX_PATH_STRING_LENGTH];
char tempCatalogPath[FILE_MAX_PATH_STRING_LENGTH];
if (service_paths_get_user_data_directory(APP_HUB_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE || if (service_paths_get_user_data_directory(APP_HUB_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE ||
!getCatalogPath(CATALOG_DOWNLOAD_FILE_NAME, downloadPath, sizeof(downloadPath)) || !getCatalogPath(CATALOG_DOWNLOAD_FILE_NAME, downloadPath, sizeof(downloadPath)) ||
!getCatalogPath(CATALOG_FILE_NAME, catalogPath, sizeof(catalogPath))) { !getCatalogPath(CATALOG_FILE_NAME, catalogPath, sizeof(catalogPath)) ||
!getCatalogPath(CATALOG_TEMP_FILE_NAME, tempCatalogPath, sizeof(tempCatalogPath))) {
return ERROR_NOT_FOUND; return ERROR_NOT_FOUND;
} }
error_t error = ensureDirectory(directory); error_t error = ensureDirectory(directory);
@ -318,33 +360,28 @@ error_t executeRefresh() {
return error; return error;
} }
AppHubCatalog downloaded; // The catalog file is only replaced by a complete conversion
if (!app_hub_catalog_parse(downloadPath, downloaded)) { const bool converted = app_hub_catalog_convert(downloadPath, tempCatalogPath);
file_remove(downloadPath); file_remove(downloadPath);
if (!converted) {
return ERROR_INVALID_ARGUMENT; return ERROR_INVALID_ARGUMENT;
} }
error = file_move(downloadPath, catalogPath, true);
if (error != ERROR_NONE) {
return error;
}
lock(); lock();
catalog = std::move(downloaded); error = file_move(tempCatalogPath, catalogPath, true);
catalogLoaded = true;
unlock(); unlock();
return ERROR_NONE; return error;
} }
error_t executeInstall(const char* id) { error_t executeInstall(const char* id) {
AppHubCatalogEntry entry;
lock(); lock();
const AppHubCatalogEntry* entry = findCatalogEntry(id); const bool found = findCatalogEntry(id, entry);
const bool compatible = entry != nullptr && app_hub_catalog_is_compatible(*entry);
const std::string file = (entry != nullptr) ? entry->file : "";
unlock(); unlock();
if (entry == nullptr) { if (!found) {
return ERROR_NOT_FOUND; return ERROR_NOT_FOUND;
} }
if (!compatible) { const std::string& file = entry.file;
if (!app_hub_catalog_is_compatible(entry)) {
return ERROR_NOT_SUPPORTED; return ERROR_NOT_SUPPORTED;
} }
@ -374,6 +411,41 @@ error_t executeInstall(const char* id) {
return error; return error;
} }
error_t executeUpdateAll(const Operation& operation) {
const auto installed = getInstalledPackages();
std::vector<std::string> ids;
lock();
for (const auto& app : collectApps(installed)) {
if (app.update_available) {
ids.emplace_back(app.id);
}
}
unlock();
error_t lastError = ERROR_NONE;
for (const auto& id : ids) {
lock();
const bool stop = state->stopping;
if (!stop) {
emitEvent(APP_HUB_EVENT_INSTALLING, operation, id.c_str(), ERROR_NONE);
}
unlock();
if (stop) {
return ERROR_INVALID_STATE;
}
const error_t error = executeInstall(id.c_str());
if (error != ERROR_NONE) {
lastError = error;
}
lock();
emitEvent(APP_HUB_EVENT_INSTALLED, operation, id.c_str(), error);
unlock();
}
return lastError;
}
error_t execute(const Operation& operation) { error_t execute(const Operation& operation) {
switch (operation.type) { switch (operation.type) {
case APP_HUB_OPERATION_REFRESH: case APP_HUB_OPERATION_REFRESH:
@ -382,6 +454,8 @@ error_t execute(const Operation& operation) {
return executeInstall(operation.app_id); return executeInstall(operation.app_id);
case APP_HUB_OPERATION_REMOVE: case APP_HUB_OPERATION_REMOVE:
return app_uninstall(operation.app_id); return app_uninstall(operation.app_id);
case APP_HUB_OPERATION_UPDATE_ALL:
return executeUpdateAll(operation);
} }
return ERROR_INVALID_ARGUMENT; return ERROR_INVALID_ARGUMENT;
} }
@ -389,21 +463,21 @@ error_t execute(const Operation& operation) {
int32_t workerMain(void* /*context*/) { int32_t workerMain(void* /*context*/) {
while (true) { while (true) {
lock(); lock();
if (stopping) { if (state->stopping) {
workerRunning = false; state->workerRunning = false;
unlock(); unlock();
return 0; return 0;
} }
if (queue.empty()) { if (state->queue.empty()) {
unlock(); unlock();
uint32_t flags = 0; uint32_t flags = 0;
const error_t waited = task_event_group_wait(&workerEvents, queueBit | stopBit, false, &flags, WORKER_IDLE_TIMEOUT); const error_t waited = task_event_group_wait(&state->workerEvents, state->queueBit | state->stopBit, false, &flags, WORKER_IDLE_TIMEOUT);
if (waited == ERROR_TIMEOUT) { if (waited == ERROR_TIMEOUT) {
// An operation can be queued between the wait and taking the lock // An operation can be queued between the wait and taking the lock
lock(); lock();
const bool idle = queue.empty(); const bool idle = state->queue.empty();
if (idle) { if (idle) {
workerRunning = false; state->workerRunning = false;
} }
unlock(); unlock();
if (idle) { if (idle) {
@ -413,42 +487,42 @@ int32_t workerMain(void* /*context*/) {
continue; continue;
} }
runningOperation = queue.front(); state->runningOperation = state->queue.front();
queue.pop_front(); state->queue.pop_front();
operationRunning = true; state->operationRunning = true;
const Operation operation = runningOperation; const Operation operation = state->runningOperation;
emitEvent(getStartedEventType(operation.type), operation, ERROR_NONE); emitEvent(getStartedEventType(operation.type), operation, operation.app_id, ERROR_NONE);
unlock(); unlock();
const error_t result = execute(operation); const error_t result = execute(operation);
lock(); lock();
operationRunning = false; state->operationRunning = false;
emitEvent(getFinishedEventType(operation.type), operation, result); emitEvent(getFinishedEventType(operation.type), operation, operation.app_id, result);
unlock(); unlock();
} }
} }
/** Requires the global lock */ /** Requires the global lock */
error_t ensureWorkerRunning() { error_t ensureWorkerRunning() {
if (workerRunning) { if (state->workerRunning) {
return ERROR_NONE; return ERROR_NONE;
} }
if (worker != nullptr) { if (state->worker != nullptr) {
// The previous worker cleared workerRunning and exits without taking the lock // The previous worker cleared workerRunning and exits without taking the lock
thread_join(worker, portMAX_DELAY, pdMS_TO_TICKS(10)); thread_join(state->worker, portMAX_DELAY, pdMS_TO_TICKS(10));
thread_free(worker); thread_free(state->worker);
} }
worker = thread_alloc_full("apphub", WORKER_STACK_SIZE, workerMain, nullptr, -1); state->worker = thread_alloc_full("apphub", WORKER_STACK_SIZE, workerMain, nullptr, -1);
if (worker == nullptr) { if (state->worker == nullptr) {
return ERROR_OUT_OF_MEMORY; return ERROR_OUT_OF_MEMORY;
} }
if (thread_start(worker) != ERROR_NONE) { if (thread_start(state->worker) != ERROR_NONE) {
thread_free(worker); thread_free(state->worker);
worker = nullptr; state->worker = nullptr;
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
workerRunning = true; state->workerRunning = true;
return ERROR_NONE; return ERROR_NONE;
} }
@ -457,8 +531,10 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId)
return ERROR_INVALID_ARGUMENT; return ERROR_INVALID_ARGUMENT;
} }
lock(); if (!lockState()) {
if (!serviceRunning) { return ERROR_INVALID_STATE;
}
if (!state->serviceRunning) {
unlock(); unlock();
return ERROR_INVALID_STATE; return ERROR_INVALID_STATE;
} }
@ -474,7 +550,7 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId)
} }
Operation operation {}; Operation operation {};
operation.id = nextOperationId++; operation.id = state->nextOperationId++;
operation.type = type; operation.type = type;
if (id != nullptr) { if (id != nullptr) {
strcpy(operation.app_id, id); strcpy(operation.app_id, id);
@ -482,8 +558,8 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId)
const error_t error = ensureWorkerRunning(); const error_t error = ensureWorkerRunning();
if (error == ERROR_NONE) { if (error == ERROR_NONE) {
queue.push_back(operation); state->queue.push_back(operation);
task_event_group_signal(&workerEvents, queueBit); task_event_group_signal(&state->workerEvents, state->queueBit);
if (operationId != nullptr) { if (operationId != nullptr) {
*operationId = operation.id; *operationId = operation.id;
} }
@ -497,44 +573,65 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId)
// region Service // region Service
void* createService(const ServiceManifest* /*manifest*/) { void* createService(const ServiceManifest* /*manifest*/) {
return nullptr; void* memory = memory_alloc_with_policy(sizeof(State), &STATE_MEMORY_POLICY);
if (memory == nullptr) {
return nullptr;
}
auto* created = new (memory) State();
lock();
state = created;
unlock();
return created;
} }
void destroyService(const ServiceManifest* /*manifest*/, void* /*data*/) {} void destroyService(const ServiceManifest* /*manifest*/, void* data) {
if (data == nullptr) {
return;
}
auto* destroyed = static_cast<State*>(data);
lock();
state = nullptr;
unlock();
destroyed->~State();
memory_free(destroyed);
}
error_t onStart(ServiceInstance* /*instance*/, void* /*data*/) { error_t onStart(ServiceInstance* /*instance*/, void* data) {
task_event_group_construct(&workerEvents); if (data == nullptr) {
if (task_event_group_claim_bit(&workerEvents, &queueBit) != ERROR_NONE || return ERROR_OUT_OF_MEMORY;
task_event_group_claim_bit(&workerEvents, &stopBit) != ERROR_NONE) { }
task_event_group_destruct(&workerEvents); task_event_group_construct(&state->workerEvents);
if (task_event_group_claim_bit(&state->workerEvents, &state->queueBit) != ERROR_NONE ||
task_event_group_claim_bit(&state->workerEvents, &state->stopBit) != ERROR_NONE) {
task_event_group_destruct(&state->workerEvents);
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
lock(); lock();
serviceRunning = true; state->serviceRunning = true;
stopping = false; state->stopping = false;
unlock(); unlock();
return ERROR_NONE; return ERROR_NONE;
} }
void onStop(ServiceInstance* /*instance*/, void* /*data*/) { void onStop(ServiceInstance* /*instance*/, void* /*data*/) {
lock(); lock();
serviceRunning = false; state->serviceRunning = false;
stopping = true; state->stopping = true;
queue.clear(); state->queue.clear();
Thread* stoppingWorker = worker; Thread* stoppingWorker = state->worker;
worker = nullptr; state->worker = nullptr;
unlock(); unlock();
if (stoppingWorker != nullptr) { if (stoppingWorker != nullptr) {
task_event_group_signal(&workerEvents, stopBit); task_event_group_signal(&state->workerEvents, state->stopBit);
thread_join(stoppingWorker, portMAX_DELAY, pdMS_TO_TICKS(10)); thread_join(stoppingWorker, portMAX_DELAY, pdMS_TO_TICKS(10));
thread_free(stoppingWorker); thread_free(stoppingWorker);
} }
lock(); lock();
workerRunning = false; state->workerRunning = false;
unlock(); unlock();
task_event_group_destruct(&workerEvents); task_event_group_destruct(&state->workerEvents);
} }
// endregion // endregion
@ -550,8 +647,10 @@ const ServiceManifest app_hub_service_manifest = {
}; };
bool app_hub_is_worker_running() { bool app_hub_is_worker_running() {
lock(); if (!lockState()) {
const bool running = workerRunning; return false;
}
const bool running = state->workerRunning;
unlock(); unlock();
return running; return running;
} }
@ -562,7 +661,9 @@ extern "C" {
error_t app_hub_search(const char* query, AppHubAppVisitorFn visitor, void* context) { error_t app_hub_search(const char* query, AppHubAppVisitorFn visitor, void* context) {
const auto installed = getInstalledPackages(); const auto installed = getInstalledPackages();
lock(); if (!lockState()) {
return ERROR_INVALID_STATE;
}
AppList apps = collectApps(installed); AppList apps = collectApps(installed);
std::ranges::sort(apps, lessIgnoringCase); std::ranges::sort(apps, lessIgnoringCase);
for (const auto& app : apps) { for (const auto& app : apps) {
@ -579,7 +680,9 @@ error_t app_hub_search(const char* query, AppHubAppVisitorFn visitor, void* cont
error_t app_hub_get_app(const char* id, AppHubApp* app) { error_t app_hub_get_app(const char* id, AppHubApp* app) {
const auto installed = getInstalledPackages(); const auto installed = getInstalledPackages();
lock(); if (!lockState()) {
return ERROR_INVALID_STATE;
}
const AppList apps = collectApps(installed); const AppList apps = collectApps(installed);
unlock(); unlock();
const auto found = std::ranges::find_if(apps, [id](const AppHubApp& candidate) { const auto found = std::ranges::find_if(apps, [id](const AppHubApp& candidate) {
@ -593,24 +696,33 @@ error_t app_hub_get_app(const char* id, AppHubApp* app) {
} }
error_t app_hub_get_description(const char* id, char* description, size_t description_size) { error_t app_hub_get_description(const char* id, char* description, size_t description_size) {
AppHubCatalogEntry entry;
lock(); lock();
const AppHubCatalogEntry* entry = findCatalogEntry(id); const bool found = findCatalogEntry(id, entry);
error_t error = ERROR_NONE;
if (entry == nullptr) {
error = ERROR_NOT_FOUND;
} else if (entry->description.size() >= description_size) {
error = ERROR_BUFFER_OVERFLOW;
} else {
strcpy(description, entry->description.c_str());
}
unlock(); unlock();
return error; if (!found) {
return ERROR_NOT_FOUND;
}
if (entry.description.size() >= description_size) {
return ERROR_BUFFER_OVERFLOW;
}
strcpy(description, entry.description.c_str());
return ERROR_NONE;
} }
// endregion // endregion
// region Operations // region Operations
bool app_hub_is_busy() {
if (!lockState()) {
return false;
}
const bool busy = state->operationRunning || !state->queue.empty();
unlock();
return busy;
}
error_t app_hub_refresh(uint32_t* operation_id) { error_t app_hub_refresh(uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_REFRESH, nullptr, operation_id); return enqueue(APP_HUB_OPERATION_REFRESH, nullptr, operation_id);
} }
@ -619,6 +731,10 @@ error_t app_hub_install(const char* id, uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_INSTALL, id, operation_id); return enqueue(APP_HUB_OPERATION_INSTALL, id, operation_id);
} }
error_t app_hub_update_all(uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_UPDATE_ALL, nullptr, operation_id);
}
error_t app_hub_remove(const char* id, uint32_t* operation_id) { error_t app_hub_remove(const char* id, uint32_t* operation_id) {
return enqueue(APP_HUB_OPERATION_REMOVE, id, operation_id); return enqueue(APP_HUB_OPERATION_REMOVE, id, operation_id);
} }
@ -634,8 +750,11 @@ error_t app_hub_event_subscribe(AppHubEventSubscription* sub, TaskEventGroup* ev
return claimed; return claimed;
} }
lock(); if (!lockState()) {
for (auto* existing = subscriptions; existing != nullptr; existing = existing->internal.next) { task_event_group_release_bit(event_group, bit);
return ERROR_INVALID_STATE;
}
for (auto* existing = state->subscriptions; existing != nullptr; existing = existing->internal.next) {
if (existing == sub) { if (existing == sub) {
unlock(); unlock();
task_event_group_release_bit(event_group, bit); task_event_group_release_bit(event_group, bit);
@ -646,16 +765,18 @@ error_t app_hub_event_subscribe(AppHubEventSubscription* sub, TaskEventGroup* ev
sub->internal.event_group = event_group; sub->internal.event_group = event_group;
sub->internal.head = 0; sub->internal.head = 0;
sub->internal.count = 0; sub->internal.count = 0;
sub->internal.next = subscriptions; sub->internal.next = state->subscriptions;
subscriptions = sub; state->subscriptions = sub;
unlock(); unlock();
return ERROR_NONE; return ERROR_NONE;
} }
error_t app_hub_event_unsubscribe(AppHubEventSubscription* sub) { error_t app_hub_event_unsubscribe(AppHubEventSubscription* sub) {
error_t result = ERROR_NOT_FOUND; error_t result = ERROR_NOT_FOUND;
lock(); if (!lockState()) {
for (auto** link = &subscriptions; *link != nullptr; link = &(*link)->internal.next) { return result;
}
for (auto** link = &state->subscriptions; *link != nullptr; link = &(*link)->internal.next) {
if (*link == sub) { if (*link == sub) {
*link = sub->internal.next; *link = sub->internal.next;
result = ERROR_NONE; result = ERROR_NONE;

View File

@ -3,6 +3,7 @@
#include <app_hub/app_hub_service.h> #include <app_hub/app_hub_service.h>
#include <app_hub/module.h> #include <app_hub/module.h>
#include <app/manager.h>
#include <service/manager.h> #include <service/manager.h>
#include <tactility/error.h> #include <tactility/error.h>
@ -11,23 +12,37 @@
extern "C" { extern "C" {
static error_t start() { static error_t start() {
return service_manager_add(&app_hub_service_manifest, /*auto_start=*/true); error_t error = app_manager_add(&app_hub_command_manifest);
if (error != ERROR_NONE) {
return error;
}
error = service_manager_add(&app_hub_service_manifest, /*auto_start=*/true);
if (error != ERROR_NONE) {
app_manager_remove(app_hub_command_manifest.id);
}
return error;
} }
static error_t stop() { static error_t stop() {
const error_t error = service_manager_stop(app_hub_service_manifest.id); error_t error = service_manager_stop(app_hub_service_manifest.id);
if (error != ERROR_NONE && error != ERROR_NOT_FOUND) { if (error != ERROR_NONE && error != ERROR_NOT_FOUND) {
return error; return error;
} }
return service_manager_remove(app_hub_service_manifest.id); error = service_manager_remove(app_hub_service_manifest.id);
if (error != ERROR_NONE) {
return error;
}
return app_manager_remove(app_hub_command_manifest.id);
} }
static const ModuleSymbol app_hub_module_symbols[] = { static const ModuleSymbol app_hub_module_symbols[] = {
DEFINE_MODULE_SYMBOL(app_hub_search), DEFINE_MODULE_SYMBOL(app_hub_search),
DEFINE_MODULE_SYMBOL(app_hub_get_app), DEFINE_MODULE_SYMBOL(app_hub_get_app),
DEFINE_MODULE_SYMBOL(app_hub_get_description), DEFINE_MODULE_SYMBOL(app_hub_get_description),
DEFINE_MODULE_SYMBOL(app_hub_is_busy),
DEFINE_MODULE_SYMBOL(app_hub_refresh), DEFINE_MODULE_SYMBOL(app_hub_refresh),
DEFINE_MODULE_SYMBOL(app_hub_install), DEFINE_MODULE_SYMBOL(app_hub_install),
DEFINE_MODULE_SYMBOL(app_hub_update_all),
DEFINE_MODULE_SYMBOL(app_hub_remove), DEFINE_MODULE_SYMBOL(app_hub_remove),
DEFINE_MODULE_SYMBOL(app_hub_event_subscribe), DEFINE_MODULE_SYMBOL(app_hub_event_subscribe),
DEFINE_MODULE_SYMBOL(app_hub_event_unsubscribe), DEFINE_MODULE_SYMBOL(app_hub_event_unsubscribe),

View File

@ -249,6 +249,28 @@ TEST_CASE("app_hub drops newer events while a subscriber's queue is full") {
task_event_group_destruct(&group); task_event_group_destruct(&group);
} }
TEST_CASE("app_hub_update_all finishes without installs when nothing has an update") {
ensureFixture();
TaskEventGroup group {};
task_event_group_construct(&group);
AppHubEventSubscription sub {};
REQUIRE_EQ(app_hub_event_subscribe(&sub, &group), ERROR_NONE);
uint32_t id = 0;
REQUIRE_EQ(app_hub_update_all(&id), ERROR_NONE);
AppHubEvent event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_UPDATING);
CHECK_EQ(event.operation_id, id);
CHECK_EQ(std::string(event.app_id), "");
event = waitForEvent(sub);
CHECK_EQ(event.type, APP_HUB_EVENT_UPDATED);
CHECK_EQ(event.operation_id, id);
CHECK_EQ(event.result, ERROR_NONE);
CHECK_EQ(app_hub_event_unsubscribe(&sub), ERROR_NONE);
task_event_group_destruct(&group);
}
TEST_CASE("app_hub rejects invalid ids") { TEST_CASE("app_hub rejects invalid ids") {
ensureFixture(); ensureFixture();
CHECK_EQ(app_hub_install("", nullptr), ERROR_INVALID_ARGUMENT); CHECK_EQ(app_hub_install("", nullptr), ERROR_INVALID_ARGUMENT);

View File

@ -17,6 +17,17 @@ std::string writeAppsJson(const char* contents) {
return path; return path;
} }
const std::string CATALOG_PATH = "/tmp/tactility_app_hub_catalog_test.txt";
std::vector<AppHubCatalogEntry> readCatalog() {
std::vector<AppHubCatalogEntry> entries;
REQUIRE(app_hub_catalog_for_each(CATALOG_PATH.c_str(), [](const AppHubCatalogEntry& entry, void* context) {
static_cast<std::vector<AppHubCatalogEntry>*>(context)->push_back(entry);
return true;
}, &entries));
return entries;
}
AppHubCatalogEntry entryWith(std::vector<std::string> requiresDeviceId, int32_t requiresRam) { AppHubCatalogEntry entryWith(std::vector<std::string> requiresDeviceId, int32_t requiresRam) {
AppHubCatalogEntry entry {}; AppHubCatalogEntry entry {};
entry.requires_device_id = std::move(requiresDeviceId); entry.requires_device_id = std::move(requiresDeviceId);
@ -26,47 +37,74 @@ AppHubCatalogEntry entryWith(std::vector<std::string> requiresDeviceId, int32_t
} // namespace } // namespace
TEST_CASE("app hub: requiresDeviceId and requiresRam are parsed, and optional") { TEST_CASE("app hub: converted entries keep all fields") {
const auto path = writeAppsJson(R"({"apps": [ const auto path = writeAppsJson(R"({"apps": [
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app", {"appId": "a", "appVersionName": "1.2", "appVersionCode": 12, "appName": "A", "appDescription": "Tab\there", "targetSdk": "0.8.0", "targetPlatforms": ["esp32", "esp32s3"], "file": "a-1.app",
"requiresDeviceId": ["m5stack-tab5", "lilygo-tdeck"], "requiresRam": 2}, "requiresDeviceId": ["m5stack-tab5", "lilygo-tdeck"], "requiresRam": 2},
{"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"} {"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"}
]})"); ]})");
AppHubCatalog entries; REQUIRE(app_hub_catalog_convert(path.c_str(), CATALOG_PATH.c_str()));
REQUIRE(app_hub_catalog_parse(path.c_str(), entries)); const auto entries = readCatalog();
REQUIRE_EQ(entries.size(), 2u); REQUIRE_EQ(entries.size(), 2u);
CHECK_EQ(entries[0].id, "a");
CHECK_EQ(entries[0].version_name, "1.2");
CHECK_EQ(entries[0].version_code, 12);
CHECK_EQ(entries[0].name, "A");
CHECK_EQ(entries[0].description, "Tab here");
CHECK_EQ(entries[0].target_sdk, "0.8.0");
CHECK_EQ(entries[0].target_platforms, std::vector<std::string> { "esp32", "esp32s3" });
CHECK_EQ(entries[0].file, "a-1.app");
CHECK_EQ(entries[0].requires_device_id, std::vector<std::string> { "m5stack-tab5", "lilygo-tdeck" }); CHECK_EQ(entries[0].requires_device_id, std::vector<std::string> { "m5stack-tab5", "lilygo-tdeck" });
CHECK_EQ(entries[0].requires_ram, 2); CHECK_EQ(entries[0].requires_ram, 2);
CHECK(entries[1].target_platforms.empty());
CHECK(entries[1].requires_device_id.empty()); CHECK(entries[1].requires_device_id.empty());
CHECK_EQ(entries[1].requires_ram, 0); CHECK_EQ(entries[1].requires_ram, 0);
remove(path.c_str()); remove(path.c_str());
remove(CATALOG_PATH.c_str());
} }
TEST_CASE("app hub: reparsing into the same list resets optional requirements") { TEST_CASE("app hub: a catalog entry is found by id") {
AppHubCatalog entries; const auto path = writeAppsJson(R"({"apps": [
const auto restricted = writeAppsJson(R"({"apps": [ {"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app"},
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app",
"requiresDeviceId": ["m5stack-tab5"], "requiresRam": 2}
]})");
REQUIRE(app_hub_catalog_parse(restricted.c_str(), entries));
const auto unrestricted = writeAppsJson(R"({"apps": [
{"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"} {"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"}
]})"); ]})");
REQUIRE(app_hub_catalog_parse(unrestricted.c_str(), entries)); REQUIRE(app_hub_catalog_convert(path.c_str(), CATALOG_PATH.c_str()));
REQUIRE_EQ(entries.size(), 1u); AppHubCatalogEntry entry;
CHECK(entries[0].requires_device_id.empty()); REQUIRE(app_hub_catalog_find(CATALOG_PATH.c_str(), "b", entry));
CHECK_EQ(entries[0].requires_ram, 0); CHECK_EQ(entry.name, "B");
remove(unrestricted.c_str()); CHECK_FALSE(app_hub_catalog_find(CATALOG_PATH.c_str(), "c", entry));
remove(path.c_str());
remove(CATALOG_PATH.c_str());
} }
TEST_CASE("app hub: a fractional requiresRam rejects the entry") { TEST_CASE("app hub: a visitor can stop the visit") {
const auto path = writeAppsJson(R"({"apps": [ const auto path = writeAppsJson(R"({"apps": [
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app"},
{"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"}
]})");
REQUIRE(app_hub_catalog_convert(path.c_str(), CATALOG_PATH.c_str()));
int visited = 0;
CHECK(app_hub_catalog_for_each(CATALOG_PATH.c_str(), [](const AppHubCatalogEntry&, void* context) {
++*static_cast<int*>(context);
return false;
}, &visited));
CHECK_EQ(visited, 1);
remove(path.c_str());
remove(CATALOG_PATH.c_str());
}
TEST_CASE("app hub: a failed conversion leaves no catalog file") {
const auto fractional = writeAppsJson(R"({"apps": [
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app", {"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app",
"requiresRam": 1.5} "requiresRam": 1.5}
]})"); ]})");
AppHubCatalog entries; CHECK_FALSE(app_hub_catalog_convert(fractional.c_str(), CATALOG_PATH.c_str()));
CHECK_FALSE(app_hub_catalog_parse(path.c_str(), entries)); CHECK_EQ(fopen(CATALOG_PATH.c_str(), "rb"), nullptr);
remove(path.c_str()); const auto tooLong = writeAppsJson((R"({"apps": [{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": ")" + std::string(2000, 'x') + R"(", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app"}]})").c_str());
CHECK_FALSE(app_hub_catalog_convert(tooLong.c_str(), CATALOG_PATH.c_str()));
CHECK_EQ(fopen(CATALOG_PATH.c_str(), "rb"), nullptr);
CHECK_FALSE(app_hub_catalog_for_each(CATALOG_PATH.c_str(), [](const AppHubCatalogEntry&, void*) { return true; }, nullptr));
remove(fractional.c_str());
} }
TEST_CASE("app hub: isCompatible applies the device and RAM requirements") { TEST_CASE("app hub: isCompatible applies the device and RAM requirements") {
@ -75,3 +113,12 @@ TEST_CASE("app hub: isCompatible applies the device and RAM requirements") {
CHECK_FALSE(app_hub_catalog_is_compatible(entryWith({ "other-device" }, 0))); CHECK_FALSE(app_hub_catalog_is_compatible(entryWith({ "other-device" }, 0)));
CHECK_FALSE(app_hub_catalog_is_compatible(entryWith({}, 256))); CHECK_FALSE(app_hub_catalog_is_compatible(entryWith({}, 256)));
} }
TEST_CASE("app hub: isCompatible requires this device's platform when the app lists platforms") {
auto entry = entryWith({}, 0);
CHECK(app_hub_catalog_is_compatible(entry));
entry.target_platforms = { "some-other-platform" };
CHECK_FALSE(app_hub_catalog_is_compatible(entry));
entry.target_platforms = { "some-other-platform", app_hub_catalog_get_platform() };
CHECK(app_hub_catalog_is_compatible(entry));
}

View File

@ -17,7 +17,8 @@ struct AllocationState {
}; };
void* stbtt_allocate(size_t size, void* user) { void* stbtt_allocate(size_t size, void* user) {
void* pointer = memory_alloc(size); static constexpr MemoryPolicy policy = { .required = 0, .desired = 0, .alignment = 0};
void* pointer = memory_alloc_with_policy(size, &policy);
if (pointer == nullptr && user != nullptr) { if (pointer == nullptr && user != nullptr) {
static_cast<AllocationState*>(user)->failed = true; static_cast<AllocationState*>(user)->failed = true;
} }

View File

@ -47,7 +47,7 @@ extern const ::AppManifest manifest = {
.category = APP_CATEGORY_SYSTEM, .category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) }, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = APP_MANIFEST_FLAG_HEADLESS, .flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 3072, .desired_memory_capability = 0 }, .stack = { .depth = 4096, .desired_memory_capability = 0 },
}; };
} // namespace coreutils::rm } // namespace coreutils::rm

View File

@ -3,23 +3,34 @@
#include <app/dir.h> #include <app/dir.h>
#include <app/io.h> #include <app/io.h>
#include <tactility/memory.h>
#include <tactility/paths.h> #include <tactility/paths.h>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <memory>
namespace { namespace {
// Reads the calling app instance's own cwd (app/dir.h) - inherited from whichever instance // Reads the calling app instance's own cwd (app/dir.h) - inherited from whichever instance
// started this one (e.g. the interactive shell, after a `cd`), so this always agrees with `pwd`. // started this one (e.g. the interactive shell, after a `cd`), so this always agrees with `pwd`.
// "/" if unset/unavailable (matches this module's old hardcoded default). // "/" if unset/unavailable (matches this module's old hardcoded default).
const char* currentDirectory() { // The buffer is allocated per call, in external memory when available. Apps have small stacks,
static char buffer[FILE_MAX_PATH_STRING_LENGTH]; // and a shared static buffer would be overwritten by other apps that run at the same time.
if (app_dir_get_cwd(buffer, sizeof(buffer)) != ERROR_NONE) { struct MemoryDeleter {
void operator()(char* memory) const { memory_free(memory); }
};
using DirectoryBuffer = std::unique_ptr<char[], MemoryDeleter>;
const char* currentDirectory(DirectoryBuffer& buffer) {
constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 };
buffer.reset(static_cast<char*>(memory_alloc_with_policy(FILE_MAX_PATH_STRING_LENGTH, &policy)));
if (buffer == nullptr || app_dir_get_cwd(buffer.get(), FILE_MAX_PATH_STRING_LENGTH) != ERROR_NONE) {
return "/"; return "/";
} }
return buffer; return buffer.get();
} }
/** /**
@ -88,7 +99,8 @@ void normalizePath(char* path) {
// Module-local equivalent of ShellFs::resolvePath(): resolves against this app instance's own // Module-local equivalent of ShellFs::resolvePath(): resolves against this app instance's own
// cwd (currentDirectory(), above) - inherited from whichever instance started it. // cwd (currentDirectory(), above) - inherited from whichever instance started it.
bool resolvePath(const char* path, char* out, size_t outSize) { bool resolvePath(const char* path, char* out, size_t outSize) {
const char* current = currentDirectory(); DirectoryBuffer currentBuffer;
const char* current = currentDirectory(currentBuffer);
if (path == nullptr || path[0] == '\0') { if (path == nullptr || path[0] == '\0') {
snprintf(out, outSize, "%s", current); snprintf(out, outSize, "%s", current);

View File

@ -15,8 +15,9 @@ if (DEFINED ENV{ESP_IDF_VERSION})
lwip lwip
mbedtls mbedtls
) )
list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_mock\\.cpp$") list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_posix\\.cpp$")
else () else ()
list(APPEND REQUIRES_LIST mbedtls)
list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_esp\\.cpp$") list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_esp\\.cpp$")
endif () endif ()

View File

@ -0,0 +1,27 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <cstddef>
#include <string>
struct HttpUrl {
std::string host;
std::string port;
/** The path and query, "/" when the URL has none */
std::string path;
};
/** Parses an https:// URL. The port is 443 when the URL has none. */
bool http_parse_https_url(const std::string& url, HttpUrl& out);
/** Parses a status line such as "HTTP/1.1 200 OK" */
bool http_parse_status_line(const std::string& line, int& statusCode);
/** Parses a header line such as "Content-Length: 42". The name is returned in lower case, the value without surrounding whitespace. */
bool http_parse_header_line(const std::string& line, std::string& name, std::string& value);
/** Parses a Content-Length value: decimal digits only, without sign or overflow */
bool http_parse_content_length(const std::string& value, long& length);
/** Parses the size line of a chunk in a chunked body, e.g. "1a2b" or "1a2b;name=value" */
bool http_parse_chunk_size(const std::string& line, size_t& size);

View File

@ -1,9 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
#include <http/private/download.h>
HttpDownloadEvent http_download_run(const std::string&, const std::string&, const std::string&, HttpDownloadLink* link) {
if (http_download_is_cancelled(link)) {
return http_download_make_cancelled_event();
}
return http_download_make_error_event("HTTP downloads are not supported on this platform");
}

View File

@ -0,0 +1,404 @@
// SPDX-License-Identifier: Apache-2.0
#include <http/private/download.h>
#include <http/private/http_parsing.h>
#include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h>
#include <psa/crypto.h>
#include <tactility/log.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
namespace {
constexpr auto* TAG = "http-download";
constexpr int TIMEOUT_SECONDS = 5;
constexpr size_t BUFFER_SIZE = 512;
// A write to a closed connection must fail instead of raising SIGPIPE. macOS has no MSG_NOSIGNAL, but SO_NOSIGPIPE on the socket.
#ifdef MSG_NOSIGNAL
constexpr int SEND_FLAGS = MSG_NOSIGNAL;
#else
constexpr int SEND_FLAGS = 0;
#endif
int bio_send(void* context, const unsigned char* data, size_t size) {
const int fd = *static_cast<int*>(context);
const ssize_t sent = send(fd, data, size, SEND_FLAGS);
if (sent >= 0) {
return static_cast<int>(sent);
}
if (errno == EINTR) {
return MBEDTLS_ERR_SSL_WANT_WRITE;
}
return (errno == EAGAIN || errno == EWOULDBLOCK) ? MBEDTLS_ERR_SSL_TIMEOUT : MBEDTLS_ERR_SSL_INTERNAL_ERROR;
}
int bio_receive(void* context, unsigned char* data, size_t size) {
const int fd = *static_cast<int*>(context);
const ssize_t received = recv(fd, data, size, 0);
if (received >= 0) {
return static_cast<int>(received);
}
if (errno == EINTR) {
return MBEDTLS_ERR_SSL_WANT_READ;
}
// The socket's receive timeout elapsed
return (errno == EAGAIN || errno == EWOULDBLOCK) ? MBEDTLS_ERR_SSL_TIMEOUT : MBEDTLS_ERR_SSL_INTERNAL_ERROR;
}
// RAII: the TLS session, the socket and the certificate are released on every exit path
class TlsConnection {
int fd = -1;
bool handshakeDone = false;
mbedtls_x509_crt ca {};
mbedtls_ssl_config config {};
mbedtls_ssl_context ssl {};
// Bytes that readLine() read beyond the line it returned
std::string pending;
public:
TlsConnection() {
mbedtls_x509_crt_init(&ca);
mbedtls_ssl_config_init(&config);
mbedtls_ssl_init(&ssl);
}
~TlsConnection() {
if (handshakeDone) {
mbedtls_ssl_close_notify(&ssl);
}
if (fd >= 0) {
close(fd);
}
mbedtls_ssl_free(&ssl);
mbedtls_ssl_config_free(&config);
mbedtls_x509_crt_free(&ca);
}
bool loadCertificate(const std::string& certPath) {
auto* file = fopen(certPath.c_str(), "rb");
if (file == nullptr) {
return false;
}
fseek(file, 0, SEEK_END);
const long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0) {
fclose(file);
return false;
}
// A PEM certificate is parsed as a NUL-terminated string
std::string certificate(static_cast<size_t>(size) + 1, '\0');
const size_t readBytes = fread(certificate.data(), 1, static_cast<size_t>(size), file);
fclose(file);
if (readBytes != static_cast<size_t>(size)) {
return false;
}
return mbedtls_x509_crt_parse(&ca, reinterpret_cast<const unsigned char*>(certificate.data()), certificate.size()) == 0;
}
bool connectSocket(const HttpUrl& url) {
addrinfo hints {};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
addrinfo* addresses = nullptr;
if (getaddrinfo(url.host.c_str(), url.port.c_str(), &hints, &addresses) != 0) {
LOG_E(TAG, "Failed to resolve %s", url.host.c_str());
return false;
}
const timeval timeout { .tv_sec = TIMEOUT_SECONDS, .tv_usec = 0 };
for (auto* address = addresses; address != nullptr; address = address->ai_next) {
fd = socket(address->ai_family, address->ai_socktype, address->ai_protocol);
if (fd < 0) {
continue;
}
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
#ifdef SO_NOSIGPIPE
const int enabled = 1;
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled));
#endif
int result;
do {
result = ::connect(fd, address->ai_addr, address->ai_addrlen);
} while (result != 0 && errno == EINTR);
if (result == 0) {
break;
}
close(fd);
fd = -1;
}
freeaddrinfo(addresses);
return fd >= 0;
}
bool handshake(const HttpUrl& url, HttpDownloadLink* link) {
if (mbedtls_ssl_config_defaults(&config, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT) != 0) {
return false;
}
mbedtls_ssl_conf_authmode(&config, MBEDTLS_SSL_VERIFY_REQUIRED);
mbedtls_ssl_conf_ca_chain(&config, &ca, nullptr);
// Same as the ESP32 implementation
mbedtls_ssl_conf_max_tls_version(&config, MBEDTLS_SSL_VERSION_TLS1_2);
if (mbedtls_ssl_setup(&ssl, &config) != 0 || mbedtls_ssl_set_hostname(&ssl, url.host.c_str()) != 0) {
return false;
}
mbedtls_ssl_set_bio(&ssl, &fd, bio_send, bio_receive, nullptr);
int result;
while ((result = mbedtls_ssl_handshake(&ssl)) != 0) {
if ((result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) || http_download_is_cancelled(link)) {
LOG_E(TAG, "TLS handshake with %s failed: -0x%04x", url.host.c_str(), static_cast<unsigned>(-result));
return false;
}
}
handshakeDone = true;
return true;
}
bool writeAll(const std::string& data) {
size_t offset = 0;
while (offset < data.size()) {
const int written = mbedtls_ssl_write(&ssl, reinterpret_cast<const unsigned char*>(data.data()) + offset, data.size() - offset);
if (written > 0) {
offset += static_cast<size_t>(written);
} else if (written != MBEDTLS_ERR_SSL_WANT_READ && written != MBEDTLS_ERR_SSL_WANT_WRITE) {
return false;
}
}
return true;
}
/** @return the number of bytes read, 0 when the server closed the connection, or -1 on an error */
int read(char* buffer, size_t size) {
if (!pending.empty()) {
const size_t count = std::min(size, pending.size());
memcpy(buffer, pending.data(), count);
pending.erase(0, count);
return static_cast<int>(count);
}
while (true) {
const int result = mbedtls_ssl_read(&ssl, reinterpret_cast<unsigned char*>(buffer), size);
if (result >= 0) {
return result;
}
if (result == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) {
return 0;
}
if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) {
return -1;
}
}
}
/** Reads a line that ends with CRLF, without the CRLF */
bool readLine(std::string& line) {
constexpr size_t MAX_LINE_LENGTH = 8192;
while (true) {
const auto end = pending.find("\r\n");
if (end != std::string::npos) {
line = pending.substr(0, end);
pending.erase(0, end + 2);
return true;
}
if (pending.size() > MAX_LINE_LENGTH) {
return false;
}
char buffer[BUFFER_SIZE];
const int result = mbedtls_ssl_read(&ssl, reinterpret_cast<unsigned char*>(buffer), sizeof(buffer));
if (result > 0) {
pending.append(buffer, static_cast<size_t>(result));
} else if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) {
return false;
}
}
}
};
// RAII: a file that's removed unless commit() renamed it onto the target path
class TempFile {
std::string tempPath;
FILE* file = nullptr;
public:
explicit TempFile(const std::string& targetPath) : tempPath(targetPath + ".tmp") {
file = fopen(tempPath.c_str(), "wb");
}
~TempFile() {
if (file != nullptr) {
fclose(file);
remove(tempPath.c_str());
}
}
bool isOpen() const { return file != nullptr; }
bool write(const char* data, size_t size) { return fwrite(data, 1, size, file) == size; }
/** Closes the file and replaces the target path with it */
bool commit(const std::string& targetPath) {
const bool closed = fclose(file) == 0;
file = nullptr;
if (!closed || rename(tempPath.c_str(), targetPath.c_str()) != 0) {
remove(tempPath.c_str());
return false;
}
return true;
}
};
}
HttpDownloadEvent http_download_run(const std::string& url, const std::string& certPath, const std::string& targetPath, HttpDownloadLink* link) {
if (http_download_is_cancelled(link)) {
return http_download_make_cancelled_event();
}
HttpUrl parsedUrl;
if (!http_parse_https_url(url, parsedUrl)) {
return http_download_make_error_event("Unsupported URL");
}
if (psa_crypto_init() != PSA_SUCCESS) {
return http_download_make_error_event("Failed to initialize crypto");
}
TlsConnection connection;
if (!connection.loadCertificate(certPath)) {
return http_download_make_error_event("Failed to read certificate file");
}
if (!connection.connectSocket(parsedUrl) || !connection.handshake(parsedUrl, link)) {
return http_download_make_error_event("Failed to open connection");
}
const std::string request =
"GET " + parsedUrl.path + " HTTP/1.1\r\n"
"Host: " + parsedUrl.host + "\r\n"
"User-Agent: Tactility\r\n"
"Accept-Encoding: identity\r\n"
"Connection: close\r\n"
"\r\n";
if (!connection.writeAll(request)) {
return http_download_make_error_event("Failed to send request");
}
std::string line;
int statusCode = 0;
if (!connection.readLine(line) || !http_parse_status_line(line, statusCode)) {
return http_download_make_error_event("Failed to fetch response headers");
}
// -1 means the length is unknown: the body is chunked or ends when the server closes the connection
long contentLength = -1;
std::string contentLengthValue;
bool hasContentLength = false;
bool hasConflictingContentLengths = false;
bool chunked = false;
while (true) {
if (!connection.readLine(line)) {
return http_download_make_error_event("Failed to fetch response headers", statusCode);
}
if (line.empty()) {
break;
}
std::string name;
std::string value;
if (!http_parse_header_line(line, name, value)) {
continue;
}
if (name == "content-length") {
// Repeated headers must have the same value, or the body's end is ambiguous
if (hasContentLength && value != contentLengthValue) {
hasConflictingContentLengths = true;
}
contentLengthValue = value;
hasContentLength = true;
} else if (name == "transfer-encoding" && value.find("chunked") != std::string::npos) {
chunked = true;
}
}
if (statusCode < 200 || statusCode >= 300) {
return http_download_make_error_event("Server response is not OK", statusCode);
}
// A chunked body ignores the Content-Length
if (hasContentLength && !chunked && (hasConflictingContentLengths || !http_parse_content_length(contentLengthValue, contentLength))) {
return http_download_make_error_event("Invalid Content-Length", statusCode);
}
if (http_download_is_cancelled(link)) {
return http_download_make_cancelled_event(statusCode);
}
// Downloaded to a sibling ".tmp" file first and only renamed onto targetPath on success, so a
// failed/cancelled download never leaves a partial file at targetPath.
TempFile file(targetPath);
if (!file.isOpen()) {
return http_download_make_error_event("Failed to open target file", statusCode);
}
char buffer[BUFFER_SIZE];
bool cancelled = false;
// Copies up to `count` body bytes to the file. A count of -1 copies until the server closes the connection.
// @return an error message, or nullptr when the bytes were copied or the download was cancelled
auto copyBody = [&](long count) -> const char* {
while (count != 0) {
if (http_download_is_cancelled(link)) {
cancelled = true;
return nullptr;
}
const size_t wanted = (count < 0) ? sizeof(buffer) : std::min(sizeof(buffer), static_cast<size_t>(count));
const int received = connection.read(buffer, wanted);
if (received < 0) {
return "Failed to read response data";
}
if (received == 0) {
return (count < 0) ? nullptr : "Response body was incomplete";
}
if (!file.write(buffer, static_cast<size_t>(received))) {
return "Failed to write downloaded data";
}
if (count > 0) {
count -= received;
}
}
return nullptr;
};
const char* error = nullptr;
if (chunked) {
while (error == nullptr && !cancelled) {
size_t chunkSize = 0;
if (!connection.readLine(line) || !http_parse_chunk_size(line, chunkSize)) {
error = "Failed to read response data";
} else if (chunkSize == 0) {
// The trailer ends with an empty line
while (connection.readLine(line) && !line.empty()) {}
break;
} else if ((error = copyBody(static_cast<long>(chunkSize))) == nullptr && (!connection.readLine(line) || !line.empty())) {
error = "Failed to read response data";
}
}
} else {
error = copyBody(contentLength);
}
if (cancelled) {
return http_download_make_cancelled_event(statusCode);
}
if (error != nullptr) {
return http_download_make_error_event(error, statusCode);
}
if (!file.commit(targetPath)) {
return http_download_make_error_event("Failed to finalize downloaded file", statusCode);
}
return http_download_make_success_event(statusCode);
}

View File

@ -0,0 +1,145 @@
// SPDX-License-Identifier: Apache-2.0
#include <http/private/http_parsing.h>
#include <cctype>
#include <climits>
#include <cstdlib>
#include <string_view>
namespace {
std::string trim(const std::string& text) {
const auto start = text.find_first_not_of(" \t");
if (start == std::string::npos) {
return "";
}
const auto end = text.find_last_not_of(" \t");
return text.substr(start, end - start + 1);
}
bool is_digits(const std::string& text) {
if (text.empty()) {
return false;
}
for (const char c : text) {
if (!std::isdigit(static_cast<unsigned char>(c))) {
return false;
}
}
return true;
}
}
bool http_parse_https_url(const std::string& url, HttpUrl& out) {
constexpr std::string_view scheme = "https://";
if (url.compare(0, scheme.size(), scheme) != 0) {
return false;
}
const auto authorityStart = scheme.size();
const auto pathStart = url.find_first_of("/?#", authorityStart);
const std::string authority = url.substr(authorityStart, pathStart == std::string::npos ? std::string::npos : pathStart - authorityStart);
if (authority.empty() || authority.find('@') != std::string::npos) {
return false;
}
// An IPv6 address is enclosed in brackets, as it contains colons itself
std::string host;
std::string rest;
if (authority[0] == '[') {
const auto close = authority.find(']');
if (close == std::string::npos) {
return false;
}
host = authority.substr(1, close - 1);
rest = authority.substr(close + 1);
} else {
const auto colon = authority.find(':');
host = authority.substr(0, colon);
rest = colon == std::string::npos ? "" : authority.substr(colon);
}
if (host.empty()) {
return false;
}
std::string port = "443";
if (!rest.empty()) {
if (rest[0] != ':' || !is_digits(rest.substr(1))) {
return false;
}
port = rest.substr(1);
}
std::string path = pathStart == std::string::npos ? "/" : url.substr(pathStart);
// The fragment isn't sent to the server
path = path.substr(0, path.find('#'));
if (path.empty() || path[0] != '/') {
path.insert(0, "/");
}
out = { .host = host, .port = port, .path = path };
return true;
}
bool http_parse_status_line(const std::string& line, int& statusCode) {
if (line.compare(0, 5, "HTTP/") != 0) {
return false;
}
const auto space = line.find(' ');
if (space == std::string::npos) {
return false;
}
const std::string code = line.substr(space + 1, 3);
if (code.size() != 3 || !is_digits(code) || (line.size() > space + 4 && line[space + 4] != ' ')) {
return false;
}
statusCode = std::atoi(code.c_str());
return true;
}
bool http_parse_header_line(const std::string& line, std::string& name, std::string& value) {
const auto colon = line.find(':');
if (colon == std::string::npos || colon == 0) {
return false;
}
name = trim(line.substr(0, colon));
for (auto& c : name) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
value = trim(line.substr(colon + 1));
return !name.empty();
}
bool http_parse_content_length(const std::string& value, long& length) {
if (value.empty() || value.find_first_not_of("0123456789") != std::string::npos) {
return false;
}
long result = 0;
for (const char c : value) {
const long digit = c - '0';
if (result > (LONG_MAX - digit) / 10) {
return false;
}
result = result * 10 + digit;
}
length = result;
return true;
}
bool http_parse_chunk_size(const std::string& line, size_t& size) {
const std::string digits = trim(line.substr(0, line.find(';')));
if (digits.empty() || digits.size() > sizeof(size_t) * 2) {
return false;
}
size_t result = 0;
for (const char c : digits) {
if (!std::isxdigit(static_cast<unsigned char>(c))) {
return false;
}
const char lower = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
result = result * 16 + static_cast<size_t>(lower <= '9' ? lower - '0' : lower - 'a' + 10);
}
size = result;
return true;
}

View File

@ -5,7 +5,7 @@ enable_language(C CXX ASM)
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp) file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
add_executable(HttpModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES}) add_executable(HttpModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
target_include_directories(HttpModuleTests PRIVATE ${DOCTESTINC}) target_include_directories(HttpModuleTests PRIVATE ${DOCTESTINC} ${PROJECT_SOURCE_DIR}/../private)
add_test(NAME HttpModuleTests COMMAND HttpModuleTests) add_test(NAME HttpModuleTests COMMAND HttpModuleTests)

View File

@ -4,7 +4,7 @@
TEST_CASE("http_download_start requires a subscribed subscription") { TEST_CASE("http_download_start requires a subscribed subscription") {
HttpDownloadSubscription sub {}; HttpDownloadSubscription sub {};
CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_INVALID_ARGUMENT); CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_INVALID_ARGUMENT);
} }
TEST_CASE("http_download_poll times out before the download finishes") { TEST_CASE("http_download_poll times out before the download finishes") {
@ -21,13 +21,13 @@ TEST_CASE("http_download_poll times out before the download finishes") {
task_event_group_destruct(&event_group); task_event_group_destruct(&event_group);
} }
TEST_CASE("http_download_start on this (non-ESP-IDF) platform always fails the download") { TEST_CASE("http_download_start fails the download when the certificate can't be read") {
TaskEventGroup event_group {}; TaskEventGroup event_group {};
task_event_group_construct(&event_group); task_event_group_construct(&event_group);
HttpDownloadSubscription sub {}; HttpDownloadSubscription sub {};
CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE);
CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE); CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE);
CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE); CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);
@ -55,7 +55,7 @@ TEST_CASE("http_download_unsubscribe is safe to call right after start, without
HttpDownloadSubscription sub {}; HttpDownloadSubscription sub {};
CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE);
CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE); CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE);
// No cancel, no poll for the terminal event - just unsubscribe and tear down immediately, // No cancel, no poll for the terminal event - just unsubscribe and tear down immediately,
// racing whatever the download task is doing. // racing whatever the download task is doing.
@ -77,7 +77,7 @@ TEST_CASE("http_download_cancel before start makes the download finish as cancel
HttpDownloadSubscription sub {}; HttpDownloadSubscription sub {};
CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE);
CHECK_EQ(http_download_cancel(&sub), ERROR_NONE); CHECK_EQ(http_download_cancel(&sub), ERROR_NONE);
CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE); CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE);
CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE); CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);

View File

@ -0,0 +1,74 @@
#include "doctest.h"
#include <http/private/http_parsing.h>
TEST_CASE("http_parse_https_url splits the host, port and path") {
HttpUrl url;
REQUIRE(http_parse_https_url("https://cdn.example.org/apps/1.0/apps.json?x=1#top", url));
CHECK_EQ(url.host, "cdn.example.org");
CHECK_EQ(url.port, "443");
CHECK_EQ(url.path, "/apps/1.0/apps.json?x=1");
REQUIRE(http_parse_https_url("https://example.org:8443", url));
CHECK_EQ(url.port, "8443");
CHECK_EQ(url.path, "/");
REQUIRE(http_parse_https_url("https://[::1]:9000/file", url));
CHECK_EQ(url.host, "::1");
CHECK_EQ(url.port, "9000");
}
TEST_CASE("http_parse_https_url rejects other schemes and malformed URLs") {
HttpUrl url;
CHECK_FALSE(http_parse_https_url("http://example.org/", url));
CHECK_FALSE(http_parse_https_url("https://", url));
CHECK_FALSE(http_parse_https_url("https://example.org:abc/", url));
CHECK_FALSE(http_parse_https_url("https://user@example.org/", url));
}
TEST_CASE("http_parse_status_line reads the status code") {
int status = 0;
REQUIRE(http_parse_status_line("HTTP/1.1 200 OK", status));
CHECK_EQ(status, 200);
REQUIRE(http_parse_status_line("HTTP/1.1 404", status));
CHECK_EQ(status, 404);
CHECK_FALSE(http_parse_status_line("HTTP/1.1 20 OK", status));
CHECK_FALSE(http_parse_status_line("SSH-2.0 200", status));
}
TEST_CASE("http_parse_header_line lowercases the name and trims the value") {
std::string name;
std::string value;
REQUIRE(http_parse_header_line("Content-Length: 42 ", name, value));
CHECK_EQ(name, "content-length");
CHECK_EQ(value, "42");
CHECK_FALSE(http_parse_header_line("no colon here", name, value));
CHECK_FALSE(http_parse_header_line(": empty name", name, value));
}
TEST_CASE("http_parse_content_length accepts decimal digits only") {
long length = -1;
REQUIRE(http_parse_content_length("42", length));
CHECK_EQ(length, 42);
REQUIRE(http_parse_content_length("0", length));
CHECK_EQ(length, 0);
CHECK_FALSE(http_parse_content_length("", length));
CHECK_FALSE(http_parse_content_length("-5", length));
CHECK_FALSE(http_parse_content_length("+5", length));
CHECK_FALSE(http_parse_content_length("12abc", length));
CHECK_FALSE(http_parse_content_length("1 2", length));
CHECK_FALSE(http_parse_content_length("99999999999999999999999", length));
CHECK_EQ(length, 0);
}
TEST_CASE("http_parse_chunk_size reads hexadecimal sizes and ignores extensions") {
size_t size = 0;
REQUIRE(http_parse_chunk_size("1a2B", size));
CHECK_EQ(size, 0x1a2b);
REQUIRE(http_parse_chunk_size("0", size));
CHECK_EQ(size, 0);
REQUIRE(http_parse_chunk_size("ff;name=value", size));
CHECK_EQ(size, 0xff);
CHECK_FALSE(http_parse_chunk_size("", size));
CHECK_FALSE(http_parse_chunk_size("xyz", size));
}

View File

@ -9,6 +9,8 @@ list(APPEND REQUIRES_LIST
TactilityKernelCpp TactilityKernelCpp
service-module service-module
gps-module gps-module
http-module
cjson-module
minmea minmea
) )

View File

@ -2,6 +2,7 @@
#pragma once #pragma once
#include <stdbool.h> #include <stdbool.h>
#include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include <tactility/concurrent/task_event_group.h> #include <tactility/concurrent/task_event_group.h>
@ -20,6 +21,9 @@ extern "C" {
#define LOCATION_PROVIDER_ID_MAX_LENGTH 15 #define LOCATION_PROVIDER_ID_MAX_LENGTH 15
/** Maximum length of an IANA time zone name from location_lookup_time_zone(), excluding the NUL terminator */
#define LOCATION_TIME_ZONE_MAX_LENGTH 63
/** Horizontal accuracy in meters of a fix with LOCATION_PRECISION_APPROXIMATE */ /** Horizontal accuracy in meters of a fix with LOCATION_PRECISION_APPROXIMATE */
#define LOCATION_APPROXIMATE_ACCURACY_METERS 3000.0f #define LOCATION_APPROXIMATE_ACCURACY_METERS 3000.0f
@ -115,6 +119,18 @@ error_t location_unsubscribe(struct LocationSubscription* sub);
/** @return true when location services are enabled and there is at least one subscription */ /** @return true when location services are enabled and there is at least one subscription */
bool location_is_in_use(void); bool location_is_in_use(void);
/**
* Look up the IANA time zone name (e.g. "Europe/Brussels") of the device's public IP address with json.geoiplookup.io.
* Blocks for up to about 15 seconds, so call it from an app's own task.
* @param[out] out_name the time zone name
* @param[in] out_size the size of @a out_name, at least LOCATION_TIME_ZONE_MAX_LENGTH + 1 for any name
* @retval ERROR_INVALID_STATE location services are disabled
* @retval ERROR_RESOURCE the request failed, e.g. without network
* @retval ERROR_NOT_FOUND json.geoiplookup.io returned no time zone
* @retval ERROR_BUFFER_OVERFLOW the name doesn't fit in @a out_name
*/
error_t location_lookup_time_zone(char* out_name, size_t out_size);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif

View File

@ -6,7 +6,7 @@
#include <stddef.h> #include <stddef.h>
/** /**
* Fallback provider that looks up the location of the public IP address with ip-api.com. * Fallback provider that looks up the location of the public IP address with json.geoiplookup.io.
* The result is reused while the network (local IP address and gateway) is unchanged. * The result is reused while the network (local IP address and gateway) is unchanged.
*/ */
extern const LocationProviderApi ip_location_provider; extern const LocationProviderApi ip_location_provider;
@ -17,25 +17,17 @@ error_t ip_provider_track_network(void);
/** Stop tracking the network and release the cached result. The provider must not run. */ /** Stop tracking the network and release the cached result. The provider must not run. */
void ip_provider_untrack_network(void); void ip_provider_untrack_network(void);
#define IP_API_MESSAGE_MAX_LENGTH 63 struct IpLookupResponse {
struct IpApiResponse {
/** HTTP status code */
int status_code;
/** Requests left in the rate limit window, -1 when unknown */
int rate_remaining;
/** Seconds until the rate limit window resets, -1 when unknown */
int rate_reset_seconds;
bool success; bool success;
/** Reason of failure, empty on success */
char message[IP_API_MESSAGE_MAX_LENGTH + 1];
double latitude; double latitude;
double longitude; double longitude;
/** IANA time zone name, empty when unknown */
char time_zone[LOCATION_TIME_ZONE_MAX_LENGTH + 1];
}; };
/** /**
* Parse an HTTP response of the newline separated ip-api endpoint, requested with fields "status,message,lat,lon" * Parse the JSON response of json.geoiplookup.io
* @param[in] response the NUL-terminated response, including the status line and headers * @param[in] response the NUL-terminated response body
* @return false when the response is malformed * @return false when the response is malformed
*/ */
bool ip_api_parse_response(const char* response, IpApiResponse* out); bool ip_lookup_parse_response(const char* response, IpLookupResponse* out);

View File

@ -1,45 +1,45 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
#include <location/ip_provider.h> #include <location/ip_provider.h>
#include <http/download.h>
#include <tactility/concurrent/task_event_group.h> #include <tactility/concurrent/task_event_group.h>
#include <tactility/concurrent/thread.h> #include <tactility/concurrent/thread.h>
#include <tactility/filesystem/file_system.h>
#include <tactility/filesystem/fs.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <tactility/paths.h>
#include <tactility/system_event.h> #include <tactility/system_event.h>
#include <tactility/time.h> #include <tactility/time.h>
#include <TactilityCpp/Memory.h> #include <TactilityCpp/Memory.h>
#include <TactilityCpp/std/string.h> #include <TactilityCpp/std/string.h>
#include <fcntl.h> #include <cJSON.h>
#include <netdb.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
#include <cerrno>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <cstring> #include <cstring>
#include <string_view> #include <string_view>
#include <strings.h>
#define TAG "location_ip" #define TAG "location_ip"
namespace { namespace {
// The free ip-api endpoint only supports plain HTTP constexpr auto* API_URL = "https://json.geoiplookup.io/";
constexpr auto* API_HOST = "ip-api.com"; // The root certificate of the API's certificate chain
constexpr auto* API_PATH = "/line/?fields=status,message,lat,lon"; constexpr auto* API_CERTIFICATE = "certificates/GTS_Root_R4.pem";
constexpr uint64_t RETRY_INTERVAL_MS = 60 * 1000; constexpr uint64_t RETRY_INTERVAL_MS = 60 * 1000;
// Bounds the request rate when the network connects repeatedly // Bounds the request rate when the network connects repeatedly
constexpr uint64_t MIN_INTERVAL_MS = 10 * 1000; constexpr uint64_t MIN_INTERVAL_MS = 10 * 1000;
// Wake up interval to check whether the fallback became needed // Wake up interval to check whether the fallback became needed
constexpr uint32_t CHECK_INTERVAL_MS = 10 * 1000; constexpr uint32_t CHECK_INTERVAL_MS = 10 * 1000;
constexpr int SOCKET_TIMEOUT_SECONDS = 5; constexpr uint64_t LOOKUP_TIMEOUT_MS = 15 * 1000;
constexpr size_t RESPONSE_MAX_LENGTH = 1024; // Bounds the wait for the download, which doesn't stop when the provider is stopped
constexpr uint32_t LOOKUP_POLL_INTERVAL_MS = 100;
constexpr size_t RESPONSE_MAX_LENGTH = 2048;
// IP geolocation is accurate to roughly city level // IP geolocation is accurate to roughly city level
constexpr float ACCURACY_METERS = 25000.0f; constexpr float ACCURACY_METERS = 25000.0f;
@ -81,96 +81,83 @@ uint64_t getUptimeMs() {
return get_micros_since_boot() / 1000; return get_micros_since_boot() / 1000;
} }
// The FreeRTOS POSIX port's tick signal can interrupt blocking socket calls, so they retry on EINTR bool getCertificatePath(char* out_path, size_t out_size) {
// The system file system is mounted at "/system" on ESP32, but it's a folder elsewhere on the simulator
bool connectWithTimeout(int fd, const sockaddr* address, socklen_t addressLength) { FileSystem* system = file_system_find_by_name("system");
const int flags = fcntl(fd, F_GETFL, 0); char system_path[FILE_MAX_PATH_STRING_LENGTH];
fcntl(fd, F_SETFL, flags | O_NONBLOCK); if (system == nullptr || file_system_get_path(system, system_path, sizeof(system_path)) != ERROR_NONE) {
int result = connect(fd, address, addressLength); return false;
if (result < 0 && errno == EINPROGRESS) {
fd_set writeSet;
do {
FD_ZERO(&writeSet);
FD_SET(fd, &writeSet);
timeval timeout { .tv_sec = SOCKET_TIMEOUT_SECONDS, .tv_usec = 0 };
result = select(fd + 1, nullptr, &writeSet, nullptr, &timeout);
} while (result < 0 && errno == EINTR);
int error = 0;
socklen_t errorLength = sizeof(error);
if (result == 1 && getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &errorLength) == 0 && error == 0) {
result = 0;
} else {
result = -1;
}
} }
fcntl(fd, F_SETFL, flags); const int written = snprintf(out_path, out_size, "%s/%s", system_path, API_CERTIFICATE);
return result == 0; return written > 0 && static_cast<size_t>(written) < out_size;
} }
bool sendAll(int fd, const char* data, size_t length) { /**
size_t sent = 0; * Downloads the lookup result into a temp file named @a file_name and parses it
while (sent < length) { * @param[in] group the event group to wait on, with a free bit for the download
const ssize_t result = send(fd, data + sent, length - sent, 0); * @param[in] running the lookup is cancelled when this becomes false
if (result < 0) { * @return false when the lookup failed or was cancelled
if (errno == EINTR) { */
continue; bool fetchLookup(const char* file_name, TaskEventGroup* group, const std::atomic<bool>& running, IpLookupResponse* out) {
} char certificate_path[FILE_MAX_PATH_STRING_LENGTH];
return false; char temp_path[FILE_MAX_PATH_STRING_LENGTH];
} char target_path[FILE_MAX_PATH_STRING_LENGTH];
sent += static_cast<size_t>(result); if (!getCertificatePath(certificate_path, sizeof(certificate_path)) || paths_get_temp_path(temp_path, sizeof(temp_path)) != ERROR_NONE) {
return false;
} }
return true; if (!directory_exists(temp_path)) {
} const error_t error = directory_make(temp_path, true);
if (error != ERROR_NONE && error != ERROR_ALREADY_EXISTS) {
/** Receive until the server closes the connection */
bool receiveAll(int fd, char* buffer, size_t bufferSize) {
size_t received = 0;
while (received < bufferSize - 1) {
const ssize_t result = recv(fd, buffer + received, bufferSize - 1 - received, 0);
if (result < 0) {
if (errno == EINTR) {
continue;
}
return false; return false;
} }
if (result == 0) { }
const int written = snprintf(target_path, sizeof(target_path), "%s/%s", temp_path, file_name);
if (written <= 0 || static_cast<size_t>(written) >= sizeof(target_path)) {
return false;
}
HttpDownloadSubscription subscription {};
if (http_download_subscribe(&subscription, group) != ERROR_NONE) {
return false;
}
if (http_download_start(API_URL, certificate_path, target_path, &subscription) != ERROR_NONE) {
http_download_unsubscribe(&subscription);
return false;
}
const uint64_t deadline = getUptimeMs() + LOOKUP_TIMEOUT_MS;
HttpDownloadEvent event {};
bool finished = false;
while (running && getUptimeMs() < deadline) {
if (http_download_poll(&subscription, &event) == ERROR_NONE) {
finished = true;
break; break;
} }
received += static_cast<size_t>(result); task_event_group_wait_any(group, nullptr, millis_to_ticks(LOOKUP_POLL_INTERVAL_MS));
} }
buffer[received] = '\0'; if (!finished) {
return received > 0; http_download_cancel(&subscription);
} }
http_download_unsubscribe(&subscription);
bool httpGet(const char* host, const char* path, char* response, size_t responseSize) { if (!finished || event.type != HTTP_DOWNLOAD_EVENT_SUCCESS) {
addrinfo hints {}; if (finished && event.type == HTTP_DOWNLOAD_EVENT_ERROR) {
hints.ai_family = AF_INET; LOG_W(TAG, "Request failed: %s (HTTP %d)", event.error.message, static_cast<int>(event.status_code));
hints.ai_socktype = SOCK_STREAM; }
addrinfo* addresses = nullptr;
if (getaddrinfo(host, "80", &hints, &addresses) != 0 || addresses == nullptr) {
return false; return false;
} }
const int fd = socket(addresses->ai_family, addresses->ai_socktype, addresses->ai_protocol); tt::std::string response(RESPONSE_MAX_LENGTH, '\0');
if (fd < 0) { size_t size = response.size() - 1;
freeaddrinfo(addresses); const error_t error = file_read_binary(target_path, reinterpret_cast<uint8_t*>(response.data()), &size);
remove(target_path);
if (error != ERROR_NONE) {
return false; return false;
} }
response[size] = '\0';
timeval timeout { .tv_sec = SOCKET_TIMEOUT_SECONDS, .tv_usec = 0 }; if (!ip_lookup_parse_response(response.c_str(), out)) {
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); LOG_W(TAG, "Malformed response");
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); return false;
}
char request[160]; return true;
const int requestLength = snprintf(request, sizeof(request), "GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n", path, host);
const bool success = connectWithTimeout(fd, addresses->ai_addr, addresses->ai_addrlen) &&
sendAll(fd, request, static_cast<size_t>(requestLength)) &&
receiveAll(fd, response, responseSize);
close(fd);
freeaddrinfo(addresses);
return success;
} }
void report(IpProvider& provider, double latitude, double longitude) { void report(IpProvider& provider, double latitude, double longitude) {
@ -198,39 +185,23 @@ void report(IpProvider& provider, double latitude, double longitude) {
/** @return the delay until the next lookup is allowed */ /** @return the delay until the next lookup is allowed */
uint64_t lookup(IpProvider& provider, uint64_t network) { uint64_t lookup(IpProvider& provider, uint64_t network) {
tt::std::string response(RESPONSE_MAX_LENGTH, '\0'); IpLookupResponse parsed;
if (!httpGet(API_HOST, API_PATH, response.data(), response.size())) { if (!fetchLookup("location_ip.json", &provider.group, provider.running, &parsed)) {
LOG_W(TAG, "Request failed");
return RETRY_INTERVAL_MS; return RETRY_INTERVAL_MS;
} }
if (!parsed.success) {
IpApiResponse parsed; LOG_W(TAG, "Lookup failed");
if (!ip_api_parse_response(response.c_str(), &parsed)) {
LOG_W(TAG, "Malformed response");
return RETRY_INTERVAL_MS; return RETRY_INTERVAL_MS;
} }
if (cache == nullptr) {
uint64_t delay = RETRY_INTERVAL_MS; cache = tt::newObject<CachedLookup>();
if (parsed.status_code != 200) {
LOG_W(TAG, "HTTP status %d", parsed.status_code);
} else if (!parsed.success) {
LOG_W(TAG, "Lookup failed: %s", parsed.message);
} else {
if (cache == nullptr) {
cache = tt::newObject<CachedLookup>();
}
if (cache != nullptr) {
*cache = { .network = network, .latitude = parsed.latitude, .longitude = parsed.longitude };
}
report(provider, parsed.latitude, parsed.longitude);
LOG_D(TAG, "Lookup succeeded");
delay = MIN_INTERVAL_MS;
} }
if (cache != nullptr) {
if (parsed.rate_remaining == 0 && parsed.rate_reset_seconds > 0) { *cache = { .network = network, .latitude = parsed.latitude, .longitude = parsed.longitude };
delay = std::max(delay, static_cast<uint64_t>(parsed.rate_reset_seconds) * 1000);
} }
return delay; report(provider, parsed.latitude, parsed.longitude);
LOG_D(TAG, "Lookup succeeded");
return MIN_INTERVAL_MS;
} }
int32_t threadMain(void* context) { int32_t threadMain(void* context) {
@ -337,22 +308,10 @@ bool waitStopped(void* context, TickType_t timeout) {
return true; return true;
} }
std::string_view nextLine(std::string_view& text) { /** The characters of IANA time zone names, e.g. "America/Port-au-Prince" or "Etc/GMT+5" */
const size_t end = text.find('\n'); bool isTimeZoneCharacter(char character) {
std::string_view line = text.substr(0, end); return (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') ||
text = end == std::string_view::npos ? std::string_view {} : text.substr(end + 1); character == '_' || character == '+' || character == '-' || character == '/';
if (!line.empty() && line.back() == '\r') {
line.remove_suffix(1);
}
return line;
}
int parseHeaderNumber(std::string_view line, const char* name) {
const size_t nameLength = strlen(name);
if (line.size() <= nameLength || line[nameLength] != ':' || strncasecmp(line.data(), name, nameLength) != 0) {
return -1;
}
return atoi(tt::std::string(line.substr(nameLength + 1)).c_str());
} }
} // namespace } // namespace
@ -369,73 +328,67 @@ void ip_provider_untrack_network(void) {
} }
} }
bool ip_api_parse_response(const char* response, IpApiResponse* out) { error_t location_lookup_time_zone(char* out_name, size_t out_size) {
*out = IpApiResponse { if (!location_is_enabled()) {
.status_code = 0, return ERROR_INVALID_STATE;
.rate_remaining = -1, }
.rate_reset_seconds = -1, TaskEventGroup group {};
task_event_group_construct(&group);
const std::atomic<bool> running = true;
IpLookupResponse parsed;
const bool fetched = fetchLookup("location_time_zone.json", &group, running, &parsed);
task_event_group_destruct(&group);
if (!fetched || !parsed.success) {
return ERROR_RESOURCE;
}
const size_t length = strlen(parsed.time_zone);
if (length == 0) {
return ERROR_NOT_FOUND;
}
if (length >= out_size) {
return ERROR_BUFFER_OVERFLOW;
}
memcpy(out_name, parsed.time_zone, length + 1);
return ERROR_NONE;
}
bool ip_lookup_parse_response(const char* response, IpLookupResponse* out) {
*out = IpLookupResponse {
.success = false, .success = false,
.message = {},
.latitude = NAN, .latitude = NAN,
.longitude = NAN, .longitude = NAN,
.time_zone = {},
}; };
std::string_view text = response; cJSON* json = cJSON_Parse(response);
const std::string_view statusLine = nextLine(text); if (!cJSON_IsObject(json)) {
if (!statusLine.starts_with("HTTP/1.")) { cJSON_Delete(json);
return false; return false;
} }
const size_t space = statusLine.find(' '); bool valid = false;
if (space == std::string_view::npos) { const cJSON* success = cJSON_GetObjectItemCaseSensitive(json, "success");
return false; if (cJSON_IsFalse(success)) {
} valid = true;
out->status_code = atoi(tt::std::string(statusLine.substr(space + 1)).c_str()); } else if (cJSON_IsTrue(success)) {
const cJSON* latitude = cJSON_GetObjectItemCaseSensitive(json, "latitude");
while (true) { const cJSON* longitude = cJSON_GetObjectItemCaseSensitive(json, "longitude");
if (text.empty()) { const cJSON* timeZone = cJSON_GetObjectItemCaseSensitive(json, "timezone_name");
return false; if (cJSON_IsNumber(latitude) && cJSON_IsNumber(longitude) &&
} std::isfinite(latitude->valuedouble) && std::isfinite(longitude->valuedouble) &&
const std::string_view header = nextLine(text); std::fabs(latitude->valuedouble) <= 90.0 && std::fabs(longitude->valuedouble) <= 180.0) {
if (header.empty()) { const std::string_view name = cJSON_IsString(timeZone) ? timeZone->valuestring : "";
break; if (name.size() <= LOCATION_TIME_ZONE_MAX_LENGTH && std::ranges::all_of(name, isTimeZoneCharacter)) {
} out->latitude = latitude->valuedouble;
const int remaining = parseHeaderNumber(header, "X-Rl"); out->longitude = longitude->valuedouble;
if (remaining >= 0) { memcpy(out->time_zone, name.data(), name.size());
out->rate_remaining = remaining; out->time_zone[name.size()] = '\0';
} out->success = true;
const int resetSeconds = parseHeaderNumber(header, "X-Ttl"); valid = true;
if (resetSeconds >= 0) { }
out->rate_reset_seconds = resetSeconds;
} }
} }
cJSON_Delete(json);
if (out->status_code != 200) { return valid;
return true;
}
// Fields are returned one per line, in the order of the API documentation
const std::string_view status = nextLine(text);
if (status == "success") {
const tt::std::string latitude(nextLine(text));
const tt::std::string longitude(nextLine(text));
char* latitudeEnd;
char* longitudeEnd;
out->latitude = strtod(latitude.c_str(), &latitudeEnd);
out->longitude = strtod(longitude.c_str(), &longitudeEnd);
if (latitude.empty() || *latitudeEnd != '\0' || longitude.empty() || *longitudeEnd != '\0' ||
!std::isfinite(out->latitude) || !std::isfinite(out->longitude) ||
std::fabs(out->latitude) > 90.0 || std::fabs(out->longitude) > 180.0) {
return false;
}
out->success = true;
} else if (status == "fail") {
const std::string_view message = nextLine(text).substr(0, IP_API_MESSAGE_MAX_LENGTH);
memcpy(out->message, message.data(), message.size());
out->message[message.size()] = '\0';
} else {
return false;
}
return true;
} }
const LocationProviderApi ip_location_provider = { const LocationProviderApi ip_location_provider = {

View File

@ -51,6 +51,7 @@ static const ModuleSymbol location_module_symbols[] = {
DEFINE_MODULE_SYMBOL(location_subscribe), DEFINE_MODULE_SYMBOL(location_subscribe),
DEFINE_MODULE_SYMBOL(location_unsubscribe), DEFINE_MODULE_SYMBOL(location_unsubscribe),
DEFINE_MODULE_SYMBOL(location_is_in_use), DEFINE_MODULE_SYMBOL(location_is_in_use),
DEFINE_MODULE_SYMBOL(location_lookup_time_zone),
MODULE_SYMBOL_TERMINATOR MODULE_SYMBOL_TERMINATOR
}; };

View File

@ -3,55 +3,46 @@
#include <location/ip_provider.h> #include <location/ip_provider.h>
#include <cstring> #include <cstring>
#include <string>
TEST_CASE("ip_api_parse_response parses a successful lookup") { TEST_CASE("ip_lookup_parse_response parses a successful lookup") {
const char* response = const char* response = R"({"ip":"192.0.2.1","latitude":52.3676,"longitude":4.9041,"city":"Amsterdam","timezone_name":"Europe/Amsterdam","success":true,"premium":false})";
"HTTP/1.1 200 OK\r\n" IpLookupResponse parsed;
"Content-Type: text/plain; charset=utf-8\r\n" REQUIRE(ip_lookup_parse_response(response, &parsed));
"X-Ttl: 60\r\n"
"X-Rl: 44\r\n"
"\r\n"
"success\n"
"39.03\n"
"-77.5\n";
IpApiResponse parsed;
REQUIRE(ip_api_parse_response(response, &parsed));
CHECK(parsed.status_code == 200);
CHECK(parsed.success); CHECK(parsed.success);
CHECK(parsed.latitude == doctest::Approx(39.03)); CHECK(parsed.latitude == doctest::Approx(52.3676));
CHECK(parsed.longitude == doctest::Approx(-77.5)); CHECK(parsed.longitude == doctest::Approx(4.9041));
CHECK(parsed.rate_remaining == 44); CHECK(strcmp(parsed.time_zone, "Europe/Amsterdam") == 0);
CHECK(parsed.rate_reset_seconds == 60);
} }
TEST_CASE("ip_api_parse_response parses a failed lookup") { TEST_CASE("ip_lookup_parse_response accepts a lookup without a time zone") {
const char* response = "HTTP/1.1 200 OK\r\nx-rl: 0\r\nx-ttl: 12\r\n\r\nfail\nprivate range\n"; IpLookupResponse parsed;
IpApiResponse parsed; REQUIRE(ip_lookup_parse_response(R"({"latitude":-33.9,"longitude":151.2,"success":true})", &parsed));
REQUIRE(ip_api_parse_response(response, &parsed)); CHECK(parsed.success);
CHECK(parsed.time_zone[0] == '\0');
}
TEST_CASE("ip_lookup_parse_response rejects an invalid time zone") {
IpLookupResponse parsed;
CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":52.37,"longitude":4.89,"timezone_name":"../etc/passwd x","success":true})", &parsed));
const std::string tooLong = R"({"latitude":52.37,"longitude":4.89,"timezone_name":")" + std::string(LOCATION_TIME_ZONE_MAX_LENGTH + 1, 'a') + R"(","success":true})";
CHECK_FALSE(ip_lookup_parse_response(tooLong.c_str(), &parsed));
}
TEST_CASE("ip_lookup_parse_response parses a failed lookup") {
IpLookupResponse parsed;
REQUIRE(ip_lookup_parse_response(R"({"success":false})", &parsed));
CHECK_FALSE(parsed.success); CHECK_FALSE(parsed.success);
CHECK(strcmp(parsed.message, "private range") == 0);
CHECK(parsed.rate_remaining == 0);
CHECK(parsed.rate_reset_seconds == 12);
} }
TEST_CASE("ip_api_parse_response handles a throttled request") { TEST_CASE("ip_lookup_parse_response rejects malformed responses") {
const char* response = "HTTP/1.1 429 Too Many Requests\r\nX-Rl: 0\r\nX-Ttl: 30\r\n\r\n"; IpLookupResponse parsed;
IpApiResponse parsed; CHECK_FALSE(ip_lookup_parse_response("", &parsed));
REQUIRE(ip_api_parse_response(response, &parsed)); CHECK_FALSE(ip_lookup_parse_response("garbage", &parsed));
CHECK(parsed.status_code == 429); CHECK_FALSE(ip_lookup_parse_response("[]", &parsed));
CHECK_FALSE(parsed.success); CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":52.37,"longitude":4.89})", &parsed));
CHECK(parsed.rate_reset_seconds == 30); CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":"52.37","longitude":4.89,"success":true})", &parsed));
} CHECK_FALSE(ip_lookup_parse_response(R"({"longitude":4.89,"success":true})", &parsed));
CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":90.5,"longitude":1.0,"success":true})", &parsed));
TEST_CASE("ip_api_parse_response rejects malformed responses") { CHECK_FALSE(ip_lookup_parse_response(R"({"latitude":1.0,"longitude":-180.5,"success":true})", &parsed));
IpApiResponse parsed;
CHECK_FALSE(ip_api_parse_response("", &parsed));
CHECK_FALSE(ip_api_parse_response("garbage", &parsed));
CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\nX-Rl: 1\r\n", &parsed));
CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\nabc\n1.0\n", &parsed));
CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nunknown\n", &parsed));
CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\nnan\n1.0\n", &parsed));
CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\n1.0\ninf\n", &parsed));
CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\n90.5\n1.0\n", &parsed));
CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\n1.0\n-180.5\n", &parsed));
} }

View File

@ -48,6 +48,7 @@ file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
list(APPEND REQUIRES_LIST list(APPEND REQUIRES_LIST
TactilityKernel TactilityKernel
TactilityKernelCpp
binfont-module binfont-module
lodepng-module lodepng-module
lvgl lvgl

View File

@ -153,12 +153,13 @@ shared_symbol_code_point_names = [
"help", # Diceware help "help", # Diceware help
"hub", # App Hub "hub", # App Hub
"info", # System Info "info", # System Info
"image", # Screenshot app "image", # Files app
"keyboard_arrow_up", "keyboard_arrow_up",
"lightbulb", "lightbulb",
"language", # Globe "language", # Globe
"lists", # Chat app toolbar "lists", # Chat app toolbar
"list_alt", # Settings apps: opens a selection dialog "list_alt", # Settings apps: opens a selection dialog
"hourglass", # App Hub: an operation for the app is pending
"mail", "mail",
"menu", "menu",
"memory", # System Info "memory", # System Info

View File

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

View File

@ -32,6 +32,7 @@
#define LVGL_ICON_SHARED_LANGUAGE "\xEE\xA8\x87" #define LVGL_ICON_SHARED_LANGUAGE "\xEE\xA8\x87"
#define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9" #define LVGL_ICON_SHARED_LISTS "\xEE\xA6\xB9"
#define LVGL_ICON_SHARED_LIST_ALT "\xEE\x83\xAE" #define LVGL_ICON_SHARED_LIST_ALT "\xEE\x83\xAE"
#define LVGL_ICON_SHARED_HOURGLASS "\xEE\xAF\xBF"
#define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99" #define LVGL_ICON_SHARED_MAIL "\xEE\x85\x99"
#define LVGL_ICON_SHARED_MENU "\xEE\x97\x92" #define LVGL_ICON_SHARED_MENU "\xEE\x97\x92"
#define LVGL_ICON_SHARED_MEMORY "\xEE\x8C\xA2" #define LVGL_ICON_SHARED_MEMORY "\xEE\x8C\xA2"

View File

@ -0,0 +1,36 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Creates a step indicator: a horizontal row of line segments, one per step, that shows the progress through a number of steps.
* The segments share the indicator's width. The theme determines their thickness, the gap between them, and the colours of finished and unfinished steps.
* @param[in] parent the parent object
* @return the created step indicator
*/
lv_obj_t* lvgl_step_indicator_create(lv_obj_t* parent);
/**
* @brief Sets the number of steps, which is the number of segments.
* @param[in] obj the step indicator
* @param[in] count the number of steps
*/
void lvgl_step_indicator_set_step_count(lv_obj_t* obj, uint32_t count);
/**
* @brief Sets how many steps are finished: the segments of the first @p finished_count steps show as finished.
* @param[in] obj the step indicator
* @param[in] finished_count the number of finished steps
*/
void lvgl_step_indicator_set_finished_count(lv_obj_t* obj, uint32_t finished_count);
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,20 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Creates a button without a container, for low-emphasis actions such as "Skip" or "Back".
* It behaves like a regular button, but the theme styles it as a text button.
* @param[in] parent the parent object for the new button
* @return the created button
*/
lv_obj_t* lvgl_text_button_create(lv_obj_t* parent);
#ifdef __cplusplus
}
#endif

View File

@ -4,6 +4,7 @@
#include <lvgl/devices/pointer.h> #include <lvgl/devices/pointer.h>
#include <lvgl/devices/trackball.h> #include <lvgl/devices/trackball.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <tactility/check.h>
#include <tactility/device.h> #include <tactility/device.h>
#include <tactility/drivers/display.h> #include <tactility/drivers/display.h>
#include <tactility/drivers/keyboard.h> #include <tactility/drivers/keyboard.h>
@ -11,6 +12,8 @@
#include <tactility/drivers/trackball.h> #include <tactility/drivers/trackball.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <TactilityCpp/Memory.h>
#include <lvgl.h> #include <lvgl.h>
#include <lvgl/theme_private.h> #include <lvgl/theme_private.h>
@ -55,7 +58,16 @@ struct IdleRepaintContext {
// One slot per display bound in lvgl_devices_attach()'s loop below that turns out to be // One slot per display bound in lvgl_devices_attach()'s loop below that turns out to be
// DISPLAY_CAPABILITY_SLOW_REFRESH; a board with multiple such displays needs independent idle // DISPLAY_CAPABILITY_SLOW_REFRESH; a board with multiple such displays needs independent idle
// tracking for each; slots are 1:1 with idle_repaint_contexts' index, not a compacted list. // tracking for each; slots are 1:1 with idle_repaint_contexts' index, not a compacted list.
static IdleRepaintContext idle_repaint_contexts[LVGL_DEVICES_MAX_PER_TYPE] = {}; struct IdleRepaintContexts {
IdleRepaintContext slots[LVGL_DEVICES_MAX_PER_TYPE] = {};
};
// Allocated once in external memory when available
static IdleRepaintContext* idle_repaint_contexts() {
static IdleRepaintContexts* instance = tt::newObject<IdleRepaintContexts>();
check(instance != nullptr);
return instance->slots;
}
extern "C" { extern "C" {
@ -144,7 +156,7 @@ void lvgl_devices_attach() {
// cases without extra plumbing at app-close time specifically. // cases without extra plumbing at app-close time specifically.
if (display_has_capability(kernel_display_device, DISPLAY_CAPABILITY_SLOW_REFRESH)) { if (display_has_capability(kernel_display_device, DISPLAY_CAPABILITY_SLOW_REFRESH)) {
display_clear(kernel_display_device); display_clear(kernel_display_device);
IdleRepaintContext* ctx = &idle_repaint_contexts[i]; IdleRepaintContext* ctx = &idle_repaint_contexts()[i];
if (ctx->timer == nullptr) { if (ctx->timer == nullptr) {
ctx->lvgl_display = added_display; ctx->lvgl_display = added_display;
ctx->kernel_display_device = kernel_display_device; ctx->kernel_display_device = kernel_display_device;
@ -257,7 +269,7 @@ void lvgl_devices_detach() {
} }
for (size_t i = 0; i < LVGL_DEVICES_MAX_PER_TYPE; i++) { for (size_t i = 0; i < LVGL_DEVICES_MAX_PER_TYPE; i++) {
IdleRepaintContext* ctx = &idle_repaint_contexts[i]; IdleRepaintContext* ctx = &idle_repaint_contexts()[i];
if (ctx->timer != nullptr) { if (ctx->timer != nullptr) {
lv_timer_delete(ctx->timer); lv_timer_delete(ctx->timer);
*ctx = {}; *ctx = {};

View File

@ -34,6 +34,7 @@ const char* const lvgl_icon_shared_names[] = {
"language", "language",
"lists", "lists",
"list_alt", "list_alt",
"hourglass",
"mail", "mail",
"menu", "menu",
"memory", "memory",

View File

@ -22,8 +22,10 @@
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/list.h> #include <lvgl/widgets/list.h>
#include <lvgl/widgets/page_indicator.h> #include <lvgl/widgets/page_indicator.h>
#include <lvgl/widgets/step_indicator.h>
#include <lvgl/widgets/spinner.h> #include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/statusbar.h> #include <lvgl/widgets/statusbar.h>
#include <lvgl/widgets/text_button.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <tactility/module.h> #include <tactility/module.h>
@ -129,6 +131,10 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create),
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page_count), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page_count),
DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page),
// lvgl_step_indicator
DEFINE_MODULE_SYMBOL(lvgl_step_indicator_create),
DEFINE_MODULE_SYMBOL(lvgl_step_indicator_set_step_count),
DEFINE_MODULE_SYMBOL(lvgl_step_indicator_set_finished_count),
// lvgl_card // lvgl_card
DEFINE_MODULE_SYMBOL(lvgl_card_create), DEFINE_MODULE_SYMBOL(lvgl_card_create),
// lvgl_chip // lvgl_chip
@ -136,6 +142,8 @@ const struct ModuleSymbol lvgl_module_symbols[] = {
// lvgl_icon_button // lvgl_icon_button
DEFINE_MODULE_SYMBOL(lvgl_icon_button_create), DEFINE_MODULE_SYMBOL(lvgl_icon_button_create),
DEFINE_MODULE_SYMBOL(lvgl_icon_button_create_variant), DEFINE_MODULE_SYMBOL(lvgl_icon_button_create_variant),
// lvgl_text_button
DEFINE_MODULE_SYMBOL(lvgl_text_button_create),
// lvgl_list // lvgl_list
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),

View File

@ -19,9 +19,12 @@ extern const lv_obj_class_t lvgl_chip_class;
extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_badge_class;
extern const lv_obj_class_t lvgl_page_indicator_class; extern const lv_obj_class_t lvgl_page_indicator_class;
extern const lv_obj_class_t lvgl_page_indicator_dot_class; extern const lv_obj_class_t lvgl_page_indicator_dot_class;
extern const lv_obj_class_t lvgl_step_indicator_class;
extern const lv_obj_class_t lvgl_step_indicator_segment_class;
extern const lv_obj_class_t lvgl_list_class; extern const lv_obj_class_t lvgl_list_class;
extern const lv_obj_class_t lvgl_list_text_class; extern const lv_obj_class_t lvgl_list_text_class;
extern const lv_obj_class_t lvgl_list_button_class; extern const lv_obj_class_t lvgl_list_button_class;
extern const lv_obj_class_t lvgl_text_button_class;
#define NEUTRAL_4 lv_color_hex(0x0E0E0E) #define NEUTRAL_4 lv_color_hex(0x0E0E0E)
#define NEUTRAL_6 lv_color_hex(0x131313) #define NEUTRAL_6 lv_color_hex(0x131313)
@ -149,6 +152,11 @@ extern const lv_obj_class_t lvgl_list_button_class;
#define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE_VARIANT #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE_VARIANT
#define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8)) #define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8))
#define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define STEP_INDICATOR_FINISHED_COLOR COLOR_SECONDARY
#define STEP_INDICATOR_UNFINISHED_COLOR (lv_color_mix(lv_color_black(), COLOR_SECONDARY, LV_OPA_50))
#define STEP_INDICATOR_THICKNESS (LV_DPX_CALC(theme->disp_dpi, 6))
#define STEP_INDICATOR_MARGIN (LV_DPX_CALC(theme->disp_dpi, 6))
#define STEP_INDICATOR_GAP STEP_INDICATOR_MARGIN
#define BADGE_COLOR COLOR_SECONDARY #define BADGE_COLOR COLOR_SECONDARY
#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define SCROLLBAR_COLOR COLOR_OUTLINE #define SCROLLBAR_COLOR COLOR_OUTLINE
@ -265,7 +273,7 @@ extern const lv_obj_class_t lvgl_list_button_class;
#define KEY_CHECKED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER #define KEY_CHECKED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER
#define KEY_SELECTED_COLOR COLOR_PRIMARY #define KEY_SELECTED_COLOR COLOR_PRIMARY
#define KEY_SELECTED_TEXT_COLOR COLOR_ON_PRIMARY #define KEY_SELECTED_TEXT_COLOR COLOR_ON_PRIMARY
#define LIST_COLOR COLOR_SURFACE #define LIST_COLOR CARD_COLOR
#define LIST_PAD DENSITY_LIST_PAD #define LIST_PAD DENSITY_LIST_PAD
#define LIST_ITEM_COLOR COLOR_SURFACE #define LIST_ITEM_COLOR COLOR_SURFACE
#define LIST_ITEM_OPACITY LV_OPA_TRANSP #define LIST_ITEM_OPACITY LV_OPA_TRANSP
@ -275,7 +283,9 @@ extern const lv_obj_class_t lvgl_list_button_class;
#define LIST_ITEM_GAP (theme->config.is_compact ? 3 : SPACE_4) #define LIST_ITEM_GAP (theme->config.is_compact ? 3 : SPACE_4)
#define LIST_ITEM_FOCUSED_COLOR COLOR_SECONDARY_CONTAINER #define LIST_ITEM_FOCUSED_COLOR COLOR_SECONDARY_CONTAINER
#define LIST_ITEM_FOCUSED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER #define LIST_ITEM_FOCUSED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER
#define LIST_DIVIDER_COLOR COLOR_OUTLINE_VARIANT #define LIST_ITEM_SELECTED_COLOR COLOR_SECONDARY_CONTAINER
#define LIST_ITEM_SELECTED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER
#define LIST_DIVIDER_COLOR (lv_color_mix(COLOR_SECONDARY, LIST_COLOR, 89))
#define LIST_DIVIDER_WIDTH DENSITY_BORDER #define LIST_DIVIDER_WIDTH DENSITY_BORDER
#define LIST_SUBHEADER_TEXT_COLOR COLOR_ON_SURFACE_VARIANT #define LIST_SUBHEADER_TEXT_COLOR COLOR_ON_SURFACE_VARIANT
#define TABS_COLOR CARD_COLOR #define TABS_COLOR CARD_COLOR
@ -356,6 +366,9 @@ typedef struct {
#endif #endif
lv_style_t page_indicator_dot; lv_style_t page_indicator_dot;
lv_style_t page_indicator_dot_current; lv_style_t page_indicator_dot_current;
lv_style_t step_indicator;
lv_style_t step_indicator_segment;
lv_style_t step_indicator_segment_finished;
lv_style_t badge; lv_style_t badge;
lv_style_t chip; lv_style_t chip;
lv_style_t chip_selected; lv_style_t chip_selected;
@ -372,12 +385,9 @@ typedef struct {
lv_style_t button_checked_pressed; lv_style_t button_checked_pressed;
#endif #endif
lv_style_t button_disabled; lv_style_t button_disabled;
#if LV_USE_MSGBOX
lv_style_t text_button; lv_style_t text_button;
#endif
#if LV_USE_MSGBOX
lv_style_t text_button_pressed; lv_style_t text_button_pressed;
#endif lv_style_t text_button_margin;
lv_style_t icon_button; lv_style_t icon_button;
lv_style_t icon_button_selected; lv_style_t icon_button_selected;
lv_style_t icon_button_filled; lv_style_t icon_button_filled;
@ -502,6 +512,7 @@ typedef struct {
lv_style_t list; lv_style_t list;
lv_style_t list_item; lv_style_t list_item;
lv_style_t list_item_focused; lv_style_t list_item_focused;
lv_style_t list_item_selected;
lv_style_t list_subheader; lv_style_t list_subheader;
#if LV_USE_TABVIEW #if LV_USE_TABVIEW
lv_style_t tabs; lv_style_t tabs;
@ -800,6 +811,22 @@ static void style_init(my_theme_t * theme)
style_init_reset(&theme->styles.page_indicator_dot_current); style_init_reset(&theme->styles.page_indicator_dot_current);
lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER); lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER);
style_init_reset(&theme->styles.step_indicator);
lv_style_set_bg_opa(&theme->styles.step_indicator, LV_OPA_TRANSP);
lv_style_set_border_width(&theme->styles.step_indicator, 0);
lv_style_set_pad_all(&theme->styles.step_indicator, STEP_INDICATOR_MARGIN);
lv_style_set_pad_gap(&theme->styles.step_indicator, STEP_INDICATOR_GAP);
style_init_reset(&theme->styles.step_indicator_segment);
lv_style_set_height(&theme->styles.step_indicator_segment, STEP_INDICATOR_THICKNESS);
lv_style_set_radius(&theme->styles.step_indicator_segment, LV_RADIUS_CIRCLE);
lv_style_set_border_width(&theme->styles.step_indicator_segment, 0);
lv_style_set_bg_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_UNFINISHED_COLOR);
lv_style_set_bg_opa(&theme->styles.step_indicator_segment, LV_OPA_COVER);
style_init_reset(&theme->styles.step_indicator_segment_finished);
lv_style_set_bg_color(&theme->styles.step_indicator_segment_finished, STEP_INDICATOR_FINISHED_COLOR);
style_init_reset(&theme->styles.badge); style_init_reset(&theme->styles.badge);
lv_style_set_width(&theme->styles.badge, BADGE_SIZE); lv_style_set_width(&theme->styles.badge, BADGE_SIZE);
lv_style_set_height(&theme->styles.badge, BADGE_SIZE); lv_style_set_height(&theme->styles.badge, BADGE_SIZE);
@ -857,7 +884,6 @@ static void style_init(my_theme_t * theme)
lv_style_set_text_color(&theme->styles.button_disabled, BUTTON_DISABLED_LABEL_COLOR); lv_style_set_text_color(&theme->styles.button_disabled, BUTTON_DISABLED_LABEL_COLOR);
lv_style_set_text_opa(&theme->styles.button_disabled, BUTTON_DISABLED_LABEL_OPACITY); lv_style_set_text_opa(&theme->styles.button_disabled, BUTTON_DISABLED_LABEL_OPACITY);
#if LV_USE_MSGBOX
style_init_reset(&theme->styles.text_button); style_init_reset(&theme->styles.text_button);
lv_style_set_bg_opa(&theme->styles.text_button, LV_OPA_TRANSP); lv_style_set_bg_opa(&theme->styles.text_button, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.text_button, TEXT_BUTTON_LABEL_COLOR); lv_style_set_text_color(&theme->styles.text_button, TEXT_BUTTON_LABEL_COLOR);
@ -865,13 +891,13 @@ static void style_init(my_theme_t * theme)
lv_style_set_pad_hor(&theme->styles.text_button, TEXT_BUTTON_PAD_HOR); lv_style_set_pad_hor(&theme->styles.text_button, TEXT_BUTTON_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.text_button, BUTTON_PAD_VER); lv_style_set_pad_ver(&theme->styles.text_button, BUTTON_PAD_VER);
lv_style_set_pad_gap(&theme->styles.text_button, BUTTON_GAP); lv_style_set_pad_gap(&theme->styles.text_button, BUTTON_GAP);
#endif
#if LV_USE_MSGBOX
style_init_reset(&theme->styles.text_button_pressed); style_init_reset(&theme->styles.text_button_pressed);
lv_style_set_bg_color(&theme->styles.text_button_pressed, TEXT_BUTTON_PRESSED_CONTAINER_COLOR); lv_style_set_bg_color(&theme->styles.text_button_pressed, TEXT_BUTTON_PRESSED_CONTAINER_COLOR);
lv_style_set_bg_opa(&theme->styles.text_button_pressed, OPACITY_STATE_PRESSED); lv_style_set_bg_opa(&theme->styles.text_button_pressed, OPACITY_STATE_PRESSED);
#endif
style_init_reset(&theme->styles.text_button_margin);
lv_style_set_margin_all(&theme->styles.text_button_margin, FOCUS_WIDTH + FOCUS_OFFSET);
style_init_reset(&theme->styles.icon_button); style_init_reset(&theme->styles.icon_button);
lv_style_set_bg_opa(&theme->styles.icon_button, LV_OPA_TRANSP); lv_style_set_bg_opa(&theme->styles.icon_button, LV_OPA_TRANSP);
@ -1213,7 +1239,7 @@ static void style_init(my_theme_t * theme)
lv_style_set_bg_color(&theme->styles.list, LIST_COLOR); lv_style_set_bg_color(&theme->styles.list, LIST_COLOR);
lv_style_set_bg_opa(&theme->styles.list, LV_OPA_COVER); lv_style_set_bg_opa(&theme->styles.list, LV_OPA_COVER);
lv_style_set_pad_hor(&theme->styles.list, 0); lv_style_set_pad_hor(&theme->styles.list, 0);
lv_style_set_pad_ver(&theme->styles.list, LIST_PAD); lv_style_set_pad_ver(&theme->styles.list, 0);
lv_style_set_pad_gap(&theme->styles.list, 0); lv_style_set_pad_gap(&theme->styles.list, 0);
lv_style_set_clip_corner(&theme->styles.list, true); lv_style_set_clip_corner(&theme->styles.list, true);
@ -1233,6 +1259,11 @@ static void style_init(my_theme_t * theme)
lv_style_set_bg_opa(&theme->styles.list_item_focused, LV_OPA_COVER); lv_style_set_bg_opa(&theme->styles.list_item_focused, LV_OPA_COVER);
lv_style_set_text_color(&theme->styles.list_item_focused, LIST_ITEM_FOCUSED_TEXT_COLOR); lv_style_set_text_color(&theme->styles.list_item_focused, LIST_ITEM_FOCUSED_TEXT_COLOR);
style_init_reset(&theme->styles.list_item_selected);
lv_style_set_bg_color(&theme->styles.list_item_selected, LIST_ITEM_SELECTED_COLOR);
lv_style_set_bg_opa(&theme->styles.list_item_selected, LV_OPA_COVER);
lv_style_set_text_color(&theme->styles.list_item_selected, LIST_ITEM_SELECTED_TEXT_COLOR);
style_init_reset(&theme->styles.list_subheader); style_init_reset(&theme->styles.list_subheader);
lv_style_set_text_color(&theme->styles.list_subheader, LIST_SUBHEADER_TEXT_COLOR); lv_style_set_text_color(&theme->styles.list_subheader, LIST_SUBHEADER_TEXT_COLOR);
lv_style_set_text_font(&theme->styles.list_subheader, theme->config.font_small); lv_style_set_text_font(&theme->styles.list_subheader, theme->config.font_small);
@ -1843,6 +1874,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
return; return;
} }
#endif #endif
if(lv_obj_check_type(obj, &lvgl_text_button_class)) {
/* text_button */
lv_obj_add_style(obj, &theme->styles.text_button, 0);
lv_obj_add_style(obj, &theme->styles.text_button_margin, 0);
lv_obj_add_style(obj, &theme->styles.transition_exit, 0);
lv_obj_add_style(obj, &theme->styles.text_button_pressed, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return;
}
#if LV_USE_MSGBOX #if LV_USE_MSGBOX
if(lv_obj_check_type(obj, &lv_msgbox_footer_button_class)) { if(lv_obj_check_type(obj, &lv_msgbox_footer_button_class)) {
/* msgbox_footer_button */ /* msgbox_footer_button */
@ -1927,6 +1969,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED); lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED);
return; return;
} }
if(lv_obj_check_type(obj, &lvgl_step_indicator_class)) {
/* step_indicator */
lv_obj_add_style(obj, &theme->styles.step_indicator, 0);
return;
}
if(lv_obj_check_type(obj, &lvgl_step_indicator_segment_class)) {
/* step_indicator_segment */
lv_obj_add_style(obj, &theme->styles.step_indicator_segment, 0);
lv_obj_add_style(obj, &theme->styles.step_indicator_segment_finished, LV_STATE_CHECKED);
return;
}
if(lv_obj_check_type(obj, &lvgl_badge_class)) { if(lv_obj_check_type(obj, &lvgl_badge_class)) {
/* badge */ /* badge */
lv_obj_add_style(obj, &theme->styles.badge, 0); lv_obj_add_style(obj, &theme->styles.badge, 0);
@ -2142,6 +2195,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
if(lv_obj_check_type(obj, &lvgl_list_button_class)) { if(lv_obj_check_type(obj, &lvgl_list_button_class)) {
/* flex_list_button */ /* flex_list_button */
lv_obj_add_style(obj, &theme->styles.list_item, 0); lv_obj_add_style(obj, &theme->styles.list_item, 0);
lv_obj_add_style(obj, &theme->styles.list_item_selected, LV_STATE_CHECKED);
lv_obj_add_style(obj, &theme->styles.transition_exit, 0); lv_obj_add_style(obj, &theme->styles.transition_exit, 0);
lv_obj_add_style(obj, &theme->styles.state_pressed, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.state_pressed, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED);

View File

@ -19,9 +19,12 @@ extern const lv_obj_class_t lvgl_chip_class;
extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_badge_class;
extern const lv_obj_class_t lvgl_page_indicator_class; extern const lv_obj_class_t lvgl_page_indicator_class;
extern const lv_obj_class_t lvgl_page_indicator_dot_class; extern const lv_obj_class_t lvgl_page_indicator_dot_class;
extern const lv_obj_class_t lvgl_step_indicator_class;
extern const lv_obj_class_t lvgl_step_indicator_segment_class;
extern const lv_obj_class_t lvgl_list_class; extern const lv_obj_class_t lvgl_list_class;
extern const lv_obj_class_t lvgl_list_text_class; extern const lv_obj_class_t lvgl_list_text_class;
extern const lv_obj_class_t lvgl_list_button_class; extern const lv_obj_class_t lvgl_list_button_class;
extern const lv_obj_class_t lvgl_text_button_class;
#define BLACK lv_color_black() #define BLACK lv_color_black()
#define WHITE lv_color_white() #define WHITE lv_color_white()
@ -96,6 +99,10 @@ extern const lv_obj_class_t lvgl_list_button_class;
#define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE
#define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8)) #define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8))
#define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define STEP_INDICATOR_COLOR COLOR_ON_SURFACE
#define STEP_INDICATOR_THICKNESS (LV_DPX_CALC(theme->disp_dpi, 6))
#define STEP_INDICATOR_MARGIN (LV_DPX_CALC(theme->disp_dpi, 6))
#define STEP_INDICATOR_GAP STEP_INDICATOR_MARGIN
#define BADGE_COLOR COLOR_ON_SURFACE #define BADGE_COLOR COLOR_ON_SURFACE
#define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6))
#define SCROLLBAR_COLOR COLOR_ON_SURFACE #define SCROLLBAR_COLOR COLOR_ON_SURFACE
@ -232,11 +239,15 @@ typedef struct {
#endif #endif
lv_style_t page_indicator_dot; lv_style_t page_indicator_dot;
lv_style_t page_indicator_dot_current; lv_style_t page_indicator_dot_current;
lv_style_t step_indicator;
lv_style_t step_indicator_segment;
lv_style_t step_indicator_segment_finished;
lv_style_t badge; lv_style_t badge;
lv_style_t chip; lv_style_t chip;
#if LV_USE_BUTTON || LV_USE_MSGBOX #if LV_USE_BUTTON || LV_USE_MSGBOX
lv_style_t button; lv_style_t button;
#endif #endif
lv_style_t text_button;
lv_style_t icon_button; lv_style_t icon_button;
lv_style_t icon_button_filled; lv_style_t icon_button_filled;
lv_style_t icon_button_filled_pressed; lv_style_t icon_button_filled_pressed;
@ -580,6 +591,23 @@ static void style_init(my_theme_t * theme)
style_init_reset(&theme->styles.page_indicator_dot_current); style_init_reset(&theme->styles.page_indicator_dot_current);
lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER); lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER);
style_init_reset(&theme->styles.step_indicator);
lv_style_set_bg_opa(&theme->styles.step_indicator, LV_OPA_TRANSP);
lv_style_set_border_width(&theme->styles.step_indicator, 0);
lv_style_set_pad_all(&theme->styles.step_indicator, STEP_INDICATOR_MARGIN);
lv_style_set_pad_gap(&theme->styles.step_indicator, STEP_INDICATOR_GAP);
style_init_reset(&theme->styles.step_indicator_segment);
lv_style_set_height(&theme->styles.step_indicator_segment, STEP_INDICATOR_THICKNESS);
lv_style_set_radius(&theme->styles.step_indicator_segment, LV_RADIUS_CIRCLE);
lv_style_set_bg_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_COLOR);
lv_style_set_bg_opa(&theme->styles.step_indicator_segment, LV_OPA_TRANSP);
lv_style_set_border_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_COLOR);
lv_style_set_border_width(&theme->styles.step_indicator_segment, 1);
style_init_reset(&theme->styles.step_indicator_segment_finished);
lv_style_set_bg_opa(&theme->styles.step_indicator_segment_finished, LV_OPA_COVER);
style_init_reset(&theme->styles.badge); style_init_reset(&theme->styles.badge);
lv_style_set_width(&theme->styles.badge, BADGE_SIZE); lv_style_set_width(&theme->styles.badge, BADGE_SIZE);
lv_style_set_height(&theme->styles.badge, BADGE_SIZE); lv_style_set_height(&theme->styles.badge, BADGE_SIZE);
@ -612,6 +640,16 @@ static void style_init(my_theme_t * theme)
lv_style_set_margin_all(&theme->styles.button, FOCUS_WIDTH + FOCUS_OFFSET); lv_style_set_margin_all(&theme->styles.button, FOCUS_WIDTH + FOCUS_OFFSET);
#endif #endif
style_init_reset(&theme->styles.text_button);
lv_style_set_bg_opa(&theme->styles.text_button, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.text_button, BUTTON_LABEL_COLOR);
lv_style_set_border_width(&theme->styles.text_button, 0);
lv_style_set_radius(&theme->styles.text_button, BUTTON_SHAPE);
lv_style_set_pad_hor(&theme->styles.text_button, BUTTON_PAD_HOR);
lv_style_set_pad_ver(&theme->styles.text_button, BUTTON_PAD_VER);
lv_style_set_pad_gap(&theme->styles.text_button, BUTTON_GAP);
lv_style_set_margin_all(&theme->styles.text_button, FOCUS_WIDTH + FOCUS_OFFSET);
style_init_reset(&theme->styles.icon_button); style_init_reset(&theme->styles.icon_button);
lv_style_set_bg_opa(&theme->styles.icon_button, LV_OPA_TRANSP); lv_style_set_bg_opa(&theme->styles.icon_button, LV_OPA_TRANSP);
lv_style_set_text_color(&theme->styles.icon_button, COLOR_ON_SURFACE); lv_style_set_text_color(&theme->styles.icon_button, COLOR_ON_SURFACE);
@ -877,7 +915,7 @@ static void style_init(my_theme_t * theme)
lv_style_set_border_width(&theme->styles.list, BORDER); lv_style_set_border_width(&theme->styles.list, BORDER);
lv_style_set_radius(&theme->styles.list, SHAPE_CONTAINER); lv_style_set_radius(&theme->styles.list, SHAPE_CONTAINER);
lv_style_set_pad_hor(&theme->styles.list, 0); lv_style_set_pad_hor(&theme->styles.list, 0);
lv_style_set_pad_ver(&theme->styles.list, LIST_PAD); lv_style_set_pad_ver(&theme->styles.list, 0);
lv_style_set_pad_gap(&theme->styles.list, 0); lv_style_set_pad_gap(&theme->styles.list, 0);
lv_style_set_clip_corner(&theme->styles.list, true); lv_style_set_clip_corner(&theme->styles.list, true);
@ -1415,6 +1453,14 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
return; return;
} }
#endif #endif
if(lv_obj_check_type(obj, &lvgl_text_button_class)) {
/* text_button */
lv_obj_add_style(obj, &theme->styles.text_button, 0);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.focus_ring, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);
return;
}
#if LV_USE_MSGBOX #if LV_USE_MSGBOX
if(lv_obj_check_type(obj, &lv_msgbox_footer_button_class)) { if(lv_obj_check_type(obj, &lv_msgbox_footer_button_class)) {
/* msgbox_footer_button */ /* msgbox_footer_button */
@ -1495,6 +1541,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED); lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED);
return; return;
} }
if(lv_obj_check_type(obj, &lvgl_step_indicator_class)) {
/* step_indicator */
lv_obj_add_style(obj, &theme->styles.step_indicator, 0);
return;
}
if(lv_obj_check_type(obj, &lvgl_step_indicator_segment_class)) {
/* step_indicator_segment */
lv_obj_add_style(obj, &theme->styles.step_indicator_segment, 0);
lv_obj_add_style(obj, &theme->styles.step_indicator_segment_finished, LV_STATE_CHECKED);
return;
}
if(lv_obj_check_type(obj, &lvgl_badge_class)) { if(lv_obj_check_type(obj, &lvgl_badge_class)) {
/* badge */ /* badge */
lv_obj_add_style(obj, &theme->styles.badge, 0); lv_obj_add_style(obj, &theme->styles.badge, 0);
@ -1703,6 +1760,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj)
if(lv_obj_check_type(obj, &lvgl_list_button_class)) { if(lv_obj_check_type(obj, &lvgl_list_button_class)) {
/* flex_list_button */ /* flex_list_button */
lv_obj_add_style(obj, &theme->styles.list_item, 0); lv_obj_add_style(obj, &theme->styles.list_item, 0);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_CHECKED);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED);
lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_FOCUS_KEY); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_FOCUS_KEY);
lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED);

View File

@ -19,6 +19,8 @@
#include <lvgl/widgets/focus_container.h> #include <lvgl/widgets/focus_container.h>
#include <lvgl/widgets/icon_button.h> #include <lvgl/widgets/icon_button.h>
#include <TactilityCpp/Memory.h>
#include <algorithm> #include <algorithm>
#include <cstring> #include <cstring>
#include <ctime> #include <ctime>
@ -61,7 +63,12 @@ struct StatusbarData {
} }
}; };
static StatusbarData statusbar_data; // Allocated once in external memory when available
static StatusbarData& statusbar_data() {
static StatusbarData* instance = tt::newObject<StatusbarData>();
check(instance != nullptr);
return *instance;
}
struct Statusbar { struct Statusbar {
lv_obj_t obj; lv_obj_t obj;
@ -99,8 +106,8 @@ static TickType_t getNextUpdateTime() {
} }
static void notify_instances() { static void notify_instances() {
mutex_lock(&statusbar_data.instances_mutex); mutex_lock(&statusbar_data().instances_mutex);
for (auto* statusbar = statusbar_data.instances; statusbar != nullptr; statusbar = statusbar->next) { for (auto* statusbar = statusbar_data().instances; statusbar != nullptr; statusbar = statusbar->next) {
LOG_D(TAG, "Update event"); LOG_D(TAG, "Update event");
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) { if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
update_main(statusbar); update_main(statusbar);
@ -110,28 +117,28 @@ static void notify_instances() {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "Statusbar"); LOG_W(TAG, "Mutex acquisition timeout (%s)", "Statusbar");
} }
} }
mutex_unlock(&statusbar_data.instances_mutex); mutex_unlock(&statusbar_data().instances_mutex);
} }
static void on_update_time(void* /*context*/) { static void on_update_time(void* /*context*/) {
time_t now = ::time(nullptr); time_t now = ::time(nullptr);
tm* tm_struct = localtime(&now); tm* tm_struct = localtime(&now);
if (recursive_mutex_try_lock(&statusbar_data.mutex, 100 / portTICK_PERIOD_MS)) { if (recursive_mutex_try_lock(&statusbar_data().mutex, 100 / portTICK_PERIOD_MS)) {
bool changed = false; bool changed = false;
if (tm_struct->tm_year >= (2025 - 1900)) { if (tm_struct->tm_year >= (2025 - 1900)) {
statusbar_data.time_hours = tm_struct->tm_hour; statusbar_data().time_hours = tm_struct->tm_hour;
statusbar_data.time_minutes = tm_struct->tm_min; statusbar_data().time_minutes = tm_struct->tm_min;
statusbar_data.time_set = true; statusbar_data().time_set = true;
changed = true; changed = true;
// Reschedule // Reschedule
timer_reset_with_interval(statusbar_data.time_update_timer, getNextUpdateTime()); timer_reset_with_interval(statusbar_data().time_update_timer, getNextUpdateTime());
} else { } else {
timer_reset_with_interval(statusbar_data.time_update_timer, pdMS_TO_TICKS(60000U)); timer_reset_with_interval(statusbar_data().time_update_timer, pdMS_TO_TICKS(60000U));
} }
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
if (changed) { if (changed) {
notify_instances(); notify_instances();
} }
@ -154,9 +161,9 @@ static lv_obj_class_t statusbar_class = {
}; };
static void onTimeChanged(struct SystemEvent* /*event*/, void* /*context*/) { static void onTimeChanged(struct SystemEvent* /*event*/, void* /*context*/) {
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
timer_reset_with_interval(statusbar_data.time_update_timer, 5); timer_reset_with_interval(statusbar_data().time_update_timer, 5);
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
} }
static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
@ -170,26 +177,26 @@ static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj)
// would let a concurrent lvgl_statusbar_icon_add()/_remove()/_set_image()/_set_visibility() call // would let a concurrent lvgl_statusbar_icon_add()/_remove()/_set_image()/_set_visibility() call
// from another task run update_icon() on a still-null icons[] slot before this // from another task run update_icon() on a still-null icons[] slot before this
// instance's own creation loop below has had a chance to fill it in. // instance's own creation loop below has had a chance to fill it in.
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
if (statusbar_data.time_update_timer == nullptr) { if (statusbar_data().time_update_timer == nullptr) {
statusbar_data.time_update_timer = timer_alloc(TIMER_TYPE_ONCE, 200 / portTICK_PERIOD_MS, on_update_time, nullptr); statusbar_data().time_update_timer = timer_alloc(TIMER_TYPE_ONCE, 200 / portTICK_PERIOD_MS, on_update_time, nullptr);
check(statusbar_data.time_update_timer != nullptr); check(statusbar_data().time_update_timer != nullptr);
timer_start(statusbar_data.time_update_timer); timer_start(statusbar_data().time_update_timer);
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, onTimeChanged, nullptr); system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, onTimeChanged, nullptr);
} }
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
} }
static void statusbar_destructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { static void statusbar_destructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
auto* statusbar = (Statusbar*)obj; auto* statusbar = (Statusbar*)obj;
mutex_lock(&statusbar_data.instances_mutex); mutex_lock(&statusbar_data().instances_mutex);
for (auto** link = &statusbar_data.instances; *link != nullptr; link = &(*link)->next) { for (auto** link = &statusbar_data().instances; *link != nullptr; link = &(*link)->next) {
if (*link == statusbar) { if (*link == statusbar) {
*link = statusbar->next; *link = statusbar->next;
break; break;
} }
} }
mutex_unlock(&statusbar_data.instances_mutex); mutex_unlock(&statusbar_data().instances_mutex);
} }
static void update_icon(lv_obj_t* image, const StatusbarIcon* icon) { static void update_icon(lv_obj_t* image, const StatusbarIcon* icon) {
@ -202,9 +209,9 @@ static void update_icon(lv_obj_t* image, const StatusbarIcon* icon) {
} }
static void show_menu() { static void show_menu() {
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
auto* callback = statusbar_data.menu_callback; auto* callback = statusbar_data().menu_callback;
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
if (callback != nullptr) { if (callback != nullptr) {
callback(); callback();
} }
@ -257,15 +264,15 @@ static void onStatusbarGesture(lv_event_t* /*event*/) {
extern "C" { extern "C" {
void lvgl_statusbar_set_menu_callback(void (*callback)()) { void lvgl_statusbar_set_menu_callback(void (*callback)()) {
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
statusbar_data.menu_callback = callback; statusbar_data().menu_callback = callback;
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
} }
void lvgl_statusbar_set_time_format_24h(bool format_24h) { void lvgl_statusbar_set_time_format_24h(bool format_24h) {
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
statusbar_data.time_format_24h = format_24h; statusbar_data().time_format_24h = format_24h;
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
notify_instances(); notify_instances();
} }
@ -362,7 +369,7 @@ lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent) {
} }
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
auto* image = lv_image_create(icon_row); auto* image = lv_image_create(icon_row);
lv_obj_set_size(image, icon_size, icon_size); // regular padding doesn't work lv_obj_set_size(image, icon_size, icon_size); // regular padding doesn't work
@ -370,17 +377,17 @@ lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent) {
lv_obj_set_style_pad_all(image, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(image, 0, LV_STATE_DEFAULT);
statusbar->icons[i] = image; statusbar->icons[i] = image;
update_icon(image, &(statusbar_data.icons[i])); update_icon(image, &(statusbar_data().icons[i]));
} }
// Only now is statusbar->icons[] fully populated - see statusbar_constructor()'s comment for // Only now is statusbar->icons[] fully populated - see statusbar_constructor()'s comment for
// why the instance can't be registered any earlier. Registering before unlocking (rather // why the instance can't be registered any earlier. Registering before unlocking (rather
// than after) closes a narrow window where a concurrent icon change could be missed by // than after) closes a narrow window where a concurrent icon change could be missed by
// this instance after the icons are populated but before it's registered. // this instance after the icons are populated but before it's registered.
mutex_lock(&statusbar_data.instances_mutex); mutex_lock(&statusbar_data().instances_mutex);
statusbar->next = statusbar_data.instances; statusbar->next = statusbar_data().instances;
statusbar_data.instances = statusbar; statusbar_data().instances = statusbar;
mutex_unlock(&statusbar_data.instances_mutex); mutex_unlock(&statusbar_data().instances_mutex);
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
// Only the icon row is focused, it joins the group by itself // Only the icon row is focused, it joins the group by itself
if (lv_obj_get_group(obj) != nullptr) { if (lv_obj_get_group(obj) != nullptr) {
@ -393,17 +400,17 @@ lv_obj_t* lvgl_statusbar_create(lv_obj_t* parent) {
} }
static void update_time(Statusbar* statusbar) { static void update_time(Statusbar* statusbar) {
if (statusbar_data.time_set) { if (statusbar_data().time_set) {
bool format24 = statusbar_data.time_format_24h; bool format24 = statusbar_data().time_format_24h;
int hours = format24 ? statusbar_data.time_hours : statusbar_data.time_hours % 12; int hours = format24 ? statusbar_data().time_hours : statusbar_data().time_hours % 12;
lv_label_set_text_fmt(statusbar->time, "%d:%02d", hours, statusbar_data.time_minutes); lv_label_set_text_fmt(statusbar->time, "%d:%02d", hours, statusbar_data().time_minutes);
} else { } else {
lv_label_set_text(statusbar->time, ""); lv_label_set_text(statusbar->time, "");
} }
} }
// In the side strip, the time and the action buttons always show. The icons get the height that's left, and the newest ones are hidden when they don't fit. // In the side strip, the time and the action buttons always show. The icons get the height that's left, and the newest ones are hidden when they don't fit.
// Call while holding statusbar_data.mutex, after update_icon() set the icons' visibility. // Call while holding statusbar_data().mutex, after update_icon() set the icons' visibility.
static void apply_side_icon_limit(Statusbar* statusbar) { static void apply_side_icon_limit(Statusbar* statusbar) {
if (!statusbar->side) { if (!statusbar->side) {
return; return;
@ -427,7 +434,7 @@ static void apply_side_icon_limit(Statusbar* statusbar) {
} }
} }
std::sort(shown, shown + shown_count, [](int a, int b) { std::sort(shown, shown + shown_count, [](int a, int b) {
return statusbar_data.icons[a].added_order < statusbar_data.icons[b].added_order; return statusbar_data().icons[a].added_order < statusbar_data().icons[b].added_order;
}); });
for (int i = static_cast<int>(fitting); i < shown_count; ++i) { for (int i = static_cast<int>(fitting); i < shown_count; ++i) {
lv_obj_set_hidden(statusbar->icons[shown[i]], true); lv_obj_set_hidden(statusbar->icons[shown[i]], true);
@ -435,12 +442,12 @@ static void apply_side_icon_limit(Statusbar* statusbar) {
} }
static void update_icons(Statusbar* statusbar) { static void update_icons(Statusbar* statusbar) {
if (recursive_mutex_try_lock(&statusbar_data.mutex, 200 / portTICK_PERIOD_MS)) { if (recursive_mutex_try_lock(&statusbar_data().mutex, 200 / portTICK_PERIOD_MS)) {
for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { for (int i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
update_icon(statusbar->icons[i], &(statusbar_data.icons[i])); update_icon(statusbar->icons[i], &(statusbar_data().icons[i]));
} }
apply_side_icon_limit(statusbar); apply_side_icon_limit(statusbar);
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
} }
} }
@ -487,20 +494,20 @@ static void copy_image(char* target, const char* image) {
extern "C" { extern "C" {
int8_t lvgl_statusbar_icon_add(const char* image, bool visible) { int8_t lvgl_statusbar_icon_add(const char* image, bool visible) {
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
int8_t result = -1; int8_t result = -1;
for (int8_t i = 0; i < STATUSBAR_ICON_LIMIT; ++i) { for (int8_t i = 0; i < STATUSBAR_ICON_LIMIT; ++i) {
if (!statusbar_data.icons[i].claimed) { if (!statusbar_data().icons[i].claimed) {
statusbar_data.icons[i].claimed = true; statusbar_data().icons[i].claimed = true;
statusbar_data.icons[i].visible = visible; statusbar_data().icons[i].visible = visible;
copy_image(statusbar_data.icons[i].image, image); copy_image(statusbar_data().icons[i].image, image);
statusbar_data.icons[i].added_order = statusbar_data.next_added_order++; statusbar_data().icons[i].added_order = statusbar_data().next_added_order++;
result = i; result = i;
LOG_D(TAG, "id %d: added", (int)i); LOG_D(TAG, "id %d: added", (int)i);
break; break;
} }
} }
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
if (result < 0) { if (result < 0) {
LOG_W(TAG, "No icon slot left, the icon isn't shown"); LOG_W(TAG, "No icon slot left, the icon isn't shown");
} }
@ -514,12 +521,12 @@ void lvgl_statusbar_icon_remove(int8_t id) {
return; return;
} }
check(id >= 0 && id < STATUSBAR_ICON_LIMIT); check(id >= 0 && id < STATUSBAR_ICON_LIMIT);
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
StatusbarIcon* icon = &statusbar_data.icons[id]; StatusbarIcon* icon = &statusbar_data().icons[id];
icon->claimed = false; icon->claimed = false;
icon->visible = false; icon->visible = false;
icon->image[0] = '\0'; icon->image[0] = '\0';
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
notify_instances(); notify_instances();
} }
@ -529,11 +536,11 @@ void lvgl_statusbar_icon_set_image(int8_t id, const char* image) {
return; return;
} }
check(id >= 0 && id < STATUSBAR_ICON_LIMIT); check(id >= 0 && id < STATUSBAR_ICON_LIMIT);
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
StatusbarIcon* icon = &statusbar_data.icons[id]; StatusbarIcon* icon = &statusbar_data().icons[id];
check(icon->claimed); check(icon->claimed);
copy_image(icon->image, image); copy_image(icon->image, image);
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
notify_instances(); notify_instances();
} }
@ -543,11 +550,11 @@ void lvgl_statusbar_icon_set_visibility(int8_t id, bool visible) {
return; return;
} }
check(id >= 0 && id < STATUSBAR_ICON_LIMIT); check(id >= 0 && id < STATUSBAR_ICON_LIMIT);
recursive_mutex_lock(&statusbar_data.mutex); recursive_mutex_lock(&statusbar_data().mutex);
StatusbarIcon* icon = &statusbar_data.icons[id]; StatusbarIcon* icon = &statusbar_data().icons[id];
check(icon->claimed); check(icon->claimed);
icon->visible = visible; icon->visible = visible;
recursive_mutex_unlock(&statusbar_data.mutex); recursive_mutex_unlock(&statusbar_data().mutex);
notify_instances(); notify_instances();
} }

View File

@ -0,0 +1,82 @@
// SPDX-License-Identifier: Apache-2.0
#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration
#include <lvgl/widgets/step_indicator.h>
static void step_indicator_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
LV_UNUSED(class_p);
lv_obj_set_clickable(obj, false);
lv_obj_set_scrollable(obj, false);
lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
}
static void step_indicator_segment_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) {
LV_UNUSED(class_p);
lv_obj_set_clickable(obj, false);
lv_obj_set_scrollable(obj, false);
// The segments share the indicator's width
lv_obj_set_flex_grow(obj, 1);
}
// Not static: the theme styles step indicators and their segments by these classes
extern "C" const lv_obj_class_t lvgl_step_indicator_class = {
.base_class = &lv_obj_class,
.constructor_cb = step_indicator_constructor,
.destructor_cb = nullptr,
.event_cb = nullptr,
.user_data = nullptr,
.name = "step_indicator",
.width_def = LV_PCT(100),
.height_def = LV_SIZE_CONTENT,
.editable = LV_OBJ_CLASS_EDITABLE_INHERIT,
.group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE,
.instance_size = 0,
.theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE
};
extern "C" const lv_obj_class_t lvgl_step_indicator_segment_class = {
.base_class = &lv_obj_class,
.constructor_cb = step_indicator_segment_constructor,
.destructor_cb = nullptr,
.event_cb = nullptr,
.user_data = nullptr,
.name = "step_indicator_segment",
.width_def = 0,
.height_def = 0,
.editable = LV_OBJ_CLASS_EDITABLE_INHERIT,
.group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE,
.instance_size = 0,
.theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE
};
extern "C" {
lv_obj_t* lvgl_step_indicator_create(lv_obj_t* parent) {
lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_step_indicator_class, parent);
lv_obj_class_init_obj(obj);
return obj;
}
void lvgl_step_indicator_set_step_count(lv_obj_t* obj, uint32_t count) {
uint32_t current = lv_obj_get_child_count(obj);
while (current > count) {
lv_obj_delete(lv_obj_get_child(obj, -1));
current--;
}
while (current < count) {
lv_obj_t* segment = lv_obj_class_create_obj(&lvgl_step_indicator_segment_class, obj);
lv_obj_class_init_obj(segment);
current++;
}
}
void lvgl_step_indicator_set_finished_count(lv_obj_t* obj, uint32_t finished_count) {
const uint32_t count = lv_obj_get_child_count(obj);
for (uint32_t i = 0; i < count; i++) {
// The theme shows finished steps by the checked state
lv_obj_set_state(lv_obj_get_child(obj, static_cast<int32_t>(i)), LV_STATE_CHECKED, i < finished_count);
}
}
}

View File

@ -0,0 +1,26 @@
// SPDX-License-Identifier: Apache-2.0
#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration
#include <lvgl/widgets/text_button.h>
// Not static: the theme styles text buttons by this class
extern "C" const lv_obj_class_t lvgl_text_button_class = {
.base_class = &lv_button_class,
.constructor_cb = nullptr,
.destructor_cb = nullptr,
.event_cb = nullptr,
.user_data = nullptr,
.name = "text_button",
.width_def = 0,
.height_def = 0,
.editable = LV_OBJ_CLASS_EDITABLE_INHERIT,
.group_def = LV_OBJ_CLASS_GROUP_DEF_INHERIT,
.instance_size = 0,
.theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE
};
lv_obj_t* lvgl_text_button_create(lv_obj_t* parent) {
lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_text_button_class, parent);
lv_obj_class_init_obj(obj);
return obj;
}

View File

@ -35,9 +35,12 @@
@widget badge: lvgl_badge_class; @widget badge: lvgl_badge_class;
@widget page_indicator: lvgl_page_indicator_class; @widget page_indicator: lvgl_page_indicator_class;
@widget page_indicator_dot: lvgl_page_indicator_dot_class; @widget page_indicator_dot: lvgl_page_indicator_dot_class;
@widget step_indicator: lvgl_step_indicator_class;
@widget step_indicator_segment: lvgl_step_indicator_segment_class;
@widget flex_list: lvgl_list_class; @widget flex_list: lvgl_list_class;
@widget flex_list_text: lvgl_list_text_class; @widget flex_list_text: lvgl_list_text_class;
@widget flex_list_button: lvgl_list_button_class; @widget flex_list_button: lvgl_list_button_class;
@widget text_button: lvgl_text_button_class;
// ============================================================ // ============================================================
// 1. Base tokens // 1. Base tokens
@ -218,6 +221,13 @@
@page-indicator-dot-size: if($isCompact, 6, 8dp); @page-indicator-dot-size: if($isCompact, 6, 8dp);
@page-indicator-gap: if($isCompact, 4, 6dp); @page-indicator-gap: if($isCompact, 4, 6dp);
// Step indicator (lvgl_step_indicator_create): a line segment per step, finished steps in the secondary colour
@step-indicator-finished-color: @color-secondary;
@step-indicator-unfinished-color: mix(black, @color-secondary, 50%);
@step-indicator-thickness: 6dp;
@step-indicator-margin: 6dp;
@step-indicator-gap: @step-indicator-margin;
// Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite
@badge-color: @color-secondary; @badge-color: @color-secondary;
@badge-size: if($isCompact, 4, 6dp); @badge-size: if($isCompact, 4, 6dp);
@ -372,7 +382,7 @@
@key-selected-text-color: @color-on-primary; @key-selected-text-color: @color-on-primary;
// List // List
@list-color: @color-surface; @list-color: @card-color;
@list-pad: @density-list-pad; @list-pad: @density-list-pad;
@list-item-color: @color-surface; @list-item-color: @color-surface;
@list-item-opacity: transp; @list-item-opacity: transp;
@ -382,7 +392,10 @@
@list-item-gap: if($isCompact, 3, @space-4); @list-item-gap: if($isCompact, 3, @space-4);
@list-item-focused-color: @color-secondary-container; @list-item-focused-color: @color-secondary-container;
@list-item-focused-text-color: @color-on-secondary-container; @list-item-focused-text-color: @color-on-secondary-container;
@list-divider-color: @color-outline-variant; @list-item-selected-color: @color-secondary-container;
@list-item-selected-text-color: @color-on-secondary-container;
// The secondary colour, muted towards the list background
@list-divider-color: mix(@color-secondary, @list-color, 35%);
@list-divider-width: @density-border; @list-divider-width: @density-border;
@list-subheader-text-color: @color-on-surface-variant; @list-subheader-text-color: @color-on-surface-variant;
@ -613,6 +626,25 @@ page_indicator_dot_current {
bgOpa: cover; bgOpa: cover;
} }
step_indicator {
bgOpa: transp;
borderWidth: 0;
padAll: @step-indicator-margin;
padGap: @step-indicator-gap;
}
step_indicator_segment {
height: @step-indicator-thickness;
radius: circle;
borderWidth: 0;
bgColor: @step-indicator-unfinished-color;
bgOpa: cover;
}
step_indicator_segment_finished {
bgColor: @step-indicator-finished-color;
}
badge { badge {
width: @badge-size; width: @badge-size;
height: @badge-size; height: @badge-size;
@ -686,6 +718,11 @@ text_button_pressed {
bgOpa: @opacity-state-pressed; bgOpa: @opacity-state-pressed;
} }
// Room for the focus ring of a text button, which the parent would clip
text_button_margin {
marginAll: @focus-width + @focus-offset;
}
icon_button { icon_button {
bgOpa: transp; bgOpa: transp;
textColor: @icon-button-color; textColor: @icon-button-color;
@ -1006,7 +1043,7 @@ list {
bgColor: @list-color; bgColor: @list-color;
bgOpa: cover; bgOpa: cover;
padHor: 0; padHor: 0;
padVer: @list-pad; padVer: 0;
padGap: 0; padGap: 0;
clipCorner: true; clipCorner: true;
} }
@ -1029,6 +1066,13 @@ list_item_focused {
textColor: @list-item-focused-text-color; textColor: @list-item-focused-text-color;
} }
// A list item that is checked to show it's the selected one
list_item_selected {
bgColor: @list-item-selected-color;
bgOpa: cover;
textColor: @list-item-selected-text-color;
}
list_subheader { list_subheader {
textColor: @list-subheader-text-color; textColor: @list-subheader-text-color;
textFont: $fontSmall; textFont: $fontSmall;
@ -1420,6 +1464,16 @@ apply(msgbox_header_button) {
disabled: state.disabled; disabled: state.disabled;
} }
apply(text_button) {
text_button: default;
text_button_margin: default;
transition_exit: default;
text_button_pressed: state.pressed;
transition_enter: state.pressed;
focus_ring: state.focusKey;
disabled: state.disabled;
}
apply(msgbox_footer_button) { apply(msgbox_footer_button) {
text_button: default; text_button: default;
transition_exit: default; transition_exit: default;
@ -1485,6 +1539,15 @@ apply(page_indicator_dot) {
page_indicator_dot_current: state.checked; page_indicator_dot_current: state.checked;
} }
apply(step_indicator) {
step_indicator: default;
}
apply(step_indicator_segment) {
step_indicator_segment: default;
step_indicator_segment_finished: state.checked;
}
apply(badge) { apply(badge) {
badge: default; badge: default;
} }
@ -1664,6 +1727,7 @@ apply(flex_list_text) {
apply(flex_list_button) { apply(flex_list_button) {
list_item: default; list_item: default;
list_item_selected: state.checked;
transition_exit: default; transition_exit: default;
state_pressed: state.pressed; state_pressed: state.pressed;
transition_enter: state.pressed; transition_enter: state.pressed;

View File

@ -33,9 +33,12 @@
@widget badge: lvgl_badge_class; @widget badge: lvgl_badge_class;
@widget page_indicator: lvgl_page_indicator_class; @widget page_indicator: lvgl_page_indicator_class;
@widget page_indicator_dot: lvgl_page_indicator_dot_class; @widget page_indicator_dot: lvgl_page_indicator_dot_class;
@widget step_indicator: lvgl_step_indicator_class;
@widget step_indicator_segment: lvgl_step_indicator_segment_class;
@widget flex_list: lvgl_list_class; @widget flex_list: lvgl_list_class;
@widget flex_list_text: lvgl_list_text_class; @widget flex_list_text: lvgl_list_text_class;
@widget flex_list_button: lvgl_list_button_class; @widget flex_list_button: lvgl_list_button_class;
@widget text_button: lvgl_text_button_class;
// ============================================================ // ============================================================
// 1. Base tokens // 1. Base tokens
@ -153,6 +156,12 @@
@page-indicator-dot-size: if($isCompact, 6, 8dp); @page-indicator-dot-size: if($isCompact, 6, 8dp);
@page-indicator-gap: if($isCompact, 4, 6dp); @page-indicator-gap: if($isCompact, 4, 6dp);
// Step indicator (lvgl_step_indicator_create): a line segment per step, finished steps filled, unfinished steps outlined
@step-indicator-color: @color-on-surface;
@step-indicator-thickness: 6dp;
@step-indicator-margin: 6dp;
@step-indicator-gap: @step-indicator-margin;
// Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite
@badge-color: @color-on-surface; @badge-color: @color-on-surface;
@badge-size: if($isCompact, 4, 6dp); @badge-size: if($isCompact, 4, 6dp);
@ -430,6 +439,26 @@ page_indicator_dot_current {
bgOpa: cover; bgOpa: cover;
} }
step_indicator {
bgOpa: transp;
borderWidth: 0;
padAll: @step-indicator-margin;
padGap: @step-indicator-gap;
}
step_indicator_segment {
height: @step-indicator-thickness;
radius: circle;
bgColor: @step-indicator-color;
bgOpa: transp;
borderColor: @step-indicator-color;
borderWidth: 1;
}
step_indicator_segment_finished {
bgOpa: cover;
}
badge { badge {
width: @badge-size; width: @badge-size;
height: @badge-size; height: @badge-size;
@ -465,6 +494,19 @@ button {
marginAll: @focus-width + @focus-offset; marginAll: @focus-width + @focus-offset;
} }
// A button without a container, for low-emphasis actions
text_button {
bgOpa: transp;
textColor: @button-label-color;
borderWidth: 0;
radius: @button-shape;
padHor: @button-pad-hor;
padVer: @button-pad-ver;
padGap: @button-gap;
// Room for the focus ring, which the parent would clip
marginAll: @focus-width + @focus-offset;
}
icon_button { icon_button {
bgOpa: transp; bgOpa: transp;
textColor: @color-on-surface; textColor: @color-on-surface;
@ -719,7 +761,7 @@ list {
borderWidth: @border; borderWidth: @border;
radius: @shape-container; radius: @shape-container;
padHor: 0; padHor: 0;
padVer: @list-pad; padVer: 0;
padGap: 0; padGap: 0;
clipCorner: true; clipCorner: true;
} }
@ -1059,6 +1101,13 @@ apply(menu_sidebar_header_cont > button, menu_main_header_cont > button, win > o
focus_ring: state.focusKey; focus_ring: state.focusKey;
} }
apply(text_button) {
text_button: default;
inverted: state.pressed;
focus_ring: state.focusKey;
disabled: state.disabled;
}
apply(button, msgbox_footer_button) { apply(button, msgbox_footer_button) {
button: default; button: default;
inverted: state.pressed; inverted: state.pressed;
@ -1120,6 +1169,15 @@ apply(page_indicator_dot) {
page_indicator_dot_current: state.checked; page_indicator_dot_current: state.checked;
} }
apply(step_indicator) {
step_indicator: default;
}
apply(step_indicator_segment) {
step_indicator_segment: default;
step_indicator_segment_finished: state.checked;
}
apply(badge) { apply(badge) {
badge: default; badge: default;
} }
@ -1270,6 +1328,7 @@ apply(flex_list_text) {
apply(flex_list_button) { apply(flex_list_button) {
list_item: default; list_item: default;
inverted: state.checked;
inverted: state.pressed; inverted: state.pressed;
inverted: state.focusKey; inverted: state.focusKey;
disabled: state.disabled; disabled: state.disabled;

View File

@ -14,6 +14,7 @@
#include <TactilityCpp/std/deque.h> #include <TactilityCpp/std/deque.h>
#include <TactilityCpp/std/vector.h> #include <TactilityCpp/std/vector.h>
#include <tactility/check.h>
#include <tactility/concurrent/event_group.h> #include <tactility/concurrent/event_group.h>
#include <tactility/concurrent/mutex.h> #include <tactility/concurrent/mutex.h>
#include <tactility/concurrent/task_event_group.h> #include <tactility/concurrent/task_event_group.h>
@ -63,22 +64,29 @@ void unlock() { mutex_unlock(&globalMutex.handle); }
/** Serializes callOnServiceThread() callers. Taken before the global lock, never while holding it */ /** Serializes callOnServiceThread() callers. Taken before the global lock, never while holding it */
GlobalMutex callMutex; GlobalMutex callMutex;
const MeshBackendApi* backends[MAX_BACKENDS] = {}; /** The registry, settings and subscriptions. Allocated once in external memory and guarded by the global lock. */
size_t backendCount = 0; struct Globals {
const MeshBackendApi* backends[MAX_BACKENDS] = {};
size_t backendCount = 0;
MeshSettings settings {};
bool settingsLoaded = false;
MeshEventSubscription* subscriptions = nullptr;
};
MeshSettings settings {}; Globals& globals() {
bool settingsLoaded = false; static Globals* instance = tt::newObject<Globals>();
check(instance != nullptr);
MeshEventSubscription* subscriptions = nullptr; return *instance;
}
uint64_t getMillis() { uint64_t getMillis() {
return get_micros_since_boot() / 1000; return get_micros_since_boot() / 1000;
} }
const MeshBackendApi* findBackend(const char* id) { const MeshBackendApi* findBackend(const char* id) {
for (size_t i = 0; i < backendCount; i++) { for (size_t i = 0; i < globals().backendCount; i++) {
if (strncmp(backends[i]->id, id, MESH_BACKEND_ID_MAX_LENGTH) == 0) { if (strncmp(globals().backends[i]->id, id, MESH_BACKEND_ID_MAX_LENGTH) == 0) {
return backends[i]; return globals().backends[i];
} }
} }
return nullptr; return nullptr;
@ -102,10 +110,10 @@ Device* findRadio() {
/** Requires the global lock and loaded settings */ /** Requires the global lock and loaded settings */
const MeshBackendApi* getSelectedBackend() { const MeshBackendApi* getSelectedBackend() {
if (settings.backend[0] != '\0') { if (globals().settings.backend[0] != '\0') {
return findBackend(settings.backend); return findBackend(globals().settings.backend);
} }
return (backendCount > 0) ? backends[0] : nullptr; return (globals().backendCount > 0) ? globals().backends[0] : nullptr;
} }
size_t getPresetCount(const MeshBackendApi* api) { size_t getPresetCount(const MeshBackendApi* api) {
@ -130,15 +138,15 @@ size_t findPreset(const MeshBackendApi* api, const char* name) {
} }
void ensureSettingsLoaded() { void ensureSettingsLoaded() {
if (!settingsLoaded) { if (!globals().settingsLoaded) {
mesh_settings_load(&settings); mesh_settings_load(&globals().settings);
settingsLoaded = true; globals().settingsLoaded = true;
} }
} }
/** Requires the global lock */ /** Requires the global lock */
void queueEvent(const MeshEvent& event) { void queueEvent(const MeshEvent& event) {
for (auto* sub = subscriptions; sub != nullptr; sub = sub->internal.next) { for (auto* sub = globals().subscriptions; sub != nullptr; sub = sub->internal.next) {
if (sub->internal.count >= MESH_EVENT_QUEUE_CAPACITY) { if (sub->internal.count >= MESH_EVENT_QUEUE_CAPACITY) {
continue; continue;
} }
@ -226,6 +234,7 @@ struct MeshHost {
RadioTxId txId = 0; RadioTxId txId = 0;
Frame txFrame; Frame txFrame;
tt::std::deque<AirtimeRecord> airtime; tt::std::deque<AirtimeRecord> airtime;
RadioRxEvent rxEvent {};
bool dutyCycleLogged = false; bool dutyCycleLogged = false;
uint64_t lastTickMs = 0; uint64_t lastTickMs = 0;
@ -377,7 +386,7 @@ void configure(MeshHost* host) {
lock(); lock();
const MeshBackendApi* api = getSelectedBackend(); const MeshBackendApi* api = getSelectedBackend();
const size_t preset = (api != nullptr) ? findPreset(api, settings.preset) : 0; const size_t preset = (api != nullptr) ? findPreset(api, globals().settings.preset) : 0;
unlock(); unlock();
if (api == nullptr) { if (api == nullptr) {
setStateLocked(host, MESH_STATE_NO_BACKEND); setStateLocked(host, MESH_STATE_NO_BACKEND);
@ -566,8 +575,8 @@ bool attachRadio(MeshHost* host) {
void drainRadioEvents(MeshHost* host) { void drainRadioEvents(MeshHost* host) {
if (host->rxSubscribed) { if (host->rxSubscribed) {
// Static: RadioRxEvent is large and only used on this thread // A host member: RadioRxEvent is large and only used on this thread
static RadioRxEvent rxEvent; auto& rxEvent = host->rxEvent;
while (radio_rx_event_poll(&host->rxSub, &rxEvent) == ERROR_NONE) { while (radio_rx_event_poll(&host->rxSub, &rxEvent) == ERROR_NONE) {
if (host->api != nullptr) { if (host->api != nullptr) {
if (!host->api->on_receive(host->backend, rxEvent.data, rxEvent.length, rxEvent.rssi, rxEvent.snr)) { if (!host->api->on_receive(host->backend, rxEvent.data, rxEvent.length, rxEvent.rssi, rxEvent.snr)) {
@ -767,7 +776,7 @@ const ServiceManifest mesh_service_manifest = {
bool mesh_service_should_auto_start() { bool mesh_service_should_auto_start() {
lock(); lock();
ensureSettingsLoaded(); ensureSettingsLoaded();
const bool enabled = settings.enabled; const bool enabled = globals().settings.enabled;
unlock(); unlock();
return enabled; return enabled;
} }
@ -781,10 +790,10 @@ error_t mesh_backend_register(const MeshBackendApi* api) {
error_t result = ERROR_NONE; error_t result = ERROR_NONE;
if (findBackend(api->id) != nullptr) { if (findBackend(api->id) != nullptr) {
result = ERROR_ALREADY_EXISTS; result = ERROR_ALREADY_EXISTS;
} else if (backendCount >= MAX_BACKENDS) { } else if (globals().backendCount >= MAX_BACKENDS) {
result = ERROR_RESOURCE; result = ERROR_RESOURCE;
} else { } else {
backends[backendCount++] = api; globals().backends[globals().backendCount++] = api;
if (service != nullptr && service->state == MESH_STATE_NO_BACKEND) { if (service != nullptr && service->state == MESH_STATE_NO_BACKEND) {
MeshHost* host = service; MeshHost* host = service;
post(host, [host] { configure(host); }); post(host, [host] { configure(host); });
@ -797,10 +806,10 @@ error_t mesh_backend_register(const MeshBackendApi* api) {
error_t mesh_backend_unregister(const MeshBackendApi* api) { error_t mesh_backend_unregister(const MeshBackendApi* api) {
lock(); lock();
error_t result = ERROR_NOT_FOUND; error_t result = ERROR_NOT_FOUND;
for (size_t i = 0; i < backendCount; i++) { for (size_t i = 0; i < globals().backendCount; i++) {
if (backends[i] == api) { if (globals().backends[i] == api) {
backends[i] = backends[--backendCount]; globals().backends[i] = globals().backends[--globals().backendCount];
backends[backendCount] = nullptr; globals().backends[globals().backendCount] = nullptr;
result = ERROR_NONE; result = ERROR_NONE;
break; break;
} }
@ -947,7 +956,7 @@ error_t mesh_host_get_data_path(MeshHost* host, const char* child_path, char* ou
void mesh_host_get_node_name(MeshHost*, char* name, size_t name_size) { void mesh_host_get_node_name(MeshHost*, char* name, size_t name_size) {
lock(); lock();
ensureSettingsLoaded(); ensureSettingsLoaded();
strncpy(name, settings.node_name, name_size - 1); strncpy(name, globals().settings.node_name, name_size - 1);
name[name_size - 1] = '\0'; name[name_size - 1] = '\0';
unlock(); unlock();
} }
@ -980,8 +989,8 @@ const char* mesh_state_to_string(MeshState state) {
error_t mesh_set_enabled(bool enabled) { error_t mesh_set_enabled(bool enabled) {
lock(); lock();
ensureSettingsLoaded(); ensureSettingsLoaded();
settings.enabled = enabled; globals().settings.enabled = enabled;
const MeshSettings copy = settings; const MeshSettings copy = globals().settings;
unlock(); unlock();
const error_t saved = mesh_settings_save(&copy); const error_t saved = mesh_settings_save(&copy);
@ -992,7 +1001,7 @@ error_t mesh_set_enabled(bool enabled) {
bool mesh_is_enabled(void) { bool mesh_is_enabled(void) {
lock(); lock();
ensureSettingsLoaded(); ensureSettingsLoaded();
const bool enabled = settings.enabled; const bool enabled = globals().settings.enabled;
unlock(); unlock();
return enabled; return enabled;
} }
@ -1010,9 +1019,9 @@ error_t mesh_set_node_name(const char* name) {
} }
lock(); lock();
ensureSettingsLoaded(); ensureSettingsLoaded();
strncpy(settings.node_name, name, MESH_NAME_MAX_LENGTH); strncpy(globals().settings.node_name, name, MESH_NAME_MAX_LENGTH);
settings.node_name[MESH_NAME_MAX_LENGTH] = '\0'; globals().settings.node_name[MESH_NAME_MAX_LENGTH] = '\0';
const MeshSettings copy = settings; const MeshSettings copy = globals().settings;
unlock(); unlock();
return mesh_settings_save(&copy); return mesh_settings_save(&copy);
} }
@ -1036,7 +1045,7 @@ bool mesh_is_available(void) {
size_t mesh_get_backend_count(void) { size_t mesh_get_backend_count(void) {
lock(); lock();
const size_t count = backendCount; const size_t count = globals().backendCount;
unlock(); unlock();
return count; return count;
} }
@ -1044,8 +1053,8 @@ size_t mesh_get_backend_count(void) {
error_t mesh_get_backend_id(size_t index, char* id, size_t id_size) { error_t mesh_get_backend_id(size_t index, char* id, size_t id_size) {
lock(); lock();
error_t result = ERROR_NOT_FOUND; error_t result = ERROR_NOT_FOUND;
if (index < backendCount && id_size > 0) { if (index < globals().backendCount && id_size > 0) {
strncpy(id, backends[index]->id, id_size - 1); strncpy(id, globals().backends[index]->id, id_size - 1);
id[id_size - 1] = '\0'; id[id_size - 1] = '\0';
result = ERROR_NONE; result = ERROR_NONE;
} }
@ -1060,12 +1069,12 @@ error_t mesh_set_backend(const char* id) {
return ERROR_NOT_FOUND; return ERROR_NOT_FOUND;
} }
ensureSettingsLoaded(); ensureSettingsLoaded();
if (strncmp(settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH) != 0) { if (strncmp(globals().settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH) != 0) {
settings.preset[0] = '\0'; globals().settings.preset[0] = '\0';
} }
strncpy(settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH); strncpy(globals().settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH);
settings.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; globals().settings.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0';
const MeshSettings copy = settings; const MeshSettings copy = globals().settings;
if (service != nullptr) { if (service != nullptr) {
MeshHost* host = service; MeshHost* host = service;
post(host, [host] { configure(host); }); post(host, [host] { configure(host); });
@ -1077,7 +1086,7 @@ error_t mesh_set_backend(const char* id) {
error_t mesh_get_backend(char* id, size_t id_size) { error_t mesh_get_backend(char* id, size_t id_size) {
lock(); lock();
ensureSettingsLoaded(); ensureSettingsLoaded();
const char* selected = settings.backend; const char* selected = globals().settings.backend;
if (service != nullptr && service->activeBackend[0] != '\0') { if (service != nullptr && service->activeBackend[0] != '\0') {
selected = service->activeBackend; selected = service->activeBackend;
} }
@ -1123,9 +1132,9 @@ error_t mesh_set_preset(const char* name) {
unlock(); unlock();
return ERROR_NOT_FOUND; return ERROR_NOT_FOUND;
} }
strncpy(settings.preset, name, MESH_NAME_MAX_LENGTH); strncpy(globals().settings.preset, name, MESH_NAME_MAX_LENGTH);
settings.preset[MESH_NAME_MAX_LENGTH] = '\0'; globals().settings.preset[MESH_NAME_MAX_LENGTH] = '\0';
const MeshSettings copy = settings; const MeshSettings copy = globals().settings;
if (service != nullptr) { if (service != nullptr) {
MeshHost* host = service; MeshHost* host = service;
post(host, [host] { configure(host); }); post(host, [host] { configure(host); });
@ -1140,7 +1149,7 @@ error_t mesh_get_preset(char* name, size_t name_size) {
const MeshBackendApi* api = getSelectedBackend(); const MeshBackendApi* api = getSelectedBackend();
error_t result = ERROR_NOT_FOUND; error_t result = ERROR_NOT_FOUND;
if (api != nullptr && getPresetCount(api) > 0 && name_size > 0) { if (api != nullptr && getPresetCount(api) > 0 && name_size > 0) {
strncpy(name, api->presets[findPreset(api, settings.preset)], name_size - 1); strncpy(name, api->presets[findPreset(api, globals().settings.preset)], name_size - 1);
name[name_size - 1] = '\0'; name[name_size - 1] = '\0';
result = ERROR_NONE; result = ERROR_NONE;
} }
@ -1168,7 +1177,7 @@ error_t mesh_send_text(const MeshAddress* to, uint8_t channel, const char* text,
message.peer = *to; message.peer = *to;
} }
message.channel = channel; message.channel = channel;
strncpy(message.sender_name, settings.node_name, MESH_NAME_MAX_LENGTH); strncpy(message.sender_name, globals().settings.node_name, MESH_NAME_MAX_LENGTH);
const int64_t now = (int64_t)time(nullptr); const int64_t now = (int64_t)time(nullptr);
message.wall_time = (now >= MIN_VALID_WALL_TIME) ? now : 0; message.wall_time = (now >= MIN_VALID_WALL_TIME) ? now : 0;
message.time_source = (now >= MIN_VALID_WALL_TIME) ? MESH_TIME_SOURCE_SYSTEM : MESH_TIME_SOURCE_NONE; message.time_source = (now >= MIN_VALID_WALL_TIME) ? MESH_TIME_SOURCE_SYSTEM : MESH_TIME_SOURCE_NONE;
@ -1295,7 +1304,7 @@ error_t mesh_event_subscribe(MeshEventSubscription* sub, TaskEventGroup* event_g
} }
lock(); lock();
for (auto* existing = subscriptions; existing != nullptr; existing = existing->internal.next) { for (auto* existing = globals().subscriptions; existing != nullptr; existing = existing->internal.next) {
if (existing == sub) { if (existing == sub) {
unlock(); unlock();
task_event_group_release_bit(event_group, bit); task_event_group_release_bit(event_group, bit);
@ -1306,8 +1315,8 @@ error_t mesh_event_subscribe(MeshEventSubscription* sub, TaskEventGroup* event_g
sub->internal.event_group = event_group; sub->internal.event_group = event_group;
sub->internal.head = 0; sub->internal.head = 0;
sub->internal.count = 0; sub->internal.count = 0;
sub->internal.next = subscriptions; sub->internal.next = globals().subscriptions;
subscriptions = sub; globals().subscriptions = sub;
unlock(); unlock();
return ERROR_NONE; return ERROR_NONE;
} }
@ -1315,7 +1324,7 @@ error_t mesh_event_subscribe(MeshEventSubscription* sub, TaskEventGroup* event_g
error_t mesh_event_unsubscribe(MeshEventSubscription* sub) { error_t mesh_event_unsubscribe(MeshEventSubscription* sub) {
error_t result = ERROR_NOT_FOUND; error_t result = ERROR_NOT_FOUND;
lock(); lock();
for (auto** link = &subscriptions; *link != nullptr; link = &(*link)->internal.next) { for (auto** link = &globals().subscriptions; *link != nullptr; link = &(*link)->internal.next) {
if (*link == sub) { if (*link == sub) {
*link = sub->internal.next; *link = sub->internal.next;
result = ERROR_NONE; result = ERROR_NONE;

View File

@ -275,13 +275,41 @@ bool ble_hid_switch_profile(struct Device* device, BleHidProfile profile) {
ble_gap_adv_stop(); ble_gap_adv_stop();
// NimBLE only allows GATT changes without discovery, which a repeating scan keeps starting
if (ble_gap_disc_active()) {
ble_gap_disc_cancel();
if (ble_get_scan_active(device)) {
ble_set_scan_active(device, false);
struct BtEvent finished = {};
finished.type = BT_EVENT_SCAN_FINISHED;
ble_publish_event(device, finished);
}
}
// A connection that is being set up, such as an auto-connect to a keyboard, blocks it too
if (ble_gap_conn_active()) {
ble_gap_conn_cancel();
}
BleHidDeviceCtx* hid_ctx = (BleHidDeviceCtx*)device_get_driver_data(device); BleHidDeviceCtx* hid_ctx = (BleHidDeviceCtx*)device_get_driver_data(device);
if (hid_ctx && hid_ctx->hid_conn_handle.load() != BLE_HS_CONN_HANDLE_NONE) { if (hid_ctx && hid_ctx->hid_conn_handle.load() != BLE_HS_CONN_HANDLE_NONE) {
ble_gap_terminate(hid_ctx->hid_conn_handle.load(), BLE_ERR_REM_USER_CONN_TERM); ble_gap_terminate(hid_ctx->hid_conn_handle.load(), BLE_ERR_REM_USER_CONN_TERM);
hid_ctx->hid_conn_handle.store(BLE_HS_CONN_HANDLE_NONE); hid_ctx->hid_conn_handle.store(BLE_HS_CONN_HANDLE_NONE);
} }
ble_gatts_reset(); // ble_svc_gap_init() asserts when NimBLE refuses the change, so check first
if (ble_gap_adv_active() || ble_gap_disc_active() || ble_gap_conn_active()) {
LOG_W(TAG, "switchGattProfile: GAP procedure still active (adv=%d disc=%d conn=%d)",
ble_gap_adv_active(), ble_gap_disc_active(), ble_gap_conn_active());
return false;
}
// Refuses with BLE_HS_EBUSY while a connection exists, which ble_svc_gap_init() would assert on
const int reset_rc = ble_gatts_reset();
if (reset_rc != 0) {
LOG_W(TAG, "switchGattProfile: ble_gatts_reset failed rc=%d", reset_rc);
return false;
}
ble_svc_gap_init(); ble_svc_gap_init();
ble_svc_gatt_init(); ble_svc_gatt_init();
@ -357,6 +385,11 @@ void ble_hid_init_gatt() {
// stored as its driver data. // stored as its driver data.
static error_t hid_device_start(struct Device* device, enum BtHidDeviceMode mode) { static error_t hid_device_start(struct Device* device, enum BtHidDeviceMode mode) {
// NimBLE can't change GATT services while connected, and the HID host holds a connection
if (ble_hid_get_host_active(device)) {
LOG_W(TAG, "Can't start the HID device while a HID host connection is active");
return ERROR_INVALID_STATE;
}
// Clean up any leftover context from a previous session (e.g. BLE was disabled // Clean up any leftover context from a previous session (e.g. BLE was disabled
// while connected: dispatch_disable() skips the normal hid_device_stop cleanup). // while connected: dispatch_disable() skips the normal hid_device_stop cleanup).
BleHidDeviceCtx* old_ctx = (BleHidDeviceCtx*)device_get_driver_data(device); BleHidDeviceCtx* old_ctx = (BleHidDeviceCtx*)device_get_driver_data(device);

View File

@ -2,6 +2,8 @@
#include <tactility/drivers/radio.h> #include <tactility/drivers/radio.h>
#include <string>
namespace tt::settings::radio { namespace tt::settings::radio {
/** @return the persisted regulatory region, RADIO_REGION_UNSET when none was chosen */ /** @return the persisted regulatory region, RADIO_REGION_UNSET when none was chosen */
@ -19,4 +21,10 @@ const char* getRegionLabel(RadioRegion region);
/** @return true when the devicetree has a radio, whether it's started or not */ /** @return true when the devicetree has a radio, whether it's started or not */
bool hasRadio(); bool hasRadio();
/**
* @param[in] timeZoneName an IANA time zone name, e.g. "Europe/Amsterdam"
* @return the region of the most common radio band where that time zone is used, RADIO_REGION_UNSET when unknown
*/
RadioRegion findRegionForTimeZone(const std::string& timeZoneName);
} }

View File

@ -62,7 +62,8 @@ public:
void setSwipeNavigation(bool enabled) { swipeNavigation = enabled; } void setSwipeNavigation(bool enabled) { swipeNavigation = enabled; }
/** /**
* Creates the grid and populates it. Call setPageButtons() right after it to add page buttons. * Creates the grid and populates it, with a page indicator below it that is hidden when there's only one page.
* Call setPageButtons() right after it to add page buttons.
* @param[in] parent the container for the grid, with a flex column layout * @param[in] parent the container for the grid, with a flex column layout
*/ */
void createWidgets(lv_obj_t* parent); void createWidgets(lv_obj_t* parent);

View File

@ -33,6 +33,8 @@ public:
View(Bindings* bindings, State* state) : bindings(bindings), state(state) {} View(Bindings* bindings, State* state) : bindings(bindings), state(state) {}
void init(void* context, lv_obj_t* parent); void init(void* context, lv_obj_t* parent);
/** Forgets the widgets after their window was buried and deleted. Call with the LVGL lock held. */
void destroyWidgets();
void update(); void update();
}; };

View File

@ -0,0 +1,83 @@
#pragma once
#include <Tactility/Mutex.h>
#include <Tactility/Thread.h>
#include <Tactility/Timer.h>
#include <lvgl.h>
#include <atomic>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace tt::app::timezone {
/**
* A searchable list of the time zones, with a button that detects the time zone from the location.
* Its state survives rebuilding its widgets, e.g. when a window is covered and shown again.
*/
class TimeZoneView final {
public:
/** Called with the LVGL lock held when the user picks a time zone, or when one was detected */
using OnSelected = std::function<void(const std::string& name, const std::string& code)>;
explicit TimeZoneView(OnSelected onSelected);
/** Cancels a running detection and waits for its lookup to end, which can take a few seconds */
~TimeZoneView();
TimeZoneView(const TimeZoneView&) = delete;
TimeZoneView& operator=(const TimeZoneView&) = delete;
/** Creates the widgets in @a parent, a flex column, with the LVGL lock held. The list grows to fill it. */
void createWidgets(lv_obj_t* parent);
/** Call with the LVGL lock held when the widgets were deleted */
void destroyWidgets();
/** Shows this time zone as the selected one */
void setSelectedName(const std::string& name);
private:
struct Entry {
std::string name;
std::string code;
};
OnSelected onSelected;
Mutex mutex;
// Guarded by mutex
std::vector<Entry> entries;
std::string filter;
std::string selectedName;
std::string message;
std::unique_ptr<Timer> filterTimer;
std::unique_ptr<Thread> detectThread;
std::atomic<bool> detecting = false;
// Set when the view is destroyed, so a running detection ends without using it
std::atomic<bool> cancelled = false;
// Valid while the widgets exist, guarded by the LVGL lock
lv_obj_t* textarea = nullptr;
lv_obj_t* detectButton = nullptr;
lv_obj_t* detectSpinner = nullptr;
lv_obj_t* messageLabel = nullptr;
lv_obj_t* list = nullptr;
static void onFilterChanged(lv_event_t* event);
static void onDetectPressed(lv_event_t* event);
static void onEntryPressed(lv_event_t* event);
void loadEntries();
void rebuildList();
void showState();
void detect();
void select(const std::string& name, const std::string& code);
};
}

View File

@ -1,55 +0,0 @@
#pragma once
#include "Tactility/TactilityConfig.h"
#if TT_FEATURE_SCREENSHOT_ENABLED
#include "ScreenshotTask.h"
#include <Tactility/Mutex.h>
#include <Tactility/service/Service.h>
namespace tt::service::screenshot {
enum class Mode {
None,
Timed,
Apps
};
class ScreenshotService final : public Service {
Mutex mutex;
std::unique_ptr<ScreenshotTask> task;
Mode mode = Mode::None;
public:
bool onStart(ServiceContext& serviceContext) override;
bool isTaskStarted();
/** The state of the service. */
Mode getMode() const;
/** @brief Start taking screenshot whenever an app is started
* @param[in] path the path to store the screenshots at
*/
void startApps(const std::string& path);
/** @brief Start taking screenshots after a certain delay
* @param[in] path the path to store the screenshots at
* @param[in] delayInSeconds the delay before starting (and between successive screenshots)
* @param[in] amount 0 = indefinite, >0 for a specific
*/
void startTimed(const std::string& path, uint8_t delayInSeconds, uint8_t amount);
/** @brief Stop taking screenshots */
void stop();
};
std::shared_ptr<ScreenshotService> optScreenshotService();
} // namespace
#endif

View File

@ -1,62 +0,0 @@
#include <Tactility/TactilityConfig.h>
#if TT_FEATURE_SCREENSHOT_ENABLED
#pragma once
#include <Tactility/Thread.h>
#include <Tactility/RecursiveMutex.h>
namespace tt::service::screenshot {
#define TASK_WORK_TYPE_DELAY 1
#define TASK_WORK_TYPE_APPS 2
class ScreenshotTask {
struct ScreenshotTaskWork {
int type = TASK_WORK_TYPE_DELAY ;
uint8_t delay_in_seconds = 0;
uint8_t amount = 0;
std::string path;
};
Thread* thread = nullptr;
RecursiveMutex mutex;
bool interrupted = false;
bool finished = false;
ScreenshotTaskWork work;
public:
ScreenshotTask() = default;
~ScreenshotTask();
/** @brief Start taking screenshots after a certain delay
* @param[in] path the path to store the screenshots at
* @param[in] delayInSeconds the delay before starting (and between successive screenshots)
* @param[in] amount 0 = indefinite, >0 for a specific
*/
void startTimed(const std::string& path, uint8_t delayInSeconds, uint8_t amount);
/** @brief Start taking screenshot whenever an app is started
* @param[in] path the path to store the screenshots at
*/
void startApps(const std::string& path);
/** @brief Stop taking screenshots */
void stop();
void taskMain();
bool isFinished();
private:
bool isInterrupted();
void setFinished();
void taskStart();
};
}
#endif

View File

@ -90,6 +90,40 @@ void updatePageIndicatorWidth(void* data) {
} }
} }
// A page indicator below the grid gets the width of its parent.
// It shows the page number when its dots are wider than its max width, which must be in pixels.
void updateCenteredPageIndicatorWidth(lv_obj_t* indicator) {
const int32_t margins = lv_obj_get_style_margin_left(indicator, LV_PART_MAIN) + lv_obj_get_style_margin_right(indicator, LV_PART_MAIN);
const int32_t max_width = std::max<int32_t>(0, lv_obj_get_content_width(lv_obj_get_parent(indicator)) - margins);
// Setting a style marks the layout as dirty, which would resize the parent and call this again
if (lv_obj_get_style_max_width(indicator, LV_PART_MAIN) != max_width) {
lv_obj_set_style_max_width(indicator, max_width, LV_STATE_DEFAULT);
}
}
void onCenteredPageIndicatorParentSizeChanged(lv_event_t* event) {
updateCenteredPageIndicatorWidth(static_cast<lv_obj_t*>(lv_event_get_user_data(event)));
}
void onCenteredPageIndicatorDeleted(lv_event_t* event) {
lv_obj_t* indicator = lv_event_get_target_obj(event);
lv_obj_remove_event_cb_with_user_data(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, indicator);
}
/** Call after moving the page indicator below the grid */
void centerPageIndicator(lv_obj_t* indicator) {
lv_obj_set_width(indicator, LV_PCT(100));
lv_obj_add_event_cb(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, LV_EVENT_SIZE_CHANGED, indicator);
lv_obj_add_event_cb(indicator, onCenteredPageIndicatorDeleted, LV_EVENT_DELETE, nullptr);
updateCenteredPageIndicatorWidth(indicator);
}
/** Call before moving the page indicator away from below the grid */
void uncenterPageIndicator(lv_obj_t* indicator) {
lv_obj_remove_event_cb_with_user_data(lv_obj_get_parent(indicator), onCenteredPageIndicatorParentSizeChanged, indicator);
lv_obj_remove_event_cb(indicator, onCenteredPageIndicatorDeleted);
}
void onBottomBarSizeChanged(lv_event_t* event) { void onBottomBarSizeChanged(lv_event_t* event) {
lv_async_call_cancel(updatePageIndicatorWidth, lv_event_get_target_obj(event)); lv_async_call_cancel(updatePageIndicatorWidth, lv_event_get_target_obj(event));
lv_async_call(updatePageIndicatorWidth, lv_event_get_target_obj(event)); lv_async_call(updatePageIndicatorWidth, lv_event_get_target_obj(event));
@ -446,6 +480,11 @@ void TileGrid::createWidgets(lv_obj_t* parent) {
barSideContainer = nullptr; barSideContainer = nullptr;
barButtons.clear(); barButtons.clear();
createGrid(parent); createGrid(parent);
// Centered below the grid
pageIndicator = lvgl_page_indicator_create(parent);
centerPageIndicator(pageIndicator);
// Keeps the dots off the screen's bottom edge
lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? LV_DPX(2) : LV_DPX(8), LV_STATE_DEFAULT);
finishCreate(parent); finishCreate(parent);
} }
@ -526,8 +565,7 @@ void TileGrid::setBarSideContainer(lv_obj_t* sideContainer) {
// The page indicator is centered below the grid // The page indicator is centered below the grid
lv_obj_set_parent(pageIndicator, barParent); lv_obj_set_parent(pageIndicator, barParent);
lv_obj_move_to_index(pageIndicator, lv_obj_get_index(grid) + 1); lv_obj_move_to_index(pageIndicator, lv_obj_get_index(grid) + 1);
lv_obj_set_width(pageIndicator, LV_PCT(100)); centerPageIndicator(pageIndicator);
lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MAX_WIDTH, LV_PART_MAIN);
// Keeps the dots off the screen's bottom edge // Keeps the dots off the screen's bottom edge
lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? 1 : 2, LV_STATE_DEFAULT); lv_obj_set_style_margin_bottom(pageIndicator, lvgl_theme_is_compact() ? 1 : 2, LV_STATE_DEFAULT);
} else { } else {
@ -538,9 +576,13 @@ void TileGrid::setBarSideContainer(lv_obj_t* sideContainer) {
lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_BG_OPA, LV_PART_MAIN); lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_BG_OPA, LV_PART_MAIN);
lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_PAD_ROW, LV_PART_MAIN); lv_obj_remove_local_style_prop(bottomBar, LV_STYLE_PAD_ROW, LV_PART_MAIN);
lv_obj_set_hidden(barSpacer, false); lv_obj_set_hidden(barSpacer, false);
uncenterPageIndicator(pageIndicator);
lv_obj_set_parent(pageIndicator, bottomBar); lv_obj_set_parent(pageIndicator, bottomBar);
lv_obj_set_width(pageIndicator, LV_SIZE_CONTENT); lv_obj_set_width(pageIndicator, LV_SIZE_CONTENT);
lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MARGIN_BOTTOM, LV_PART_MAIN); lv_obj_remove_local_style_prop(pageIndicator, LV_STYLE_MARGIN_BOTTOM, LV_PART_MAIN);
// The bar's size can stay the same, which wouldn't update the max width
lv_async_call_cancel(updatePageIndicatorWidth, bottomBar);
lv_async_call(updatePageIndicatorWidth, bottomBar);
} }
// The keys move through the bar where it's shown: below the statusbar's icons in the side strip, or below the grid // The keys move through the bar where it's shown: below the statusbar's icons in the side strip, or below the grid
setGroupOrder(grid, bottomBar, sideContainer != nullptr); setGroupOrder(grid, bottomBar, sideContainer != nullptr);

View File

@ -1,4 +1,5 @@
#include <Tactility/Mutex.h> #include <Tactility/Mutex.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/apphubdetails/AppHubDetailsApp.h> #include <Tactility/app/apphubdetails/AppHubDetailsApp.h>
#include <app/event.h> #include <app/event.h>
@ -14,7 +15,9 @@
#include <tactility/drivers/wifi.h> #include <tactility/drivers/wifi.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h> #include <lvgl/grid_navigation.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h> #include <lvgl/widgets/card.h>
#include <lvgl/widgets/chip.h> #include <lvgl/widgets/chip.h>
@ -50,6 +53,8 @@ struct Context {
lv_obj_t* contentWrapper = nullptr; lv_obj_t* contentWrapper = nullptr;
lv_obj_t* refreshButton = nullptr; lv_obj_t* refreshButton = nullptr;
lv_obj_t* updateAllButton = nullptr;
lv_obj_t* updateAllSpinner = nullptr;
lv_obj_t* filterChips = nullptr; lv_obj_t* filterChips = nullptr;
// Survives across a bury/resurface cycle // Survives across a bury/resurface cycle
Filter filter = Filter::All; Filter filter = Filter::All;
@ -74,6 +79,10 @@ struct Context {
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 */
uint32_t updateAllOperationId = 0;
/** The ids of the apps that failed to update during updateAllOperationId */
std::vector<std::string> failedUpdates;
}; };
@ -103,6 +112,30 @@ void updateFilterChips(Context* ctx) {
} }
} }
/**
* Shows the spinner while App Hub is busy, and otherwise the update all button when there are updates.
* Requires the LVGL lock.
*/
void updateToolbarActions(Context* ctx, bool hasUpdates) {
if (ctx->updateAllSpinner == nullptr) {
return;
}
const bool busy = app_hub_is_busy();
lv_obj_set_hidden(ctx->updateAllSpinner, !busy);
lv_obj_set_hidden(ctx->updateAllButton, busy || !hasUpdates);
}
void onUpdateAllPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
const error_t error = app_hub_update_all(nullptr);
if (error != ERROR_NONE) {
LOG_E(TAG, "Failed to queue the update of all apps: %s", error_to_string(error));
return;
}
// Shows the spinner
showApps(ctx);
}
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");
@ -189,6 +222,7 @@ void showRefreshFailedError(Context* ctx, const char* message) {
lv_label_set_text(label, message); lv_label_set_text(label, message);
lv_obj_set_hidden(ctx->refreshButton, false); lv_obj_set_hidden(ctx->refreshButton, false);
updateToolbarActions(ctx, false);
} }
void showNoInternet(Context* ctx) { void showNoInternet(Context* ctx) {
@ -236,6 +270,7 @@ void showApps(Context* ctx) {
}, &apps); }, &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; }));
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);
}); });
@ -256,8 +291,11 @@ void showApps(Context* ctx) {
lv_obj_t* focus_target = nullptr; lv_obj_t* focus_target = nullptr;
for (int i = 0; i < apps.size(); i++) { for (int i = 0; i < apps.size(); i++) {
const auto& app = apps[i]; const auto& app = apps[i];
const char* icon = app.installed ? LV_SYMBOL_OK : nullptr; const char* icon = app.operation_pending ? LVGL_ICON_SHARED_HOURGLASS : (app.installed ? LV_SYMBOL_OK : nullptr);
auto* entry_button = lvgl_list_add_button(list, icon, app.name); auto* entry_button = lvgl_list_add_button(list, icon, app.name);
if (app.operation_pending) {
lv_obj_set_style_text_font(lv_obj_get_child(entry_button, 0), lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
}
auto int_as_voidptr = reinterpret_cast<void*>(i); auto int_as_voidptr = reinterpret_cast<void*>(i);
lv_obj_set_user_data(entry_button, int_as_voidptr); lv_obj_set_user_data(entry_button, int_as_voidptr);
lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx); lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx);
@ -331,7 +369,17 @@ void refresh(Context* ctx) {
lvgl_unlock(); lvgl_unlock();
} }
void onOperationFinished(Context* ctx, const AppHubEvent& event) { void onOperationEvent(Context* ctx, const AppHubEvent& event) {
if (event.type == APP_HUB_EVENT_REFRESHING) {
// The list doesn't change yet, only the toolbar shows that App Hub is busy
lvgl_lock();
if (ctx->updateAllButton != nullptr) {
updateToolbarActions(ctx, !lv_obj_is_hidden(ctx->updateAllButton));
}
lvgl_unlock();
return;
}
const bool isRefresh = event.operation_id == ctx->refreshOperationId; const bool isRefresh = event.operation_id == ctx->refreshOperationId;
if (isRefresh) { if (isRefresh) {
ctx->refreshOperationId = 0; ctx->refreshOperationId = 0;
@ -341,13 +389,10 @@ void onOperationFinished(Context* ctx, const AppHubEvent& event) {
} }
} }
if (ctx->contentWrapper == nullptr) {
// Buried (e.g. AppHubDetailsApp is open): createWidgets() rebuilds the list on resurface
return;
}
lvgl_lock(); lvgl_lock();
if (isRefresh && event.result != ERROR_NONE) { if (ctx->contentWrapper == nullptr) {
// Buried (e.g. AppHubDetailsApp or a dialog is open): createWidgets() rebuilds the list on resurface
} else if (isRefresh && event.result != ERROR_NONE) {
showRefreshFailedError(ctx, "Cannot reach server"); showRefreshFailedError(ctx, "Cannot reach server");
} else { } else {
showApps(ctx); showApps(ctx);
@ -366,6 +411,10 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx); ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx);
lv_obj_set_hidden(ctx->refreshButton, true); lv_obj_set_hidden(ctx->refreshButton, true);
ctx->updateAllButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onUpdateAllPressed, ctx);
lv_obj_set_hidden(ctx->updateAllButton, true);
ctx->updateAllSpinner = lvgl_toolbar_add_spinner_action(toolbar);
lv_obj_set_hidden(ctx->updateAllSpinner, true);
ctx->filterChips = createFilterChips(parent, ctx); ctx->filterChips = createFilterChips(parent, ctx);
updateFilterChips(ctx); updateFilterChips(ctx);
@ -391,9 +440,36 @@ void destroyWidgets(void* userData) {
ctx->scrollY = lv_obj_get_scroll_y(ctx->contentWrapper); ctx->scrollY = lv_obj_get_scroll_y(ctx->contentWrapper);
ctx->contentWrapper = nullptr; ctx->contentWrapper = nullptr;
ctx->refreshButton = nullptr; ctx->refreshButton = nullptr;
ctx->updateAllButton = nullptr;
ctx->updateAllSpinner = nullptr;
ctx->filterChips = nullptr; ctx->filterChips = nullptr;
} }
/** Tracks the update of all apps, and shows which apps failed when it's done */
void onUpdateAllEvent(Context* ctx, const AppHubEvent& event) {
if (event.type == APP_HUB_EVENT_UPDATING) {
ctx->updateAllOperationId = event.operation_id;
ctx->failedUpdates.clear();
} else if (event.type == APP_HUB_EVENT_INSTALLED && event.operation_id == ctx->updateAllOperationId && event.result != ERROR_NONE) {
ctx->failedUpdates.emplace_back(event.app_id);
} else if (event.type == APP_HUB_EVENT_UPDATED && event.operation_id == ctx->updateAllOperationId) {
ctx->updateAllOperationId = 0;
if (!ctx->failedUpdates.empty()) {
std::string names;
for (const auto& id : ctx->failedUpdates) {
AppHubApp app {};
if (!names.empty()) {
names += ", ";
}
names += (app_hub_get_app(id.c_str(), &app) == ERROR_NONE) ? app.name : id.c_str();
}
const auto count = ctx->failedUpdates.size();
alertdialog::start(ctx->appInstanceId, "Update failed", std::format("{} {} failed to update: {}", count, count == 1 ? "app" : "apps", names));
ctx->failedUpdates.clear();
}
}
}
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id(); uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx; Context ctx;
@ -429,11 +505,8 @@ int32_t appMain(int argc, char* argv[]) {
AppHubEvent app_hub_event {}; AppHubEvent app_hub_event {};
while (app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) { while (app_hub_event_poll(&ctx.appHubSub, &app_hub_event) == ERROR_NONE) {
if (app_hub_event.type == APP_HUB_EVENT_REFRESHED || onUpdateAllEvent(&ctx, app_hub_event);
app_hub_event.type == APP_HUB_EVENT_INSTALLED || onOperationEvent(&ctx, app_hub_event);
app_hub_event.type == APP_HUB_EVENT_REMOVED) {
onOperationFinished(&ctx, app_hub_event);
}
} }
if (!shouldClose && ctx.refreshRequested.exchange(false)) { if (!shouldClose && ctx.refreshRequested.exchange(false)) {

View File

@ -136,6 +136,10 @@ void onOperationEvent(Context* ctx, const AppHubEvent& event) {
} }
void updateViews(Context* ctx) { void updateViews(Context* ctx) {
// Covered by another window (e.g. a dialog): createWidgets() updates the views when it's shown again
if (ctx->toolbar == nullptr) {
return;
}
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);
@ -200,6 +204,14 @@ void createWidgets(lv_obj_t* parent, void* userData) {
updateViews(ctx); updateViews(ctx);
} }
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->toolbar = nullptr;
ctx->spinner = nullptr;
ctx->updateButton = nullptr;
ctx->updateLabel = nullptr;
}
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id(); uint32_t appInstanceId = app_scheduler_current_app_id();
// argv layout: [0]=appId // argv layout: [0]=appId
@ -218,7 +230,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);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false; bool shouldClose = false;
while (!shouldClose) { while (!shouldClose) {

View File

@ -151,6 +151,11 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->unlock(); ctx->unlock();
} }
// Runs with the LVGL lock held and must not take other locks
void destroyWidgets(void* userData) {
static_cast<Context*>(userData)->view.destroyWidgets();
}
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id(); uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx; Context ctx;
@ -189,7 +194,7 @@ int32_t appMain(int argc, char* argv[]) {
} }
} }
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
auto radio_state = bluetooth::getRadioState(); auto radio_state = bluetooth::getRadioState();
bool can_scan = radio_state == bluetooth::RadioState::On; bool can_scan = radio_state == bluetooth::RadioState::On;

View File

@ -165,11 +165,15 @@ void View::updatePeerList() {
// Scan results section // Scan results section
auto scan_results = state->getScanResults(); auto scan_results = state->getScanResults();
auto* available_section = createSection(peers_list, "Available"); auto* available_section = createSection(peers_list, "Available");
if (!scan_results.empty()) { // Devices without a name are skipped. The index stays that of the scan results, which the click handler uses.
for (size_t i = 0; i < scan_results.size(); ++i) { bool hasNamedResults = false;
for (size_t i = 0; i < scan_results.size(); ++i) {
if (!scan_results[i].name.empty()) {
createPeerListItem(available_section, scan_results[i], false, i); createPeerListItem(available_section, scan_results[i], false, i);
hasNamedResults = true;
} }
} else if (!state->isScanning()) { }
if (!hasNamedResults && !state->isScanning()) {
lvgl_list_add_text(available_section, "No devices found."); lvgl_list_add_text(available_section, "No devices found.");
} }
// Never hide peers_list: it always contains the "Enable on boot" row. // Never hide peers_list: it always contains the "Enable on boot" row.
@ -212,7 +216,18 @@ void View::init(void* newContext, lv_obj_t* parent) {
lv_obj_set_style_border_width(peers_list, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(peers_list, 0, LV_STATE_DEFAULT);
} }
void View::destroyWidgets() {
root = nullptr;
enable_switch = nullptr;
scanning_spinner = nullptr;
peers_list = nullptr;
}
void View::update() { void View::update() {
// The widgets are gone while another window covers this one
if (root == nullptr) {
return;
}
updateBtToggle(); updateBtToggle();
updateScanning(); updateScanning();
updatePeerList(); updatePeerList();

View File

@ -33,7 +33,6 @@ constexpr IconEntry ICONS[] = {
{"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE},
{"tactility.meshinspect", LVGL_ICON_SHARED_LISTS}, {"tactility.meshinspect", LVGL_ICON_SHARED_LISTS},
{"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE},
{"tactility.screenshot", LVGL_ICON_SHARED_IMAGE},
{"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES},
{"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL},
{"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD}, {"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD},

View File

@ -1,312 +1,86 @@
#include <Tactility/Tactility.h>
#include <Tactility/TactilityConfig.h> #include <Tactility/TactilityConfig.h>
#if TT_FEATURE_SCREENSHOT_ENABLED #if TT_FEATURE_SCREENSHOT_ENABLED
#include <Tactility/lvgl/Lvgl.h> #include <Tactility/lvgl/Lvgl.h>
#include <Tactility/service/screenshot/Screenshot.h>
#include <Tactility/Timer.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h> #include <app/manifest.h>
#include <app/scheduler.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/filesystem/fs.h> #include <tactility/filesystem/fs.h>
#include <tactility/log.h> #include <tactility/freertos/freertos.h>
#include <tactility/paths.h> #include <tactility/paths.h>
#include <lvgl.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/widgets/card.h> #include <lv_screenshot.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/toolbar.h> #include <cstdio>
#include <string>
namespace tt::app::screenshot { namespace tt::app::screenshot {
constexpr auto* TAG = "Screenshot";
extern const ::AppManifest manifest;
namespace { namespace {
enum class CaptureMode { constexpr TickType_t LVGL_LOCK_TIMEOUT = pdMS_TO_TICKS(1000);
Timer,
AppStart
};
struct ModeOption { /** The default path is in the temp folder, so "tt screenshot" can pull and remove it */
CaptureMode mode; bool getDefaultPath(char* out_path, size_t out_size) {
const char* name; char temp_path[FILE_MAX_PATH_STRING_LENGTH];
}; if (paths_get_temp_path(temp_path, sizeof(temp_path)) != ERROR_NONE) {
return false;
constexpr ModeOption MODE_OPTIONS[] = {
{ CaptureMode::Timer, "Timer" },
{ CaptureMode::AppStart, "App start" },
};
struct Context {
uint32_t appInstanceId;
CaptureMode mode = CaptureMode::Timer;
lv_obj_t* modeChips = nullptr;
lv_obj_t* pathTextArea = nullptr;
lv_obj_t* startStopButtonLabel = nullptr;
// Shown in timer mode
lv_obj_t* delayRow = nullptr;
lv_obj_t* delayTextArea = nullptr;
std::unique_ptr<Timer> updateTimer;
};
void updateScreenshotMode(Context* ctx) {
auto service = service::screenshot::optScreenshotService();
if (service == nullptr) {
LOG_E(TAG, "Service not found/running");
return;
} }
if (!directory_exists(temp_path)) {
lv_obj_t* label = ctx->startStopButtonLabel; const error_t error = directory_make(temp_path, true);
if (service->isTaskStarted()) { if (error != ERROR_NONE && error != ERROR_ALREADY_EXISTS) {
lv_label_set_text(label, "Stop"); return false;
} else {
lv_label_set_text(label, "Start");
}
const uint32_t chip_count = lv_obj_get_child_count(ctx->modeChips);
for (uint32_t i = 0; i < chip_count; i++) {
lv_obj_set_state(lv_obj_get_child(ctx->modeChips, static_cast<int32_t>(i)), LV_STATE_CHECKED, MODE_OPTIONS[i].mode == ctx->mode);
}
lv_obj_set_hidden(ctx->delayRow, ctx->mode != CaptureMode::Timer);
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId);
}
void onStartPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto service = service::screenshot::optScreenshotService();
if (service == nullptr) {
LOG_E(TAG, "Service not found/running");
return;
}
if (service->isTaskStarted()) {
LOG_I(TAG, "Stop screenshot");
service->stop();
updateScreenshotMode(ctx);
return;
}
const char* path = lv_textarea_get_text(ctx->pathTextArea);
error_t result = directory_make(path, true);
if (result != ERROR_NONE && result != ERROR_ALREADY_EXISTS) {
LOG_E(TAG, "Task failed to start: couldn't create directory at %s", path);
return;
}
if (ctx->mode == CaptureMode::Timer) {
LOG_I(TAG, "Start timed screenshots");
const char* delay_text = lv_textarea_get_text(ctx->delayTextArea);
int delay = atoi(delay_text);
if (delay > 0) {
service->startTimed(path, delay, 1);
} else {
LOG_W(TAG, "Ignored screenshot start because delay was 0");
}
} else {
LOG_I(TAG, "Start app screenshots");
service->startApps(path);
}
updateScreenshotMode(ctx);
}
void onModeChipPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->mode = MODE_OPTIONS[lv_obj_get_index(lv_event_get_target_obj(event))].mode;
updateScreenshotMode(ctx);
}
/** A transparent row in a card: "Title [content]" */
lv_obj_t* createRow(lv_obj_t* card, const char* title) {
auto* row = lv_obj_create(card);
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_style_pad_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_scrollable(row, false);
lv_label_set_text(lv_label_create(row), title);
return row;
}
void createModeWidgets(Context* ctx, lv_obj_t* parent) {
auto* title = lv_label_create(parent);
lv_label_set_text(title, "Mode");
lv_obj_set_width(title, LV_PCT(100));
// The chips are centered in the card while they fit, and scroll when they don't
auto* card = lvgl_card_create(parent);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
ctx->modeChips = lv_obj_create(card);
lv_obj_set_size(ctx->modeChips, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_max_width(ctx->modeChips, LV_PCT(100), LV_STATE_DEFAULT);
lv_obj_set_flex_flow(ctx->modeChips, LV_FLEX_FLOW_ROW);
lv_obj_set_scroll_dir(ctx->modeChips, LV_DIR_HOR);
lv_obj_set_style_bg_opa(ctx->modeChips, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ctx->modeChips, 0, LV_STATE_DEFAULT);
// The chips' margins leave room for their focus rings and space them apart
lv_obj_set_style_pad_all(ctx->modeChips, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_column(ctx->modeChips, 0, LV_STATE_DEFAULT);
for (const auto& option : MODE_OPTIONS) {
auto* chip = lvgl_chip_create(ctx->modeChips);
lv_label_set_text(lv_label_create(chip), option.name);
lv_obj_add_event_cb(chip, onModeChipPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
lvgl_grid_navigation_add(ctx->modeChips);
}
void createSettingsWidgets(Context* ctx, lv_obj_t* parent) {
auto* card = lvgl_card_create(parent);
lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
auto* path_row = createRow(card, "Path");
ctx->pathTextArea = lv_textarea_create(path_row);
lv_textarea_set_one_line(ctx->pathTextArea, true);
lv_obj_set_flex_grow(ctx->pathTextArea, 1);
char data_path[FILE_MAX_PATH_STRING_LENGTH];
if (paths_get_data_path(data_path, sizeof(data_path)) == ERROR_NONE) {
std::string lvgl_mount_path = std::string(data_path) + "/screenshots";
lv_textarea_set_text(ctx->pathTextArea, lvgl_mount_path.c_str());
} else {
lv_textarea_set_text(ctx->pathTextArea, "Error: no data path");
}
ctx->delayRow = createRow(card, "Delay");
ctx->delayTextArea = lv_textarea_create(ctx->delayRow);
lv_textarea_set_one_line(ctx->delayTextArea, true);
lv_textarea_set_accepted_chars(ctx->delayTextArea, "0123456789");
lv_textarea_set_text(ctx->delayTextArea, "10");
lv_obj_set_flex_grow(ctx->delayTextArea, 1);
lv_label_set_text(lv_label_create(ctx->delayRow), "seconds");
}
void createStartStopButton(Context* ctx, lv_obj_t* parent) {
auto* button = lv_button_create(parent);
lv_obj_add_event_cb(button, onStartPressed, LV_EVENT_SHORT_CLICKED, ctx);
ctx->startStopButtonLabel = lv_label_create(button);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
if (ctx->updateTimer->isRunning()) {
ctx->updateTimer->stop();
}
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, "Screenshot");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
// The cards stretch, the start/stop button is centered
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
auto service = service::screenshot::optScreenshotService();
if (service != nullptr && service->getMode() == service::screenshot::Mode::Apps) {
ctx->mode = CaptureMode::AppStart;
}
createModeWidgets(ctx, wrapper);
createSettingsWidgets(ctx, wrapper);
createStartStopButton(ctx, wrapper);
updateScreenshotMode(ctx);
if (!ctx->updateTimer->isRunning()) {
ctx->updateTimer->start();
}
}
int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.updateTimer = std::make_unique<Timer>(Timer::Type::Periodic, 500 / portTICK_PERIOD_MS, [&ctx] {
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
updateScreenshotMode(&ctx);
lvgl_unlock();
}
});
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY);
AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
switch (event.type) {
case APP_EVENT_CLOSE:
shouldClose = true;
break;
default:
break;
}
if (shouldClose) break;
} }
} }
const int written = snprintf(out_path, out_size, "%s/screenshot.png", temp_path);
return written > 0 && static_cast<size_t>(written) < out_size;
}
if (ctx.updateTimer->isRunning()) { int32_t main(int argc, char* argv[]) {
ctx.updateTimer->stop(); if (argc > 2 || (argc == 2 && argv[1][0] != '/')) {
printf("usage: screenshot [absolute path of the .png file]\n");
return 1;
} }
window_manager_remove(window); char path[FILE_MAX_PATH_STRING_LENGTH];
check(app_event_unsubscribe(&sub) == ERROR_NONE); if (argc == 2) {
task_event_group_destruct(&event_group); snprintf(path, sizeof(path), "%s", argv[1]);
} else if (!getDefaultPath(path, sizeof(path))) {
printf("screenshot: no storage available\n");
return 1;
}
if (!lvgl_try_lock(LVGL_LOCK_TIMEOUT)) {
printf("screenshot: the display is busy\n");
return 1;
}
// lv_screenshot writes through lv_fs, which requires the drive prefix
const std::string lvgl_path = std::string(lvgl::PATH_PREFIX) + path;
const bool success = lv_screenshot_create(lv_screen_active(), LV_100ASK_SCREENSHOT_SV_PNG, lvgl_path.c_str());
lvgl_unlock();
if (!success) {
printf("screenshot: failed to save %s\n", path);
return 1;
}
printf("%s\n", path);
return 0; return 0;
} }
} // namespace } // namespace
extern const ::AppManifest manifest = { extern const ::AppManifest manifest = {
.id = "tactility.screenshot", .id = "screenshot",
.name = "Screenshot", .name = "screenshot",
.category = APP_CATEGORY_SYSTEM, .category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(main) },
.flags = 0, .flags = APP_MANIFEST_FLAG_HEADLESS,
.stack = {} .stack = { .depth = 8192, .desired_memory_capability = 0 },
}; };
} // namespace } // namespace tt::app::screenshot
#endif #endif

View File

@ -3,11 +3,10 @@
#include <tactility/paths.h> #include <tactility/paths.h>
#include <Tactility/StringUtils.h> #include <Tactility/StringUtils.h>
#include <Tactility/app/timezone/TimeZone.h> #include <Tactility/app/timezone/TimeZoneView.h>
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <Tactility/app/wifimanage/WifiManage.h> #include <Tactility/app/wifimanage/WifiManage.h>
#include <Tactility/file/File.h> #include <Tactility/file/File.h>
#include <Tactility/settings/RadioSettings.h> #include <Tactility/settings/Time.h>
#include <Tactility/Tactility.h> #include <Tactility/Tactility.h>
#include <app/event.h> #include <app/event.h>
@ -27,11 +26,17 @@
#include <tactility/drivers/wifi.h> #include <tactility/drivers/wifi.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <lvgl/devices/indev.h>
#include <lvgl/fonts.h> #include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/step_indicator.h>
#include <lvgl/widgets/text_button.h>
#include <lvgl.h> #include <lvgl.h>
#include <functional> #include <functional>
#include <memory>
#include <vector> #include <vector>
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
@ -92,8 +97,19 @@ struct StepConfiguration {
std::function<void()> run; std::function<void()> run;
/** Optional: handles the result of the dialog that run() started */ /** Optional: handles the result of the dialog that run() started */
std::function<void(int32_t)> onResult = nullptr; std::function<void(int32_t)> onResult = nullptr;
/** What the page shows below its title */
enum class Content {
Description,
/** The description, with the location choices below it */
LocationChoice,
/** The time zone list instead of the description */
TimeZones
} content = Content::Description;
}; };
/** The location choices of the location page, in the order of their chips */
constexpr const char* LOCATION_CHOICES[] = { "Precise", "Approximate", "Disabled" };
struct Context { struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
@ -102,14 +118,134 @@ struct Context {
std::vector<StepConfiguration> steps; std::vector<StepConfiguration> steps;
uint32_t pendingStepDialogId = 0; uint32_t pendingStepDialogId = 0;
// Valid while the window is shown
lv_obj_t* titleLabel = nullptr; lv_obj_t* titleLabel = nullptr;
lv_obj_t* descriptionLabel = nullptr; lv_obj_t* descriptionLabel = nullptr;
lv_obj_t* backButton = nullptr;
lv_obj_t* skipButton = nullptr; lv_obj_t* skipButton = nullptr;
lv_obj_t* continueButton = nullptr; lv_obj_t* continueButton = nullptr;
lv_obj_t* stepIndicator = nullptr;
lv_obj_t* bottom = nullptr;
lv_obj_t* timeZoneContainer = nullptr;
lv_obj_t* locationChips = nullptr;
// The index in LOCATION_CHOICES picked on the location page, -1 when none. Guarded by the LVGL lock.
int32_t locationChoice = -1;
std::unique_ptr<timezone::TimeZoneView> timeZoneView;
// The time zone picked on the time zone page, guarded by the LVGL lock
std::string timeZoneName;
std::string timeZoneCode;
}; };
/** The welcome page, a page per step, and the completion page */
uint32_t getPageCount(const Context* ctx) {
return static_cast<uint32_t>(ctx->steps.size()) + 2;
}
uint32_t getPage(const Context* ctx) {
switch (ctx->phase) {
case Phase::Welcome:
return 0;
case Phase::StepIntro:
return static_cast<uint32_t>(ctx->stepIndex) + 1;
case Phase::Done:
return getPageCount(ctx) - 1;
}
return 0;
}
/** The welcome page counts as the first step, followed by the configuration steps */
uint32_t getStepCount(const Context* ctx) {
return static_cast<uint32_t>(ctx->steps.size()) + 1;
}
/** The steps before the current one, or all of them once setup is done */
uint32_t getFinishedStepCount(const Context* ctx) {
switch (ctx->phase) {
case Phase::Welcome:
return 0;
case Phase::StepIntro:
return static_cast<uint32_t>(ctx->stepIndex) + 1;
case Phase::Done:
return getStepCount(ctx);
}
return 0;
}
StepConfiguration::Content getContent(const Context* ctx) {
return ctx->phase == Phase::StepIntro ? ctx->steps[ctx->stepIndex].content : StepConfiguration::Content::Description;
}
/** Shows which location choice is picked */
void renderLocationChips(Context* ctx) {
const uint32_t count = lv_obj_get_child_count(ctx->locationChips);
for (uint32_t i = 0; i < count; i++) {
lv_obj_set_state(lv_obj_get_child(ctx->locationChips, static_cast<int32_t>(i)), LV_STATE_CHECKED, static_cast<int32_t>(i) == ctx->locationChoice);
}
}
/** The time zone page shows the title at the top and the list below it, the other pages center their text */
void renderLayout(Context* ctx) {
const auto content = getContent(ctx);
const bool showsTimeZones = content == StepConfiguration::Content::TimeZones;
const bool showsLocationChoice = content == StepConfiguration::Content::LocationChoice;
const bool hasTimeZoneWidgets = lv_obj_get_child_count(ctx->timeZoneContainer) > 0;
lv_obj_set_hidden(ctx->descriptionLabel, showsTimeZones);
lv_obj_set_hidden(ctx->timeZoneContainer, !showsTimeZones);
lv_obj_set_hidden(ctx->locationChips, !showsLocationChoice);
if (!showsTimeZones) {
if (hasTimeZoneWidgets) {
lv_obj_clean(ctx->timeZoneContainer);
ctx->timeZoneView->destroyWidgets();
}
const int titleMargin = lvgl_get_text_font_height(FONT_SIZE_LARGE);
if (showsLocationChoice) {
// Centered as a group in the space above the buttons
const int32_t gap = titleMargin / 2;
lv_obj_update_layout(lv_obj_get_parent(ctx->titleLabel));
const int32_t height = lv_obj_get_height(ctx->titleLabel) + gap + lv_obj_get_height(ctx->descriptionLabel) + gap + lv_obj_get_height(ctx->locationChips);
lv_area_t parent;
lv_area_t bottom;
lv_obj_get_content_coords(lv_obj_get_parent(ctx->titleLabel), &parent);
lv_obj_get_coords(ctx->bottom, &bottom);
const int32_t top = LV_MAX((bottom.y1 - parent.y1 - height) / 2, 0);
lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, top);
lv_obj_align_to(ctx->descriptionLabel, ctx->titleLabel, LV_ALIGN_OUT_BOTTOM_MID, 0, gap);
lv_obj_align_to(ctx->locationChips, ctx->descriptionLabel, LV_ALIGN_OUT_BOTTOM_MID, 0, gap);
// Continue confirms the picked choice
renderLocationChips(ctx);
lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->locationChoice < 0);
} else {
lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0);
lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -titleMargin);
lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED);
}
return;
}
lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, LV_DPX(8));
if (!hasTimeZoneWidgets) {
ctx->timeZoneView->createWidgets(ctx->timeZoneContainer);
}
// Continue confirms the picked time zone
lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->timeZoneName.empty());
// Fills the space between the title and the buttons
lv_obj_update_layout(lv_obj_get_parent(ctx->timeZoneContainer));
lv_area_t title;
lv_area_t bottom;
lv_obj_get_coords(ctx->titleLabel, &title);
lv_obj_get_coords(ctx->bottom, &bottom);
lv_area_t parent;
lv_obj_get_content_coords(lv_obj_get_parent(ctx->timeZoneContainer), &parent);
lv_obj_set_pos(ctx->timeZoneContainer, 0, title.y2 + LV_DPX(4) - parent.y1);
lv_obj_set_size(ctx->timeZoneContainer, LV_PCT(100), bottom.y1 - title.y2 - LV_DPX(8));
}
void renderCurrent(Context* ctx) { void renderCurrent(Context* ctx) {
lvgl_step_indicator_set_finished_count(ctx->stepIndicator, getFinishedStepCount(ctx));
lv_obj_set_hidden(ctx->backButton, ctx->phase == Phase::Welcome);
switch (ctx->phase) { switch (ctx->phase) {
case Phase::Welcome: { case Phase::Welcome: {
lv_label_set_text(ctx->titleLabel, "Welcome"); lv_label_set_text(ctx->titleLabel, "Welcome");
@ -135,6 +271,7 @@ void renderCurrent(Context* ctx) {
lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish"); lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish");
break; break;
} }
renderLayout(ctx);
} }
void advanceTo(Context* ctx, size_t index) { void advanceTo(Context* ctx, size_t index) {
@ -146,10 +283,29 @@ void advanceTo(Context* ctx, size_t index) {
} }
lvgl_lock(); lvgl_lock();
renderCurrent(ctx); // Covered by a step's app: createWidgets() renders the current page when the window is shown again
if (ctx->titleLabel != nullptr) {
renderCurrent(ctx);
}
lvgl_unlock(); lvgl_unlock();
} }
void onBackClicked(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// The previous page: a step's intro page, or the welcome page before the first step
const size_t previousPage = getPage(ctx) - 1;
if (ctx->phase == Phase::Welcome) {
return;
} else if (previousPage == 0) {
ctx->phase = Phase::Welcome;
lvgl_lock();
renderCurrent(ctx);
lvgl_unlock();
} else {
advanceTo(ctx, previousPage - 1);
}
}
void onSkipClicked(lv_event_t* event) { void onSkipClicked(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
if (ctx->phase == Phase::StepIntro) { if (ctx->phase == Phase::StepIntro) {
@ -174,6 +330,13 @@ void onContinueClicked(lv_event_t* event) {
} }
} }
void onLocationChipPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->locationChoice = static_cast<int32_t>(reinterpret_cast<uintptr_t>(lv_obj_get_user_data(lv_event_get_target_obj(event))));
renderLocationChips(ctx);
lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED);
}
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData); auto* ctx = static_cast<Context*>(userData);
@ -190,26 +353,90 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_WRAP); lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_WRAP);
lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0); lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0);
int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE); // The location choices: one can be picked, and they wrap to more lines on narrow screens
lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin); ctx->locationChips = lv_obj_create(parent);
lv_obj_remove_style_all(ctx->locationChips);
lv_obj_set_size(ctx->locationChips, LV_PCT(100), LV_SIZE_CONTENT);
// The chip margins space the choices, so they fit on one line on small screens
lv_obj_set_flex_flow(ctx->locationChips, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(ctx->locationChips, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
for (size_t i = 0; i < std::size(LOCATION_CHOICES); i++) {
auto* chip = lvgl_chip_create(ctx->locationChips);
lv_label_set_text(lv_label_create(chip), LOCATION_CHOICES[i]);
lv_obj_set_user_data(chip, reinterpret_cast<void*>(static_cast<uintptr_t>(i)));
lv_obj_add_event_cb(chip, onLocationChipPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
lvgl_grid_navigation_add(ctx->locationChips);
lv_obj_set_hidden(ctx->locationChips, true);
ctx->skipButton = lv_button_create(parent); // Filled with the time zone list on the time zone page
lv_obj_t* skip_label = lv_label_create(ctx->skipButton); ctx->timeZoneContainer = lv_obj_create(parent);
lv_label_set_text(skip_label, "Skip"); lv_obj_remove_style_all(ctx->timeZoneContainer);
lv_obj_center(skip_label); lv_obj_set_flex_flow(ctx->timeZoneContainer, LV_FLEX_FLOW_COLUMN);
lv_obj_align(ctx->skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12); lv_obj_set_style_pad_hor(ctx->timeZoneContainer, LV_DPX(8), LV_STATE_DEFAULT);
lv_obj_set_hidden(ctx->timeZoneContainer, true);
// The buttons, with the progress through the pages below them at the bottom
auto* bottom = lv_obj_create(parent);
ctx->bottom = bottom;
lv_obj_remove_style_all(bottom);
lv_obj_set_size(bottom, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(bottom, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(bottom, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_align(bottom, LV_ALIGN_BOTTOM_MID, 0, 0);
// Back on the left, and Skip next to Continue on the right
auto* buttonRow = lv_obj_create(bottom);
lv_obj_remove_style_all(buttonRow);
lv_obj_set_size(buttonRow, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(buttonRow, LV_DPX(8), LV_STATE_DEFAULT);
lv_obj_set_flex_flow(buttonRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(buttonRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
ctx->backButton = lvgl_text_button_create(buttonRow);
lv_label_set_text(lv_label_create(ctx->backButton), "Back");
lv_obj_add_event_cb(ctx->backButton, onBackClicked, LV_EVENT_SHORT_CLICKED, ctx);
auto* spacer = lv_obj_create(buttonRow);
lv_obj_remove_style_all(spacer);
lv_obj_set_height(spacer, 1);
lv_obj_set_flex_grow(spacer, 1);
ctx->skipButton = lvgl_text_button_create(buttonRow);
lv_label_set_text(lv_label_create(ctx->skipButton), "Skip");
lv_obj_add_event_cb(ctx->skipButton, onSkipClicked, LV_EVENT_SHORT_CLICKED, ctx); lv_obj_add_event_cb(ctx->skipButton, onSkipClicked, LV_EVENT_SHORT_CLICKED, ctx);
ctx->continueButton = lv_button_create(parent); ctx->continueButton = lv_button_create(buttonRow);
lv_obj_t* continue_label = lv_label_create(ctx->continueButton); lv_label_set_text(lv_label_create(ctx->continueButton), "Continue");
lv_label_set_text(continue_label, "Continue");
lv_obj_center(continue_label);
lv_obj_align(ctx->continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12);
lv_obj_add_event_cb(ctx->continueButton, onContinueClicked, LV_EVENT_SHORT_CLICKED, ctx); lv_obj_add_event_cb(ctx->continueButton, onContinueClicked, LV_EVENT_SHORT_CLICKED, ctx);
ctx->stepIndicator = lvgl_step_indicator_create(bottom);
lvgl_step_indicator_set_step_count(ctx->stepIndicator, getStepCount(ctx));
// The arrow keys move between the buttons, and Continue has the focus first
lvgl_grid_navigation_add(buttonRow);
if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) {
lv_group_focus_obj(buttonRow);
lv_gridnav_set_focused(buttonRow, ctx->continueButton, LV_ANIM_OFF);
}
renderCurrent(ctx); renderCurrent(ctx);
} }
void destroyWidgets(void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->titleLabel = nullptr;
ctx->descriptionLabel = nullptr;
ctx->backButton = nullptr;
ctx->skipButton = nullptr;
ctx->continueButton = nullptr;
ctx->stepIndicator = nullptr;
ctx->bottom = nullptr;
ctx->timeZoneContainer = nullptr;
ctx->locationChips = nullptr;
ctx->timeZoneView->destroyWidgets();
}
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id(); uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx {}; Context ctx {};
@ -222,11 +449,6 @@ int32_t appMain(int argc, char* argv[]) {
.run = [&ctx] { ctx.pendingStepDialogId = touchcalibration::start(ctx.appInstanceId); } .run = [&ctx] { ctx.pendingStepDialogId = touchcalibration::start(ctx.appInstanceId); }
}, },
#endif #endif
{
.title = "Time Zone Setup",
.description = "Let's set the time zone.",
.run = [&ctx] { ctx.pendingStepDialogId = timezone::start(ctx.appInstanceId, true); }
},
{ {
.title = "Wi-Fi Setup", .title = "Wi-Fi Setup",
.description = "Let's connect to a Wi-Fi access point.", .description = "Let's connect to a Wi-Fi access point.",
@ -245,40 +467,37 @@ int32_t appMain(int argc, char* argv[]) {
} }
}; };
// The radio may only transmit on the frequencies and power of the user's region
if (settings::radio::hasRadio()) {
ctx.steps.push_back({
.title = "Radio Region",
.description = "Let's select your region, so the radio uses the frequencies that are allowed there.",
.run = [&ctx] {
std::vector<std::string> items;
for (int region = RADIO_REGION_UNSET + 1; region < RADIO_REGION_COUNT; region++) {
items.emplace_back(settings::radio::getRegionLabel(static_cast<RadioRegion>(region)));
}
ctx.pendingStepDialogId = selectiondialog::start(ctx.appInstanceId, "Region", items);
},
.onResult = [](int32_t selected) {
if (selected >= 0 && selected < RADIO_REGION_COUNT - 1) {
settings::radio::saveRegion(static_cast<RadioRegion>(selected + 1));
}
}
});
}
ctx.steps.push_back({ ctx.steps.push_back({
.title = "Location Services", .title = "Location Services",
.description = "Location services let apps use your location. It comes from GPS, or from your IP address via ip-api.com when there is no GPS fix.", .description = "Do you want to allow apps to retrieve your location? It uses GPS and/or contacts geoiplookup.io",
.run = [&ctx] { .run = [&ctx] {
const std::vector<std::string> items = { "Allow precise location", "Allow approximate location", "Don't allow" }; // The order of LOCATION_CHOICES: precise, approximate, disabled
ctx.pendingStepDialogId = selectiondialog::start(ctx.appInstanceId, "Location", items); if (ctx.locationChoice == 0 || ctx.locationChoice == 1) {
}, location_set_precision(ctx.locationChoice == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE);
.onResult = [](int32_t selected) {
if (selected == 0 || selected == 1) {
location_set_precision(selected == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE);
location_set_enabled(true); location_set_enabled(true);
} else if (selected == 2) { } else {
location_set_enabled(false); location_set_enabled(false);
} }
advanceTo(&ctx, ctx.stepIndex + 1);
},
.content = StepConfiguration::Content::LocationChoice
});
// Setting the time zone also sets the radio region, as both depend on where the device is
ctx.steps.push_back({
.title = "Time Zone",
.description = "",
.run = [&ctx] {
settings::setTimeZone(ctx.timeZoneName, ctx.timeZoneCode);
advanceTo(&ctx, ctx.stepIndex + 1);
},
.content = StepConfiguration::Content::TimeZones
});
ctx.timeZoneView = std::make_unique<timezone::TimeZoneView>([&ctx](const std::string& name, const std::string& code) {
ctx.timeZoneName = name;
ctx.timeZoneCode = code;
if (ctx.continueButton != nullptr) {
lv_obj_remove_state(ctx.continueButton, LV_STATE_DISABLED);
} }
}); });
@ -288,7 +507,7 @@ 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);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
bool shouldClose = false; bool shouldClose = false;
while (!shouldClose) { while (!shouldClose) {

View File

@ -1,9 +1,5 @@
#include <Tactility/app/timezone/TimeZone.h> #include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/LogMessages.h> #include <Tactility/app/timezone/TimeZoneView.h>
#include <Tactility/MountPoints.h>
#include <Tactility/Mutex.h>
#include <Tactility/StringUtils.h>
#include <Tactility/Timer.h>
#include <Tactility/settings/Time.h> #include <Tactility/settings/Time.h>
#include <app/event.h> #include <app/event.h>
@ -15,176 +11,34 @@
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h> #include <tactility/check.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/fonts.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <memory> #include <memory>
namespace tt::app::timezone { namespace tt::app::timezone {
constexpr auto* TAG = "TimeZone";
extern const ::AppManifest manifest; extern const ::AppManifest manifest;
namespace { namespace {
struct TimeZoneEntry {
std::string name;
std::string code;
};
struct Context { struct Context {
uint32_t appInstanceId; uint32_t appInstanceId;
Mutex mutex;
std::vector<TimeZoneEntry> entries;
std::unique_ptr<Timer> updateTimer;
lv_obj_t* listWidget = nullptr;
lv_obj_t* filterTextareaWidget = nullptr;
bool saveTimeZone = false; bool saveTimeZone = false;
std::unique_ptr<TimeZoneView> view;
// The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this // The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this
// is a plain (non-atomic) field safely shared between the LVGL thread (writer, before // is a plain (non-atomic) field safely shared between the LVGL thread (writer, before
// emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it). // emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it).
int32_t result = 1; // Cancelled - safety-net default if closed without picking a time zone int32_t result = 1; // Cancelled - safety-net default if closed without picking a time zone
}; };
// The last picked name/code. Static rather than per-instance: simple, and in practice only one // The last picked name/code. Static rather than per-instance: simple, and in practice only one
// TimeZone dialog is ever open at a time. Written on the LVGL thread (the item-selected // TimeZone dialog is ever open at a time. Written before emitting APP_EVENT_CLOSE; read by the
// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastName()/getLastCode() // parent via getLastName()/getLastCode() after receiving that event - safe without a lock for
// after receiving that event - safe without a lock for the same reason Context::result is (see // the same reason Context::result is (see AlertDialog.cpp).
// AlertDialog.cpp).
std::string lastName; std::string lastName;
std::string lastCode; std::string lastCode;
bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) {
std::string partial_strip = input.substr(1, input.size() - 3);
auto first_end_quote = partial_strip.find('"');
if (first_end_quote == std::string::npos) {
return false;
} else {
outName = partial_strip.substr(0, first_end_quote);
outCode = partial_strip.substr(first_end_quote + 3);
return true;
}
}
void onTextareaValueChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
if (ctx->updateTimer->isRunning()) {
ctx->updateTimer->stop();
}
ctx->updateTimer->start();
ctx->mutex.unlock();
}
}
void createListItem(Context* ctx, lv_obj_t* list, const std::string& title, size_t index) {
auto* btn = lvgl_list_add_button(list, nullptr, title.c_str());
struct ButtonContext {
Context* ctx;
size_t index;
};
auto* buttonCtx = new ButtonContext { ctx, index };
lv_obj_add_event_cb(btn, [](lv_event_t* e) {
auto* buttonCtx = static_cast<ButtonContext*>(lv_event_get_user_data(e));
delete buttonCtx;
}, LV_EVENT_DELETE, buttonCtx);
lv_obj_add_event_cb(btn, [](lv_event_t* e) {
auto* buttonCtx = static_cast<ButtonContext*>(lv_event_get_user_data(e));
auto* ctx = buttonCtx->ctx;
auto index = buttonCtx->index;
LOG_I(TAG, "Selected item at index %d", (int)index);
auto& entry = ctx->entries[index];
if (ctx->saveTimeZone) {
settings::setTimeZone(entry.name, entry.code);
}
lastName = entry.name;
lastCode = entry.code;
ctx->result = 0; // Ok
app_event_emit_close(ctx->appInstanceId);
}, LV_EVENT_SHORT_CLICKED, buttonCtx);
}
void readTimeZones(Context* ctx, std::string filter) {
auto path = std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv";
auto* file = fopen(path.c_str(), "rb");
if (file == nullptr) {
LOG_E(TAG, "Failed to open %s", path.c_str());
return;
}
char line[96];
std::string name;
std::string code;
uint32_t count = 0;
std::vector<TimeZoneEntry> new_entries;
while (fgets(line, 96, file)) {
if (parseEntry(line, name, code)) {
if (string::lowercase(name).find(filter) != std::string::npos) {
count++;
new_entries.push_back({.name = name, .code = code});
// Safety guard
if (count > 50) {
// TODO: Show warning that we're not displaying a complete list
break;
}
}
} else {
LOG_E(TAG, "Parse error at line %llu", count);
}
}
fclose(file);
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
ctx->entries = std::move(new_entries);
ctx->mutex.unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
}
LOG_I(TAG, "Processed %llu entries", count);
}
void updateList(Context* ctx) {
if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) {
std::string filter = string::lowercase(std::string(lv_textarea_get_text(ctx->filterTextareaWidget)));
lvgl_unlock();
readTimeZones(ctx, filter);
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL");
return;
}
if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
lv_obj_clean(ctx->listWidget);
uint32_t index = 0;
for (auto& entry : ctx->entries) {
createListItem(ctx, ctx->listWidget, entry.name, index);
index++;
}
ctx->mutex.unlock();
}
lvgl_unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL");
}
}
void onBackPressed(lv_event_t* event) { void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId); app_event_emit_close(ctx->appInstanceId);
@ -199,32 +53,20 @@ void createWidgets(lv_obj_t* parent, void* userData) {
auto* toolbar = lvgl_toolbar_create(parent, "Select Time zone"); auto* toolbar = lvgl_toolbar_create(parent, "Select Time zone");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* search_wrapper = lv_obj_create(parent); // Margins around the view's card
lv_obj_set_size(search_wrapper, LV_PCT(100), LV_SIZE_CONTENT); auto* wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(search_wrapper, LV_FLEX_FLOW_ROW); lv_obj_remove_style_all(wrapper);
lv_obj_set_flex_align(search_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_style_pad_all(search_wrapper, 0, 0); lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_style_border_width(search_wrapper, 0, 0); lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(wrapper, LV_DPX(8), LV_STATE_DEFAULT);
auto* icon = lv_image_create(search_wrapper); ctx->view->createWidgets(wrapper);
lv_obj_set_style_margin_left(icon, 8, 0); }
lv_obj_set_style_image_recolor_opa(icon, 255, 0);
lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0);
lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_image_set_src(icon, LVGL_ICON_SHARED_SEARCH);
auto* textarea = lv_textarea_create(search_wrapper); void destroyWidgets(void* userData) {
lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam"); auto* ctx = static_cast<Context*>(userData);
lv_textarea_set_one_line(textarea, true); ctx->view->destroyWidgets();
lv_obj_add_event_cb(textarea, onTextareaValueChanged, LV_EVENT_VALUE_CHANGED, ctx);
ctx->filterTextareaWidget = textarea;
lv_obj_set_flex_grow(textarea, 1);
auto* list = lvgl_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_style_border_width(list, 0, 0);
ctx->listWidget = list;
} }
int32_t appMain(int argc, char* argv[]) { int32_t appMain(int argc, char* argv[]) {
@ -234,9 +76,15 @@ int32_t appMain(int argc, char* argv[]) {
Context ctx; Context ctx;
ctx.appInstanceId = appInstanceId; ctx.appInstanceId = appInstanceId;
ctx.saveTimeZone = argc > 0 && argv[0][0] == '1'; ctx.saveTimeZone = argc > 0 && argv[0][0] == '1';
// Picking a time zone, or detecting one, closes the app with it
ctx.updateTimer = std::make_unique<Timer>(Timer::Type::Once, 500 / portTICK_PERIOD_MS, [&ctx] { ctx.view = std::make_unique<TimeZoneView>([&ctx](const std::string& name, const std::string& code) {
updateList(&ctx); if (ctx.saveTimeZone) {
settings::setTimeZone(name, code);
}
lastName = name;
lastCode = code;
ctx.result = 0; // Ok
app_event_emit_close(ctx.appInstanceId);
}); });
TaskEventGroup event_group {}; TaskEventGroup event_group {};
@ -245,8 +93,7 @@ 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);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx);
ctx.updateTimer->start();
while (true) { while (true) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY);
@ -262,8 +109,8 @@ int32_t appMain(int argc, char* argv[]) {
if (shouldClose) break; if (shouldClose) break;
} }
ctx.updateTimer->stop();
window_manager_remove(window); window_manager_remove(window);
ctx.view.reset();
check(app_event_unsubscribe(&sub) == ERROR_NONE); check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group); task_event_group_destruct(&event_group);

View File

@ -0,0 +1,304 @@
#include <Tactility/app/timezone/TimeZoneView.h>
#include <Tactility/MountPoints.h>
#include <Tactility/StringUtils.h>
#include <location/location.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/spinner.h>
#include <cstdio>
namespace tt::app::timezone {
constexpr auto* TAG = "TimeZoneView";
namespace {
// Bounds the list, so a short filter doesn't create hundreds of widgets
constexpr size_t MAX_ENTRIES = 50;
constexpr TickType_t FILTER_DELAY = pdMS_TO_TICKS(500);
std::string getTimeZonesPath() {
return std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv";
}
/** Parses a line like "Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3" */
bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) {
if (input.size() < 3) {
return false;
}
std::string partial_strip = input.substr(1, input.size() - 3);
auto first_end_quote = partial_strip.find('"');
if (first_end_quote == std::string::npos) {
return false;
}
outName = partial_strip.substr(0, first_end_quote);
outCode = partial_strip.substr(first_end_quote + 3);
return true;
}
/** Calls @a visitor for each time zone until it returns false */
void forEachTimeZone(const std::function<bool(const std::string& name, const std::string& code)>& visitor) {
auto* file = fopen(getTimeZonesPath().c_str(), "rb");
if (file == nullptr) {
LOG_E(TAG, "Failed to open %s", getTimeZonesPath().c_str());
return;
}
char line[96];
std::string name;
std::string code;
while (fgets(line, sizeof(line), file)) {
if (parseEntry(line, name, code) && !visitor(name, code)) {
break;
}
}
fclose(file);
}
} // namespace
TimeZoneView::TimeZoneView(OnSelected onSelected) : onSelected(std::move(onSelected)) {
filterTimer = std::make_unique<Timer>(Timer::Type::Once, FILTER_DELAY, [this] {
loadEntries();
lvgl_lock();
rebuildList();
lvgl_unlock();
});
loadEntries();
}
TimeZoneView::~TimeZoneView() {
filterTimer->stop();
cancelled = true;
if (detectThread != nullptr) {
detectThread->join();
}
}
void TimeZoneView::createWidgets(lv_obj_t* parent) {
// The search row, the message and the list share a card. Only the list scrolls, so the card keeps its size.
auto* card = lvgl_card_create(parent);
lv_obj_set_width(card, LV_PCT(100));
lv_obj_set_flex_grow(card, 1);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
auto* searchRow = lv_obj_create(card);
lv_obj_remove_style_all(searchRow);
lv_obj_set_size(searchRow, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(searchRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(searchRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(searchRow, LV_DPX(4), LV_STATE_DEFAULT);
auto* searchIcon = lv_image_create(searchRow);
lv_obj_set_style_image_recolor_opa(searchIcon, LV_OPA_COVER, LV_STATE_DEFAULT);
lv_obj_set_style_image_recolor(searchIcon, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
lv_obj_set_style_text_font(searchIcon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_image_set_src(searchIcon, LVGL_ICON_SHARED_SEARCH);
textarea = lv_textarea_create(searchRow);
lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam");
lv_textarea_set_one_line(textarea, true);
lv_obj_set_flex_grow(textarea, 1);
// Detects the time zone from the location, with a spinner in its place while it does
detectButton = lvgl_icon_button_create(searchRow);
auto* detectLabel = lv_label_create(detectButton);
lv_obj_set_style_text_font(detectLabel, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
lv_label_set_text(detectLabel, LVGL_ICON_SHARED_LOCATION_ON);
lv_obj_add_event_cb(detectButton, onDetectPressed, LV_EVENT_SHORT_CLICKED, this);
detectSpinner = lvgl_spinner_create(searchRow);
messageLabel = lv_label_create(card);
lv_obj_set_width(messageLabel, LV_PCT(100));
lv_label_set_long_mode(messageLabel, LV_LABEL_LONG_WRAP);
list = lvgl_list_create(card);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT);
mutex.lock();
lv_textarea_set_text(textarea, filter.c_str());
mutex.unlock();
// Added after restoring the text, so restoring it doesn't filter the list again
lv_obj_add_event_cb(textarea, onFilterChanged, LV_EVENT_VALUE_CHANGED, this);
showState();
rebuildList();
}
void TimeZoneView::destroyWidgets() {
textarea = nullptr;
detectButton = nullptr;
detectSpinner = nullptr;
messageLabel = nullptr;
list = nullptr;
}
void TimeZoneView::setSelectedName(const std::string& name) {
mutex.lock();
selectedName = name;
mutex.unlock();
if (list != nullptr) {
rebuildList();
}
}
void TimeZoneView::onFilterChanged(lv_event_t* event) {
auto* view = static_cast<TimeZoneView*>(lv_event_get_user_data(event));
view->mutex.lock();
view->filter = string::lowercase(std::string(lv_textarea_get_text(view->textarea)));
view->mutex.unlock();
view->filterTimer->stop();
view->filterTimer->start();
}
void TimeZoneView::onDetectPressed(lv_event_t* event) {
auto* view = static_cast<TimeZoneView*>(lv_event_get_user_data(event));
if (view->detecting.exchange(true)) {
return;
}
view->mutex.lock();
view->message.clear();
view->mutex.unlock();
view->showState();
// The previous detection already ended, as detecting was false
if (view->detectThread != nullptr) {
view->detectThread->join();
}
view->detectThread = std::make_unique<Thread>("timezone_detect", 5120, [view] {
view->detect();
return 0;
});
view->detectThread->start();
}
void TimeZoneView::onEntryPressed(lv_event_t* event) {
auto* view = static_cast<TimeZoneView*>(lv_event_get_user_data(event));
const auto index = reinterpret_cast<uintptr_t>(lv_obj_get_user_data(lv_event_get_target_obj(event)));
view->mutex.lock();
if (index >= view->entries.size()) {
view->mutex.unlock();
return;
}
const Entry entry = view->entries[index];
view->mutex.unlock();
view->select(entry.name, entry.code);
}
void TimeZoneView::loadEntries() {
mutex.lock();
const std::string currentFilter = filter;
mutex.unlock();
std::vector<Entry> matches;
forEachTimeZone([&matches, &currentFilter](const std::string& name, const std::string& code) {
if (string::lowercase(name).find(currentFilter) != std::string::npos) {
matches.push_back({ name, code });
}
return matches.size() < MAX_ENTRIES;
});
mutex.lock();
entries = std::move(matches);
mutex.unlock();
}
// Called with the LVGL lock held
void TimeZoneView::rebuildList() {
if (list == nullptr) {
return;
}
lv_obj_clean(list);
mutex.lock();
for (size_t index = 0; index < entries.size(); index++) {
auto* button = lvgl_list_add_button(list, nullptr, entries[index].name.c_str());
lv_obj_set_user_data(button, reinterpret_cast<void*>(static_cast<uintptr_t>(index)));
lv_obj_add_event_cb(button, onEntryPressed, LV_EVENT_SHORT_CLICKED, this);
if (entries[index].name == selectedName) {
lv_obj_add_state(button, LV_STATE_CHECKED);
}
}
mutex.unlock();
}
// Called with the LVGL lock held
void TimeZoneView::showState() {
if (detectButton == nullptr) {
return;
}
const bool isDetecting = detecting;
lv_obj_set_hidden(detectButton, isDetecting);
lv_obj_set_hidden(detectSpinner, !isDetecting);
mutex.lock();
lv_label_set_text(messageLabel, message.c_str());
lv_obj_set_hidden(messageLabel, message.empty());
mutex.unlock();
}
// Runs on the detection thread, as the lookup blocks
void TimeZoneView::detect() {
char name[LOCATION_TIME_ZONE_MAX_LENGTH + 1];
const error_t error = location_lookup_time_zone(name, sizeof(name));
std::string code;
std::string failure;
if (error == ERROR_NONE) {
forEachTimeZone([&name, &code](const std::string& entryName, const std::string& entryCode) {
if (entryName == name) {
code = entryCode;
return false;
}
return true;
});
if (code.empty()) {
failure = std::string("The detected time zone ") + name + " isn't in the list.";
}
} else if (error == ERROR_INVALID_STATE) {
failure = "Detecting the time zone needs location services. You can turn them on in Settings.";
} else {
failure = "The time zone couldn't be detected. Check the internet connection and try again.";
}
// Waits for the LVGL lock without blocking the destructor, which can be called with the lock held
while (!lvgl_try_lock(pdMS_TO_TICKS(50))) {
if (cancelled) {
return;
}
}
if (cancelled) {
lvgl_unlock();
return;
}
detecting = false;
mutex.lock();
message = failure;
mutex.unlock();
showState();
if (failure.empty()) {
LOG_I(TAG, "Detected %s", name);
// Shows the detected time zone in the list
if (textarea != nullptr) {
lv_textarea_set_text(textarea, name);
}
select(name, code);
}
lvgl_unlock();
}
// Called with the LVGL lock held
void TimeZoneView::select(const std::string& name, const std::string& code) {
setSelectedName(name);
onSelected(name, code);
}
}

View File

@ -115,9 +115,36 @@ BtEventSubscription btEventSub {};
Thread* btEventThread = nullptr; Thread* btEventThread = nullptr;
std::atomic<bool> btEventThreadRunning {false}; std::atomic<bool> btEventThreadRunning {false};
// Scans repeat while the radio is on, so new devices show up without opening the app.
// Only used by the bridge thread.
constexpr TickType_t AUTO_SCAN_INTERVAL = pdMS_TO_TICKS(10000);
bool autoScanEnabled = false;
bool autoScanPending = false;
TickType_t nextAutoScanTick = 0;
static void scheduleAutoScan(TickType_t delay) {
autoScanPending = autoScanEnabled;
nextAutoScanTick = xTaskGetTickCount() + delay;
}
static void runAutoScan() {
autoScanPending = false;
Device* dev;
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) != ERROR_NONE) {
scheduleAutoScan(AUTO_SCAN_INTERVAL);
return;
}
if (!bluetooth_is_scanning(dev) && bluetooth_scan_start(dev) != ERROR_NONE) {
scheduleAutoScan(AUTO_SCAN_INTERVAL);
}
device_put(dev);
}
static void bt_event_bridge(BtEvent event) { static void bt_event_bridge(BtEvent event) {
switch (event.type) { switch (event.type) {
case BT_EVENT_RADIO_STATE_CHANGED: case BT_EVENT_RADIO_STATE_CHANGED:
autoScanEnabled = event.radio_state == BT_RADIO_STATE_ON;
scheduleAutoScan(0);
switch (event.radio_state) { switch (event.radio_state) {
case BT_RADIO_STATE_ON: case BT_RADIO_STATE_ON:
getMainDispatcher().dispatch([] { getMainDispatcher().dispatch([] {
@ -175,6 +202,7 @@ static void bt_event_bridge(BtEvent event) {
break; break;
case BT_EVENT_SCAN_FINISHED: case BT_EVENT_SCAN_FINISHED:
scheduleAutoScan(AUTO_SCAN_INTERVAL);
getMainDispatcher().dispatch([] { autoConnectHidHost(); }); getMainDispatcher().dispatch([] { autoConnectHidHost(); });
break; break;
@ -276,6 +304,9 @@ int32_t btEventThreadMain() {
while (bluetooth_event_poll(&btEventSub, &event) == ERROR_NONE) { while (bluetooth_event_poll(&btEventSub, &event) == ERROR_NONE) {
bt_event_bridge(event); bt_event_bridge(event);
} }
if (autoScanPending && static_cast<int32_t>(xTaskGetTickCount() - nextAutoScanTick) >= 0) {
runAutoScan();
}
} }
return 0; return 0;
} }

View File

@ -54,9 +54,6 @@ namespace service {
namespace displayidle { extern const ServiceManifest manifest; } namespace displayidle { extern const ServiceManifest manifest; }
namespace keyboardidle { extern const ServiceManifest manifest; } namespace keyboardidle { extern const ServiceManifest manifest; }
#endif #endif
#if TT_FEATURE_SCREENSHOT_ENABLED
namespace screenshot { extern const ServiceManifest manifest; }
#endif
} }
namespace lvgl { namespace lvgl {
@ -185,9 +182,6 @@ static void onLvglStarted() {
#if defined(CONFIG_TT_TDECK_WORKAROUND) #if defined(CONFIG_TT_TDECK_WORKAROUND)
addService(service::keyboardidle::manifest); addService(service::keyboardidle::manifest);
#endif #endif
#if TT_FEATURE_SCREENSHOT_ENABLED
addService(service::screenshot::manifest);
#endif
lvgl::startUsbHidInput(); lvgl::startUsbHidInput();
lvgl::startKeyboardDeviceListener(); lvgl::startKeyboardDeviceListener();
@ -207,9 +201,6 @@ static void onLvglStopped() {
if (device_exists_of_type(&IMU_TYPE)) { if (device_exists_of_type(&IMU_TYPE)) {
check(service::removeService(service::autorotate::manifest.id)); check(service::removeService(service::autorotate::manifest.id));
} }
#if TT_FEATURE_SCREENSHOT_ENABLED
check(service::removeService(service::screenshot::manifest.id));
#endif
#if defined(CONFIG_TT_TDECK_WORKAROUND) #if defined(CONFIG_TT_TDECK_WORKAROUND)
check(service::removeService(service::keyboardidle::manifest.id)); check(service::removeService(service::keyboardidle::manifest.id));
#endif #endif

View File

@ -304,8 +304,8 @@ static void createWidgets(lv_obj_t* parent, void* /*userData*/) {
// The arrow keys move between the tiles. Toggles that are focused directly would be switched by them. // The arrow keys move between the tiles. Toggles that are focused directly would be switched by them.
lvgl_grid_navigation_add(grid); lvgl_grid_navigation_add(grid);
// Without a pointer device, tapping outside of the card or swiping up isn't possible // A keyboard or encoder can't tap outside of the card or swipe up, so it needs a button to close the menu
if (!lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { if (lvgl_indev_exists(LV_INDEV_TYPE_KEYPAD) || lvgl_indev_exists(LV_INDEV_TYPE_ENCODER)) {
auto* close_button = createTile(card, LVGL_ICON_SHARED_CLOSE); auto* close_button = createTile(card, LVGL_ICON_SHARED_CLOSE);
lv_obj_add_event_cb(close_button, onCloseClicked, LV_EVENT_SHORT_CLICKED, nullptr); lv_obj_add_event_cb(close_button, onCloseClicked, LV_EVENT_SHORT_CLICKED, nullptr);
lv_group_focus_obj(close_button); lv_group_focus_obj(close_button);

View File

@ -1,106 +0,0 @@
#include <Tactility/TactilityConfig.h>
#if TT_FEATURE_SCREENSHOT_ENABLED
#include <Tactility/LogMessages.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/service/screenshot/Screenshot.h>
#include <lvgl.h>
#include <memory>
#include <tactility/log.h>
namespace tt::service::screenshot {
constexpr auto* TAG = "ScreenshotService";
extern const ServiceManifest manifest;
std::shared_ptr<ScreenshotService> optScreenshotService() {
return service::findServiceById<ScreenshotService>(manifest.id);
}
void ScreenshotService::startApps(const std::string& path) {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_W(TAG,LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
if (task == nullptr || task->isFinished()) {
task = std::make_unique<ScreenshotTask>();
mode = Mode::Apps;
task->startApps(path);
} else {
LOG_W(TAG,"Screenshot task already running");
}
}
void ScreenshotService::startTimed(const std::string& path, uint8_t delayInSeconds, uint8_t amount) {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_W(TAG,LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
if (task == nullptr || task->isFinished()) {
task = std::make_unique<ScreenshotTask>();
mode = Mode::Timed;
task->startTimed(path, delayInSeconds, amount);
} else {
LOG_W(TAG,"Screenshot task already running");
}
}
bool ScreenshotService::onStart(ServiceContext& serviceContext) {
if (lv_screen_active() == nullptr) {
LOG_E(TAG, "No display found");
return false;
}
return true;
}
void ScreenshotService::stop() {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_W(TAG,LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
if (task != nullptr) {
task = nullptr;
mode = Mode::None;
} else {
LOG_W(TAG,"Screenshot task not running");
}
}
Mode ScreenshotService::getMode() const {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_W(TAG,LOG_MESSAGE_MUTEX_LOCK_FAILED);
return Mode::None;
}
return mode;
}
bool ScreenshotService::isTaskStarted() {
auto* current_task = task.get();
if (current_task == nullptr) {
return false;
} else {
return !current_task->isFinished();
}
}
extern const ServiceManifest manifest = {
.id = "tactility.screenshot",
.createService = create<ScreenshotService>
};
} // namespace
#endif

View File

@ -1,182 +0,0 @@
#include <Tactility/TactilityConfig.h>
#if TT_FEATURE_SCREENSHOT_ENABLED
#include <Tactility/LogMessages.h>
#include <Tactility/CpuAffinity.h>
#include <Tactility/service/screenshot/ScreenshotTask.h>
#include <Tactility/lvgl/Lvgl.h>
#include <app/manager.h>
#include <tactility/delay.h>
#include <tactility/error.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lv_screenshot.h>
#include <format>
namespace tt::service::screenshot {
constexpr auto* TAG = "ScreenshotTask";
ScreenshotTask::~ScreenshotTask() {
if (thread) {
stop();
}
}
bool ScreenshotTask::isInterrupted() {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_W(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
return true;
}
return interrupted;
}
bool ScreenshotTask::isFinished() {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_W(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
return false;
}
return finished;
}
void ScreenshotTask::setFinished() {
auto lock = mutex.asScopedLock();
lock.lock();
finished = true;
}
static void makeScreenshot(const std::string& path) {
if (lvgl_try_lock(50 / portTICK_PERIOD_MS)) {
std::string lvgl_path = std::string(lvgl::PATH_PREFIX) + path;
if (lv_screenshot_create(lv_scr_act(), LV_100ASK_SCREENSHOT_SV_PNG, lvgl_path.c_str())) {
LOG_I(TAG, "Screenshot saved to %s", path.c_str());
} else {
LOG_E(TAG, "Screenshot not saved to %s", path.c_str());
}
lvgl_unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "LVGL");
}
}
void ScreenshotTask::taskMain() {
uint8_t screenshots_taken = 0;
uint32_t last_app_instance_id = 0;
while (!isInterrupted()) {
if (work.type == TASK_WORK_TYPE_DELAY) {
// Splitting up the delays makes it easier to stop the service
for (int i = 0; i < (work.delay_in_seconds * 10) && !isInterrupted(); ++i){
delay_millis(100);
}
if (!isInterrupted()) {
screenshots_taken++;
std::string filename = std::format("{}/screenshot-{}.png", work.path, screenshots_taken);
makeScreenshot(filename);
if (work.amount > 0 && screenshots_taken >= work.amount) {
break; // Interrupted loop
}
}
} else if (work.type == TASK_WORK_TYPE_APPS) {
AppInstanceId app_instance_id = 0;
bool has_topmost = app_manager_get_topmost_instance_id(&app_instance_id) == ERROR_NONE;
if (has_topmost && app_instance_id != last_app_instance_id) {
delay_millis(100);
last_app_instance_id = app_instance_id;
auto filename = std::format("{}/screenshot-{}.png", work.path, app_instance_id);
makeScreenshot(filename);
}
// Ensure the LVGL widgets are rendered as the app just started
delay_millis(250);
}
}
setFinished();
}
void ScreenshotTask::taskStart() {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
check(thread == nullptr);
thread = new Thread(
"screenshot",
8192,
[this] {
this->taskMain();
return 0;
},
getCpuAffinityConfiguration().graphics
);
thread->start();
}
void ScreenshotTask::startApps(const std::string& path) {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
if (thread == nullptr) {
interrupted = false;
work.type = TASK_WORK_TYPE_APPS;
work.path = path;
taskStart();
} else {
LOG_E(TAG, "Task was already running");
}
}
void ScreenshotTask::startTimed(const std::string& path, uint8_t delay_in_seconds, uint8_t amount) {
auto lock = mutex.asScopedLock();
if (!lock.lock(50 / portTICK_PERIOD_MS)) {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
if (thread == nullptr) {
interrupted = false;
work.type = TASK_WORK_TYPE_DELAY;
work.delay_in_seconds = delay_in_seconds;
work.amount = amount;
work.path = path;
taskStart();
} else {
LOG_E(TAG, "Task was already running");
}
}
void ScreenshotTask::stop() {
if (thread != nullptr) {
if (mutex.lock(50 / portTICK_PERIOD_MS)) {
interrupted = true;
mutex.unlock();
}
thread->join();
if (mutex.lock(50 / portTICK_PERIOD_MS)) {
delete thread;
thread = nullptr;
mutex.unlock();
}
}
}
} // namespace
#endif

View File

@ -46,4 +46,63 @@ bool hasRadio() {
return device_exists_of_type(&RADIO_TYPE); return device_exists_of_type(&RADIO_TYPE);
} }
RadioRegion findRegionForTimeZone(const std::string& timeZoneName) {
struct ZoneRegion {
const char* zone;
RadioRegion region;
};
// Exact names before the area prefixes below, e.g. the Russian zones in Europe and Asia
static constexpr ZoneRegion ZONES[] = {
{ "Europe/Moscow", RADIO_REGION_RU_868 },
{ "Europe/Kaliningrad", RADIO_REGION_RU_868 },
{ "Europe/Samara", RADIO_REGION_RU_868 },
{ "Europe/Volgograd", RADIO_REGION_RU_868 },
{ "Europe/Kirov", RADIO_REGION_RU_868 },
{ "Europe/Astrakhan", RADIO_REGION_RU_868 },
{ "Europe/Saratov", RADIO_REGION_RU_868 },
{ "Europe/Ulyanovsk", RADIO_REGION_RU_868 },
{ "Asia/Yekaterinburg", RADIO_REGION_RU_868 },
{ "Asia/Omsk", RADIO_REGION_RU_868 },
{ "Asia/Novosibirsk", RADIO_REGION_RU_868 },
{ "Asia/Barnaul", RADIO_REGION_RU_868 },
{ "Asia/Tomsk", RADIO_REGION_RU_868 },
{ "Asia/Novokuznetsk", RADIO_REGION_RU_868 },
{ "Asia/Krasnoyarsk", RADIO_REGION_RU_868 },
{ "Asia/Irkutsk", RADIO_REGION_RU_868 },
{ "Asia/Chita", RADIO_REGION_RU_868 },
{ "Asia/Yakutsk", RADIO_REGION_RU_868 },
{ "Asia/Khandyga", RADIO_REGION_RU_868 },
{ "Asia/Vladivostok", RADIO_REGION_RU_868 },
{ "Asia/Ust-Nera", RADIO_REGION_RU_868 },
{ "Asia/Magadan", RADIO_REGION_RU_868 },
{ "Asia/Sakhalin", RADIO_REGION_RU_868 },
{ "Asia/Srednekolymsk", RADIO_REGION_RU_868 },
{ "Asia/Kamchatka", RADIO_REGION_RU_868 },
{ "Asia/Anadyr", RADIO_REGION_RU_868 },
{ "Asia/Shanghai", RADIO_REGION_CN_470 },
{ "Asia/Urumqi", RADIO_REGION_CN_470 },
{ "Asia/Kolkata", RADIO_REGION_IN_865 },
{ "Asia/Calcutta", RADIO_REGION_IN_865 },
{ "Asia/Tokyo", RADIO_REGION_JP_920 },
{ "Pacific/Auckland", RADIO_REGION_NZ_865 },
{ "Pacific/Chatham", RADIO_REGION_NZ_865 },
};
static constexpr ZoneRegion AREAS[] = {
{ "Europe/", RADIO_REGION_EU_868 },
{ "America/", RADIO_REGION_US_915 },
{ "Australia/", RADIO_REGION_AU_915 },
};
for (const auto& zone : ZONES) {
if (timeZoneName == zone.zone) {
return zone.region;
}
}
for (const auto& area : AREAS) {
if (timeZoneName.starts_with(area.zone)) {
return area.region;
}
}
return RADIO_REGION_UNSET;
}
} }

View File

@ -1,10 +1,12 @@
#include <service/paths.h> #include <service/paths.h>
#include <Tactility/settings/RadioSettings.h>
#include <Tactility/settings/Time.h> #include <Tactility/settings/Time.h>
#include <Tactility/settings/SystemSettings.h> #include <Tactility/settings/SystemSettings.h>
#include <tactility/paths.h> #include <tactility/paths.h>
#include <tactility/preferences.h> #include <tactility/preferences.h>
#include <tactility/log.h>
#include <tactility/system_event.h> #include <tactility/system_event.h>
#include <lvgl/widgets/statusbar.h> #include <lvgl/widgets/statusbar.h>
@ -15,6 +17,8 @@
namespace tt::settings { namespace tt::settings {
constexpr auto* TAG = "Time";
constexpr auto* TIMEZONE_PREFERENCES_KEY_NAME = "tz_name"; constexpr auto* TIMEZONE_PREFERENCES_KEY_NAME = "tz_name";
constexpr auto* TIMEZONE_PREFERENCES_KEY_CODE = "tz_code"; constexpr auto* TIMEZONE_PREFERENCES_KEY_CODE = "tz_code";
constexpr auto* TIMEZONE_PREFERENCES_KEY_TIME24 = "tz_time24"; constexpr auto* TIMEZONE_PREFERENCES_KEY_TIME24 = "tz_time24";
@ -61,6 +65,14 @@ void setTimeZone(const std::string& name, const std::string& code) {
tzset(); tzset();
#endif #endif
// The time zone tells where the device is, and so which radio band it may use
if (radio::hasRadio()) {
const RadioRegion region = radio::findRegionForTimeZone(name);
if (region != RADIO_REGION_UNSET && !radio::saveRegion(region)) {
LOG_E(TAG, "Failed to save the radio region");
}
}
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
} }

View File

@ -66,18 +66,19 @@ class TtTest(unittest.TestCase):
def default_tools(self): def default_tools(self):
return {name: read_tool(name) for name in ("update-self.py", "update-sdk.py", "_bootstrap.py")} | {"echo.py": ECHO_TOOL} return {name: read_tool(name) for name in ("update-self.py", "update-sdk.py", "_bootstrap.py")} | {"echo.py": ECHO_TOOL}
def publish_sdk(self, tools, commit="commit1", sdk_content="# sdk"): def publish_sdk(self, tools, commit="commit1", sdk_content="# sdk", platforms=("posix",)):
version_directory = os.path.join(self.cdn, "sdk", VERSION) version_directory = os.path.join(self.cdn, "sdk", VERSION)
os.makedirs(version_directory, exist_ok=True) os.makedirs(version_directory, exist_ok=True)
tools_zip = make_zip(tools) tools_zip = make_zip(tools)
with open(os.path.join(version_directory, "tools.zip"), "wb") as file: with open(os.path.join(version_directory, "tools.zip"), "wb") as file:
file.write(tools_zip) file.write(tools_zip)
with open(os.path.join(version_directory, "TactilitySDK-posix.zip"), "wb") as file: for platform in platforms:
file.write(make_zip({"TactilitySDK.cmake": sdk_content})) with open(os.path.join(version_directory, f"TactilitySDK-{platform}.zip"), "wb") as file:
file.write(make_zip({"TactilitySDK.cmake": sdk_content}))
index = { index = {
"version": VERSION, "version": VERSION,
"gitCommit": commit, "gitCommit": commit,
"platforms": {"posix": "TactilitySDK-posix.zip"}, "platforms": {platform: f"TactilitySDK-{platform}.zip" for platform in platforms},
"tools": {"file": "tools.zip", "sha256": hashlib.sha256(tools_zip).hexdigest()}, "tools": {"file": "tools.zip", "sha256": hashlib.sha256(tools_zip).hexdigest()},
} }
with open(os.path.join(version_directory, "index.json"), "w") as file: with open(os.path.join(version_directory, "index.json"), "w") as file:
@ -160,22 +161,68 @@ class TtTest(unittest.TestCase):
self.assertIn("echo", result.stdout) self.assertIn("echo", result.stdout)
self.assertIn(VERSION, result.stdout) self.assertIn(VERSION, result.stdout)
def test_changed_tools_are_downloaded_again(self): def test_installed_tools_and_platforms_are_only_replaced_by_update_sdk(self):
self.write_manifest(self.work) self.write_manifest(self.work)
self.assertEqual(self.run_tt("echo").returncode, 3) self.assertEqual(self.run_tt("echo").returncode, 3)
self.publish_sdk({"echo.py": "import sys\nsys.exit(4)\n"}) tools = self.default_tools() | {"echo.py": "import sys\nimport _bootstrap\n_bootstrap.run()\nsys.exit(4)\n"}
self.assertEqual(self.run_tt("echo").returncode, 3) self.publish_sdk(tools, commit="commit2", sdk_content="# sdk 2")
self.expire_index() self.expire_index()
self.expire_update_check()
result = self.run_tt("echo")
self.assertEqual(result.returncode, 3)
self.assertEqual(self.read_sdk_file(), "# sdk")
self.assertIn("run 'tt update-sdk'", result.stderr)
result = self.run_tt("update-sdk")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Downloading the tools", result.stdout)
self.assertIn("was updated", result.stdout)
self.assertEqual(self.run_tt("echo").returncode, 4) self.assertEqual(self.run_tt("echo").returncode, 4)
self.assertEqual(self.read_sdk_file(), "# sdk 2")
def test_failed_download_keeps_the_cache(self): def test_all_platforms_installs_every_platform_of_the_sdk(self):
self.publish_sdk(self.default_tools(), platforms=("posix", "esp32"))
self.write_manifest(self.work, platforms="all")
self.assertEqual(self.run_tt("echo").returncode, 3)
for platform in ("posix", "esp32"):
self.assertTrue(os.path.isdir(self.sdk_path(f"platform-{platform}", "TactilitySDK")), platform)
def test_missing_platform_of_a_republished_sdk_asks_for_update_sdk(self):
self.publish_sdk(self.default_tools(), platforms=("posix", "esp32"))
self.write_manifest(self.work)
self.assertEqual(self.run_tt("echo").returncode, 3)
self.publish_sdk(self.default_tools(), commit="commit2", sdk_content="# sdk 2", platforms=("posix", "esp32"))
self.expire_index()
self.write_manifest(self.work, platforms="posix,esp32")
result = self.run_tt("echo")
self.assertEqual(result.returncode, 1)
self.assertIn("run 'tt update-sdk' first", result.stderr)
self.assertFalse(os.path.exists(self.sdk_path("platform-esp32")))
self.assertEqual(self.run_tt("update-sdk").returncode, 0)
self.assertEqual(self.run_tt("echo").returncode, 3)
for platform in ("posix", "esp32"):
with open(self.sdk_path(f"platform-{platform}", "commit")) as file:
self.assertEqual(file.read(), "commit2", platform)
def test_failed_update_sdk_keeps_the_installed_build(self):
self.write_manifest(self.work)
self.assertEqual(self.run_tt("echo").returncode, 3)
tools = self.default_tools() | {"echo.py": "import sys\nsys.exit(4)\n"}
self.publish_sdk(tools, commit="commit2", sdk_content="# sdk 2")
os.remove(os.path.join(self.cdn, "sdk", VERSION, "TactilitySDK-posix.zip"))
result = self.run_tt("update-sdk")
self.assertEqual(result.returncode, 1)
self.assertIn("was not changed", result.stderr)
self.assertEqual(self.read_sdk_file(), "# sdk")
self.assertEqual(self.run_tt("echo").returncode, 3)
self.assertEqual(sorted(name for name in os.listdir(self.sdk_path()) if name.endswith((".new", ".old"))), [])
def test_installed_sdk_works_offline(self):
self.write_manifest(self.work) self.write_manifest(self.work)
self.assertEqual(self.run_tt("echo").returncode, 3) self.assertEqual(self.run_tt("echo").returncode, 3)
shutil.rmtree(self.cdn) shutil.rmtree(self.cdn)
self.expire_index() self.expire_index()
result = self.run_tt("echo") result = self.run_tt("echo")
self.assertEqual(result.returncode, 3, result.stderr) self.assertEqual(result.returncode, 3, result.stderr)
self.assertIn("cached", result.stderr)
def test_tools_with_a_wrong_checksum_are_refused(self): def test_tools_with_a_wrong_checksum_are_refused(self):
self.write_manifest(self.work) self.write_manifest(self.work)
@ -225,16 +272,19 @@ class TtTest(unittest.TestCase):
def test_update_replaces_itself_and_installs_wrappers(self): def test_update_replaces_itself_and_installs_wrappers(self):
tt_copy = os.path.join(self.root, "tt.py") tt_copy = os.path.join(self.root, "tt.py")
shutil.copy(TT_PATH, tt_copy) shutil.copy(TT_PATH, tt_copy)
self.publish_tt({"tt.py": "# updated\n", "tt": "#!/bin/sh\n", "tt.cmd": "@echo off\r\n"}) with open(os.path.join(self.root, "tt.cmd"), "w") as file:
file.write("@echo off\r\n")
self.publish_tt({"tt.py": "# updated\n", "tt": "#!/bin/sh\n", "tt.ps1": "exit 0\r\n"})
self.write_manifest(self.work) self.write_manifest(self.work)
result = self.run_tt("update-self", tt_path=tt_copy) result = self.run_tt("update-self", tt_path=tt_copy)
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
with open(tt_copy) as file: with open(tt_copy) as file:
self.assertEqual(file.read(), "# updated\n") self.assertEqual(file.read(), "# updated\n")
self.assertTrue(os.access(os.path.join(self.root, "tt"), os.X_OK)) self.assertTrue(os.access(os.path.join(self.root, "tt"), os.X_OK))
self.assertTrue(os.access(os.path.join(self.root, "tt.cmd"), os.X_OK)) self.assertTrue(os.access(os.path.join(self.root, "tt.ps1"), os.X_OK))
self.assertTrue(os.access(tt_copy, os.X_OK)) self.assertTrue(os.access(tt_copy, os.X_OK))
self.assertEqual(sorted(os.listdir(self.root)), ["cdn", "home", "tt", "tt.cmd", "tt.py", "work"]) # The obsolete tt.cmd is removed
self.assertEqual(sorted(os.listdir(self.root)), ["cdn", "home", "tt", "tt.ps1", "tt.py", "work"])
def test_update_rejects_invalid_python(self): def test_update_rejects_invalid_python(self):
tt_copy = os.path.join(self.root, "tt.py") tt_copy = os.path.join(self.root, "tt.py")

View File

@ -267,6 +267,24 @@ def write_spiram_variables(output_file, device_properties: dict):
output_file.write("CONFIG_SPIRAM_RODATA=y\n") output_file.write("CONFIG_SPIRAM_RODATA=y\n")
output_file.write("CONFIG_SPIRAM_XIP_FROM_PSRAM=y\n") output_file.write("CONFIG_SPIRAM_XIP_FROM_PSRAM=y\n")
def write_iram_reduction(output_file, device_properties: dict):
# Without PSRAM, internal RAM is scarce: keep these in flash, which costs some speed
has_spiram = get_property_or_exit(device_properties, "hardware.spiRam")
if has_spiram == "true":
return
output_file.write("# Reduce IRAM usage on devices without PSRAM\n")
for option in (
"ESP_PHY_IRAM_OPT",
"LOG_IN_IRAM",
"ESP_TIMER_IN_IRAM",
"ESP_INTR_IN_IRAM",
"ESP_SYSTEM_IN_IRAM",
"LIBC_MISC_IN_IRAM",
"ESP_HW_SUPPORT_FUNC_IN_IRAM",
"SPI_FLASH_PLACE_FUNCTIONS_IN_IRAM",
):
output_file.write(f"CONFIG_{option}=n\n")
def write_performance_improvements(output_file, device_properties: dict): def write_performance_improvements(output_file, device_properties: dict):
idf_target = get_property_or_exit(device_properties, "hardware.target").lower() idf_target = get_property_or_exit(device_properties, "hardware.target").lower()
if idf_target == "esp32s3": if idf_target == "esp32s3":
@ -305,6 +323,11 @@ def write_lvgl_variables(output_file, device_properties: dict):
output_file.write("CONFIG_LV_USE_THEME_DEFAULT=n\n") output_file.write("CONFIG_LV_USE_THEME_DEFAULT=n\n")
output_file.write("CONFIG_LV_USE_THEME_SIMPLE=n\n") output_file.write("CONFIG_LV_USE_THEME_SIMPLE=n\n")
output_file.write("CONFIG_LV_USE_THEME_MONO=n\n") output_file.write("CONFIG_LV_USE_THEME_MONO=n\n")
# Devices without PSRAM can run out of the memory that LVGL's file cache allocates on the first read of a file
fs_cache_size_text = get_property_or_none(device_properties, "lvgl.fsCacheSize")
if fs_cache_size_text is not None:
fs_cache_size = safe_int(fs_cache_size_text, f"File cache size must be an integer, but was: '{fs_cache_size_text}'")
output_file.write(f"CONFIG_LV_FS_STDIO_CACHE_SIZE={fs_cache_size}\n")
font_size_text = get_property_or_none(device_properties, "font.defaultSize") font_size_text = get_property_or_none(device_properties, "font.defaultSize")
if font_size_text is None: if font_size_text is None:
font_size_text = get_property_or_default(device_properties, "lvgl.fontSize", "14") font_size_text = get_property_or_default(device_properties, "lvgl.fontSize", "14")
@ -423,6 +446,7 @@ def write_properties(output_file, device_properties: dict, device_id: str, is_de
write_flash_variables(output_file, device_properties) write_flash_variables(output_file, device_properties)
write_partition_table(output_file, device_properties, is_dev) write_partition_table(output_file, device_properties, is_dev)
write_spiram_variables(output_file, device_properties) write_spiram_variables(output_file, device_properties)
write_iram_reduction(output_file, device_properties)
write_performance_improvements(output_file, device_properties) write_performance_improvements(output_file, device_properties)
write_usb_variables(output_file, device_properties) write_usb_variables(output_file, device_properties)
write_bluetooth_variables(output_file, device_properties) write_bluetooth_variables(output_file, device_properties)

9
tt.cmd
View File

@ -1,9 +0,0 @@
@echo off
rem Runs tt.py from the directory of this script, on Windows
where py >nul 2>nul
if %ERRORLEVEL% equ 0 (
py -3 "%~dp0tt.py" %*
) else (
python "%~dp0tt.py" %*
)
exit /b %ERRORLEVEL%

9
tt.ps1 Executable file
View File

@ -0,0 +1,9 @@
# Runs tt.py from the folder of this script, on Windows.
# PowerShell passes the arguments to Python as they are, without parsing them again.
$script = Join-Path $PSScriptRoot "tt.py"
if (Get-Command py -ErrorAction SilentlyContinue) {
& py -3 $script @args
} else {
& python $script @args
}
exit $LASTEXITCODE

61
tt.py
View File

@ -5,7 +5,7 @@
"tt <command> [arguments]" runs <command>.py from the tools of that SDK version. "tt <command> [arguments]" runs <command>.py from the tools of that SDK version.
When TACTILITY_SDK_PATH is set, its SDK and tools are used instead of downloading them. When TACTILITY_SDK_PATH is set, its SDK and tools are used instead of downloading them.
Both ~/.tactility and TACTILITY_SDK_PATH hold sdk/<version>/tools/ and sdk/<version>/platform-<platform>/TactilitySDK/. Both ~/.tactility and TACTILITY_SDK_PATH hold sdk/<version>/tools/ and sdk/<version>/platform-<platform>/TactilitySDK/.
The tt (Linux, macOS) and tt.cmd (Windows) wrappers next to this script run it. The tt (Linux, macOS) and tt.ps1 (Windows PowerShell) wrappers next to this script run it.
The SDK version is read from ./manifest.properties, or else from the first Apps/*/manifest.properties or apps/*/manifest.properties. The SDK version is read from ./manifest.properties, or else from the first Apps/*/manifest.properties or apps/*/manifest.properties.
""" """
@ -20,7 +20,7 @@ import time
import urllib.request import urllib.request
import zipfile import zipfile
TT_VERSION = "1.0.0" TT_VERSION = "1.1.1"
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
@ -143,16 +143,9 @@ def install_tools(version, version_directory, index):
fail(f"SDK {version} has no tools") fail(f"SDK {version} has no tools")
tools_directory = os.path.join(version_directory, "tools") tools_directory = os.path.join(version_directory, "tools")
hash_path = os.path.join(version_directory, "tools.sha256") hash_path = os.path.join(version_directory, "tools.sha256")
if os.path.isdir(tools_directory) and os.path.isfile(hash_path):
with open(hash_path) as file:
if file.read().strip() == tools["sha256"]:
return
print(f"Downloading the tools of SDK {version}", file=sys.stderr) print(f"Downloading the tools of SDK {version}", file=sys.stderr)
zip_path = os.path.join(version_directory, "tools.zip") zip_path = os.path.join(version_directory, "tools.zip")
if not download(sdk_url(version, tools["file"]), zip_path): if not download(sdk_url(version, tools["file"]), zip_path):
if os.path.isdir(tools_directory):
warn(f"using the cached tools of SDK {version}")
return
fail(f"failed to download the tools of SDK {version}") fail(f"failed to download the tools of SDK {version}")
if file_sha256(zip_path) != tools["sha256"]: if file_sha256(zip_path) != tools["sha256"]:
os.remove(zip_path) os.remove(zip_path)
@ -167,8 +160,6 @@ def install_platforms(version, version_directory, index, platforms):
available = index.get("platforms", {}) available = index.get("platforms", {})
for platform in platforms: for platform in platforms:
platform_directory = os.path.join(version_directory, f"platform-{platform}") platform_directory = os.path.join(version_directory, f"platform-{platform}")
if os.path.isdir(os.path.join(platform_directory, "TactilitySDK")):
continue
if platform not in available: if platform not in available:
fail(f"platform {platform} is not available in SDK {version}") fail(f"platform {platform} is not available in SDK {version}")
print(f"Downloading SDK {version} for {platform}", file=sys.stderr) print(f"Downloading SDK {version} for {platform}", file=sys.stderr)
@ -182,12 +173,50 @@ def install_platforms(version, version_directory, index, platforms):
file.write(index.get("gitCommit", "")) file.write(index.get("gitCommit", ""))
def read_text(path):
try:
with open(path) as file:
return file.read().strip()
except OSError:
return None
def is_installed_build(version_directory, index):
"""True when the installed tools and platforms are of the build that the index describes"""
installed_tools_hash = read_text(os.path.join(version_directory, "tools.sha256"))
if installed_tools_hash is not None and installed_tools_hash != index.get("tools", {}).get("sha256"):
return False
for name in os.listdir(version_directory):
commit = read_text(os.path.join(version_directory, name, "commit"))
if name.startswith("platform-") and commit is not None and commit != index.get("gitCommit", ""):
return False
return True
def prepare_sdk(version, platforms): def prepare_sdk(version, platforms):
"""Installs what's missing of the SDK version. Installed tools and platforms are only replaced by "tt update-sdk", so they stay from the same build."""
version_directory = version_path(HOME_DIRECTORY, version) version_directory = version_path(HOME_DIRECTORY, version)
index = None
# "target.platforms=all" means every platform of the SDK
if platforms == ["all"]:
os.makedirs(version_directory, exist_ok=True)
index = load_index(version, version_directory)
platforms = sorted(index.get("platforms", {}))
has_tools = os.path.isdir(os.path.join(version_directory, "tools"))
missing_platforms = [platform for platform in platforms if not os.path.isdir(os.path.join(version_directory, f"platform-{platform}", "TactilitySDK"))]
if has_tools and not missing_platforms:
return version_directory
os.makedirs(version_directory, exist_ok=True) os.makedirs(version_directory, exist_ok=True)
index = load_index(version, version_directory) if index is None:
install_tools(version, version_directory, index) index = load_index(version, version_directory)
install_platforms(version, version_directory, index, platforms) # The CDN only has the latest build of a version, so a newer build can't be mixed with the installed one
if not is_installed_build(version_directory, index):
if has_tools:
fail(f"SDK {version} was published again since it was installed: run 'tt update-sdk' first")
fail(f"SDK {version} was published again since it was installed: remove {version_directory} and try again")
if not has_tools:
install_tools(version, version_directory, index)
install_platforms(version, version_directory, index, missing_platforms)
return version_directory return version_directory
@ -258,7 +287,9 @@ def main(arguments):
fail(f"tools not found: {tools_directory}") fail(f"tools not found: {tools_directory}")
else: else:
sdk_path = HOME_DIRECTORY sdk_path = HOME_DIRECTORY
tools_directory = os.path.join(prepare_sdk(version, platforms), "tools") # update-sdk replaces the installed build, so it only needs the tools
required_platforms = [] if command == "update-sdk" else platforms
tools_directory = os.path.join(prepare_sdk(version, required_platforms), "tools")
environment = tool_environment(version, sdk_path, is_local) environment = tool_environment(version, sdk_path, is_local)
if is_help: if is_help:
print_help(version, tools_directory) print_help(version, tools_directory)