mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-10-17 02:45:05 +00:00
App Hub, Setup, BT, tt, posix http, and more (#697)
This commit is contained in:
parent
a485dbbf94
commit
76434d5bcd
@ -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
23
.claude/rules/memory.md
Normal 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.
|
||||||
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
41
Buildscripts/TactilitySDK/Tools/screenshot.py
Normal file
41
Buildscripts/TactilitySDK/Tools/screenshot.py
Normal 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()
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
69
Buildscripts/check-tt-version.py
Executable file
69
Buildscripts/check-tt-version.py
Executable 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()
|
||||||
@ -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/'},
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
13
Data/system/certificates/GTS_Root_R4.pem
Normal file
13
Data/system/certificates/GTS_Root_R4.pem
Normal 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-----
|
||||||
@ -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-----
|
|
||||||
@ -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
|
||||||
|
|||||||
Binary file not shown.
@ -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
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 ()
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 {};
|
||||||
|
|||||||
296
Modules/app-hub-module/source/app_hub_command.cpp
Normal file
296
Modules/app-hub-module/source/app_hub_command.cpp
Normal 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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 ()
|
||||||
|
|
||||||
|
|||||||
27
Modules/http-module/private/http/private/http_parsing.h
Normal file
27
Modules/http-module/private/http/private/http_parsing.h
Normal 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);
|
||||||
@ -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");
|
|
||||||
}
|
|
||||||
404
Modules/http-module/source/download_posix.cpp
Normal file
404
Modules/http-module/source/download_posix.cpp
Normal 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);
|
||||||
|
}
|
||||||
145
Modules/http-module/source/http_parsing.cpp
Normal file
145
Modules/http-module/source/http_parsing.cpp
Normal 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;
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
74
Modules/http-module/tests/source/http_parsing_test.cpp
Normal file
74
Modules/http-module/tests/source/http_parsing_test.cpp
Normal 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));
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ list(APPEND REQUIRES_LIST
|
|||||||
TactilityKernelCpp
|
TactilityKernelCpp
|
||||||
service-module
|
service-module
|
||||||
gps-module
|
gps-module
|
||||||
|
http-module
|
||||||
|
cjson-module
|
||||||
minmea
|
minmea
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
36
Modules/lvgl-module/include/lvgl/widgets/step_indicator.h
Normal file
36
Modules/lvgl-module/include/lvgl/widgets/step_indicator.h
Normal 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
|
||||||
20
Modules/lvgl-module/include/lvgl/widgets/text_button.h
Normal file
20
Modules/lvgl-module/include/lvgl/widgets/text_button.h
Normal 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
|
||||||
@ -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 = {};
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
Modules/lvgl-module/source/widgets/step_indicator.cpp
Normal file
82
Modules/lvgl-module/source/widgets/step_indicator.cpp
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
26
Modules/lvgl-module/source/widgets/text_button.cpp
Normal file
26
Modules/lvgl-module/source/widgets/text_button.cpp
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(©);
|
const error_t saved = mesh_settings_save(©);
|
||||||
@ -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(©);
|
return mesh_settings_save(©);
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
83
Tactility/Private/Tactility/app/timezone/TimeZoneView.h
Normal file
83
Tactility/Private/Tactility/app/timezone/TimeZoneView.h
Normal 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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},
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
304
Tactility/Source/app/timezone/TimeZoneView.cpp
Normal file
304
Tactility/Source/app/timezone/TimeZoneView.cpp
Normal 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, ¤tFilter](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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
24
device.py
24
device.py
@ -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
9
tt.cmd
@ -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
9
tt.ps1
Executable 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
61
tt.py
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user