Implement posix app loading (#643)

- Added POSIX desktop support for SDK builds, application packaging, and integration testing.
- Added POSIX filesystem partitions and improved application path handling.
- Added simulator support for loading and running applications dynamically.
- Improved simulator task stack handling and display startup reliability.
- Improved simulator display scaling, resizing, high-DPI support, and pointer accuracy.
- Fixed LVGL timers and input polling on POSIX. (fixes simulator with Linux on some Intel graphics platforms)
- Standardized data paths across platforms.
- Logging now works via separate task: this allows apps to write to log without it affecting their stdout (for apps that output text as relevant date for other apps, like the File Selection app)
- Fixes for app stdio
This commit is contained in:
Ken Van Hoeylandt
2026-08-31 22:09:04 +02:00
committed by GitHub
parent d3656bcd3d
commit 643cbc3806
66 changed files with 2139 additions and 336 deletions
@@ -0,0 +1,43 @@
name: Build
runs:
using: "composite"
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- name: 'Detect architecture'
id: arch
shell: bash
run: echo "value=$(uname -m)" >> "$GITHUB_OUTPUT"
- name: 'Configure'
shell: bash
run: cmake -S ./ -B buildsim
- name: 'Build'
shell: bash
run: cmake --build buildsim --target TactilityKernel lvgl minitar minmea $(cat Buildscripts/release-sdk-modules.txt)
- name: 'Release'
shell: bash
run: python Buildscripts/release-sdk-posix.py release/TactilitySDK
- name: 'Test Integration Prep'
shell: bash
# The manifest.properties of our integration test uses version 0.0.0 to indicate that it is not using a normal SDK
# This way, it only works with our custom build. That means we have to create a copy of the SDK with the correct folder structure:
env:
TACTILITY_ARCH: ${{ steps.arch.outputs.value }}
run: |
TACTILITY_SDK_NAME="0.0.0-posix-$TACTILITY_ARCH"
mkdir -p test_sdk/$TACTILITY_SDK_NAME
cp -r release/TactilitySDK test_sdk/$TACTILITY_SDK_NAME
- name: 'Test Integration'
shell: bash
env:
TACTILITY_ARCH: ${{ steps.arch.outputs.value }}
run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python tactility.py build posix-$TACTILITY_ARCH --local-sdk
- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
with:
name: TactilitySDK-posix-${{ steps.arch.outputs.value }}
path: release/TactilitySDK
retention-days: 30
+1 -1
View File
@@ -29,7 +29,7 @@ runs:
env:
# NOTE: Update with ESP-IDF!
ESP_IDF_VERSION: '5.5.2'
run: python Buildscripts/release-sdk.py release/TactilitySDK
run: python Buildscripts/release-sdk-esp32.py release/TactilitySDK
- name: 'Test Integration Prep'
shell: bash
# The manifest.properties of our integration test uses version 0.0.0 to indicate that it is not using a normal SDK
+11 -3
View File
@@ -11,7 +11,7 @@ on:
permissions: read-all
jobs:
BuildSdk:
BuildSdkEsp32:
strategy:
matrix:
board: [
@@ -30,9 +30,17 @@ jobs:
with:
board_id: ${{ matrix.board.id }}
arch: ${{ matrix.board.arch }}
BuildSdkPosix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: "Build SDK"
uses: ./.github/actions/build-sdk-posix
GenerateDeviceMatrix:
runs-on: ubuntu-latest
needs: [ BuildSdk ]
needs: [ BuildSdkEsp32 ]
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
@@ -57,7 +65,7 @@ jobs:
arch: ${{ matrix.board.arch }}
BundleArtifacts:
runs-on: ubuntu-latest
needs: [ BuildFirmware ]
needs: [ BuildFirmware, BuildSdkPosix ]
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
+3
View File
@@ -2,6 +2,7 @@
.DS_Store
build*/
!.github/actions/build*/
cmake*/
CMakeCache.txt
*.cbp
@@ -25,3 +26,5 @@ sdkconfig.board.*.dev
.caveman.json
.ai/mcp
__pycache__
@@ -4,7 +4,12 @@ endfunction()
function(_tactility_project)
endfunction()
macro(tactility_project project_name)
macro(tactility_project_pre project_name)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH} ${TACTILITY_SDK_PATH}/Modules)
endmacro()
macro(tactility_project_post project_name)
set(TACTILITY_SKIP_SPIFFS 1)
# Tactility's PanicHandler.cpp needs s0 to stay a frame pointer to capture a callstack for
@@ -45,3 +50,13 @@ macro(tactility_project project_name)
)
endmacro()
macro(tactility_component_register)
cmake_parse_arguments(TT_COMPONENT "" "" "SRCS;INCLUDE_DIRS;REQUIRES;PRIV_REQUIRES" ${ARGN})
idf_component_register(
SRCS ${TT_COMPONENT_SRCS}
INCLUDE_DIRS ${TT_COMPONENT_INCLUDE_DIRS}
REQUIRES TactilitySDK ${TT_COMPONENT_REQUIRES}
PRIV_REQUIRES ${TT_COMPONENT_PRIV_REQUIRES}
)
endmacro()
@@ -0,0 +1,51 @@
function(tactility_project)
endfunction()
function(_tactility_project)
endfunction()
macro(tactility_project_pre project_name)
endmacro()
macro(tactility_project_post project_name)
# The app's own library target is defined in a subdirectory (e.g. "main"); without this it
# would land nested under that subdirectory instead of directly in the build dir.
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
# Mirrors the ESP-IDF "TactilitySDK" component (Buildscripts/TactilitySDK/CMakeLists.txt):
# apps link against this single target instead of listing SDK include dirs themselves.
# Posix apps are dlopen()ed into a running Tactility process (see app-posix-module) and
# resolve symbols against Tactility's own copies at load time, so headers are all they need
# at compile time - no libraries to link.
add_library(TactilitySDK INTERFACE)
target_include_directories(TactilitySDK INTERFACE
${TACTILITY_SDK_PATH}/Modules/app-module/include
${TACTILITY_SDK_PATH}/Modules/crypt-module/include
${TACTILITY_SDK_PATH}/Modules/gps-module/include
${TACTILITY_SDK_PATH}/Modules/lvgl-module/include
${TACTILITY_SDK_PATH}/Modules/lvgl-window-manager-module/include
${TACTILITY_SDK_PATH}/Modules/service-module/include
${TACTILITY_SDK_PATH}/Libraries/TactilityKernel/include
${TACTILITY_SDK_PATH}/Libraries/lvgl/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/utils
)
target_compile_definitions(TactilitySDK INTERFACE LV_LVGL_H_INCLUDE_SIMPLE)
# ESP-IDF's project() auto-discovers the "main" component; plain CMake doesn't.
add_subdirectory(main)
endmacro()
macro(tactility_component_register)
cmake_parse_arguments(TT_COMPONENT "" "" "SRCS;INCLUDE_DIRS;REQUIRES;PRIV_REQUIRES" ${ARGN})
# Must be a SHARED object, not a -pie executable: glibc's dlopen() unconditionally refuses
# any ET_DYN carrying the DF_1_PIE flag ("cannot dynamically load position-independent
# executable"), regardless of whether it has a dynamic-linker segment - verified empirically.
add_library(${PROJECT_NAME} SHARED ${TT_COMPONENT_SRCS})
target_link_libraries(${PROJECT_NAME} PRIVATE TactilitySDK)
set_target_properties(${PROJECT_NAME} PROPERTIES POSITION_INDEPENDENT_CODE ON)
if (TT_COMPONENT_INCLUDE_DIRS)
target_include_directories(${PROJECT_NAME} PRIVATE ${TT_COMPONENT_INCLUDE_DIRS})
endif ()
endmacro()
+11 -3
View File
@@ -2,6 +2,8 @@ if (COMMAND tactility_add_module)
return()
endif()
cmake_minimum_required(VERSION 3.24)
macro(tactility_get_module_name NAME OUT_NAME)
if (DEFINED ENV{ESP_IDF_VERSION})
set(${OUT_NAME} ${COMPONENT_LIB})
@@ -16,8 +18,7 @@ macro(tactility_add_module NAME)
# undefined reference to. Needed when this module provides symbols a component it depends on
# (e.g. lvgl__lvgl's custom-allocator hooks) calls back into - a reverse reference a normal
# single-pass static-archive link can't resolve, since that component is scanned after this
# one's archive has already been passed once. POSIX builds link everything as plain OBJECT
# libraries (no archive-pruning to begin with), so this is a no-op there.
# one's archive has already been passed once.
set(options WHOLE_ARCHIVE)
set(oneValueArgs)
set(multiValueArgs SRCS INCLUDE_DIRS PRIV_INCLUDE_DIRS REQUIRES PRIV_REQUIRES)
@@ -40,7 +41,7 @@ macro(tactility_add_module NAME)
${whole_archive_arg}
)
else()
add_library(${NAME} OBJECT)
add_library(${NAME} STATIC)
target_sources(${NAME} PRIVATE ${ARG_SRCS})
target_include_directories(${NAME}
PRIVATE ${ARG_PRIV_INCLUDE_DIRS}
@@ -48,5 +49,12 @@ macro(tactility_add_module NAME)
)
target_link_libraries(${NAME} PUBLIC ${ARG_REQUIRES})
target_link_libraries(${NAME} PRIVATE ${ARG_PRIV_REQUIRES})
if (ARG_WHOLE_ARCHIVE)
# A static archive only pulls in object files that already have a pending undefined
# reference at the point the archive is scanned, so a plain link drops ${NAME}'s
# reverse dependencies (see WHOLE_ARCHIVE comment above). Make whoever links ${NAME}
# whole-archive it instead of just archive-pruning it.
set_property(TARGET ${NAME} APPEND PROPERTY INTERFACE_LINK_LIBRARIES $<LINK_LIBRARY:WHOLE_ARCHIVE,${NAME}>)
endif()
endif()
endmacro()
+31 -19
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import os
import platform
import shutil
import subprocess
import sys
@@ -17,42 +18,53 @@ def get_idf_target():
return None
return None
def main():
# 1. Get idf_target
idf_target = get_idf_target()
if not idf_target:
print("Could not determine IDF target from sdkconfig")
sys.exit(1)
# 2. Get version
def get_version():
try:
with open("version.txt", "r") as f:
version = f.read().strip()
return f.read().strip()
except FileNotFoundError:
print("version.txt not found")
sys.exit(1)
# 3. Construct sdk_path
# release/TactilitySDK/${version}-${idf_target}/TactilitySDK
sdk_path = os.path.join("release", "TactilitySDK", f"{version}-{idf_target}", "TactilitySDK")
# 4. Cleanup sdk_path
def run_release_script(script_name, sdk_path):
# Cleanup sdk_path
if os.path.exists(sdk_path):
print(f"Cleaning up {sdk_path}")
shutil.rmtree(sdk_path)
os.makedirs(sdk_path, exist_ok=True)
# 5. Call release-sdk.py
# Note: Using sys.executable to ensure we use the same python interpreter
script_path = os.path.join("Buildscripts", "release-sdk.py")
script_path = os.path.join("Buildscripts", script_name)
print(f"Running {script_path} {sdk_path}")
result = subprocess.run([sys.executable, script_path, sdk_path])
if result.returncode != 0:
print(f"Error: {script_path} failed with return code {result.returncode}")
sys.exit(result.returncode)
def main():
version = get_version()
# ESP_IDF_VERSION is only set once an ESP-IDF environment has been activated (export.sh /
# the Windows PowerShell profile - see building.md); same check release-sdk-esp32.py and
# release-sdk-posix.py themselves use to tell the two builds apart.
esp_idf_version = os.environ.get("ESP_IDF_VERSION", "")
if esp_idf_version:
idf_target = get_idf_target()
if not idf_target:
print("Could not determine IDF target from sdkconfig")
sys.exit(1)
# release/TactilitySDK/${version}-${idf_target}/TactilitySDK
sdk_path = os.path.join("release", "TactilitySDK", f"{version}-{idf_target}", "TactilitySDK")
run_release_script("release-sdk-esp32.py", sdk_path)
else:
# release/TactilitySDK/${version}-posix-${arch}/TactilitySDK
arch = platform.machine()
sdk_path = os.path.join("release", "TactilitySDK", f"{version}-posix-{arch}", "TactilitySDK")
run_release_script("release-sdk-posix.py", sdk_path)
if __name__ == "__main__":
main()
@@ -1,66 +1,16 @@
#!/usr/bin/env python3
import os
import shutil
import glob
import subprocess
import sys
import importlib.util
from textwrap import dedent
def map_copy(mappings, target_base):
"""
Helper function to map input files/directories to output files/directories.
mappings: list of dicts with 'src' (glob pattern) and 'dst' (relative to target_base or absolute)
'src' can be a single file or a directory (if it ends with /).
"""
for mapping in mappings:
src_pattern = mapping['src']
dst_rel = mapping['dst']
dst_path = os.path.join(target_base, dst_rel)
_shared_spec = importlib.util.spec_from_file_location("release_sdk_shared", os.path.join("Buildscripts", "release-sdk-shared.py"))
shared = importlib.util.module_from_spec(_shared_spec)
_shared_spec.loader.exec_module(shared)
# To preserve directory structure, we need to know where the wildcard starts
# or have a way to determine the "base" of the search.
# We'll split the pattern into a fixed base and a pattern part.
# Simple heuristic: find the first occurrence of '*' or '?'
wildcard_idx = -1
for i, char in enumerate(src_pattern):
if char in '*?':
wildcard_idx = i
break
if wildcard_idx != -1:
# Found a wildcard. The base is the directory containing it.
pattern_base = os.path.dirname(src_pattern[:wildcard_idx])
else:
# No wildcard. If it's a directory, we might want to preserve its name?
# For now, let's treat no-wildcard as no relative structure needed.
pattern_base = None
src_files = glob.glob(src_pattern, recursive=True)
if not src_files:
continue
for src in src_files:
if os.path.isdir(src):
continue
if pattern_base and src.startswith(pattern_base):
# Calculate relative path from the base of the glob pattern
rel_src = os.path.relpath(src, pattern_base)
# If dst_rel ends with /, it's a target directory
if dst_rel.endswith('/') or os.path.isdir(dst_path):
final_dst = os.path.join(dst_path, rel_src)
else:
# If dst_rel is a file, we can't really preserve structure
# unless we join it. But usually it's a dir if structure is preserved.
final_dst = dst_path
else:
final_dst = dst_path if not (dst_rel.endswith('/') or os.path.isdir(dst_path)) else os.path.join(dst_path, os.path.basename(src))
os.makedirs(os.path.dirname(final_dst), exist_ok=True)
shutil.copy2(src, final_dst)
def get_driver_mappings(driver_name):
return [
{'src': f'Drivers/{driver_name}/include/**', 'dst': f'Drivers/{driver_name}/include/'},
@@ -82,11 +32,7 @@ def create_module_cmakelists(module_name):
INCLUDE_DIRS "include"
)
add_prebuilt_library({module_name} "binary/lib{module_name}.a")
'''.format(module_name=module_name))
def write_module_cmakelists(path, content):
with open(path, 'w') as f:
f.write(content)
''')
def driver_is_available(driver_name):
"""
@@ -101,28 +47,20 @@ def driver_is_available(driver_name):
def add_driver(target_path, driver_name):
mappings = get_driver_mappings(driver_name)
map_copy(mappings, target_path)
shared.map_copy(mappings, target_path)
cmakelists_content = create_module_cmakelists(driver_name)
write_module_cmakelists(os.path.join(target_path, f"Drivers/{driver_name}/CMakeLists.txt"), cmakelists_content)
shared.write_module_cmakelists(os.path.join(target_path, f"Drivers/{driver_name}/CMakeLists.txt"), cmakelists_content)
def add_module(target_path, module_name):
mappings = get_module_mappings(module_name)
map_copy(mappings, target_path)
shared.map_copy(mappings, target_path)
cmakelists_content = create_module_cmakelists(module_name)
write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content)
def generate_tactility_sdk_cmake(target_path):
src = os.path.join('Buildscripts', 'TactilitySDK', 'TactilitySDK.cmake')
shutil.copy2(src, os.path.join(target_path, 'TactilitySDK.cmake'))
def generate_tactility_sdk_top_cmakelists(target_path):
src = os.path.join('Buildscripts', 'TactilitySDK', 'CMakeLists.txt')
shutil.copy2(src, os.path.join(target_path, 'CMakeLists.txt'))
shared.write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content)
def main():
if len(sys.argv) < 2:
print("Usage: release-sdk.py [target_path]")
print("Example: release-sdk.py release/TactilitySDK")
print("Usage: release-sdk-esp32.py [target_path]")
print("Example: release-sdk-esp32.py release/TactilitySDK")
sys.exit(1)
esp_idf_version = os.environ.get("ESP_IDF_VERSION", "")
@@ -169,20 +107,16 @@ def main():
{'src': 'Libraries/minmea/COPYING', 'dst': 'Libraries/minmea/'},
]
map_copy(mappings, target_path)
shared.map_copy(mappings, target_path)
# Modules
add_module(target_path, "app-module")
add_module(target_path, "crypt-module")
add_module(target_path, "gps-module")
add_module(target_path, "http-module")
add_module(target_path, "lvgl-module")
add_module(target_path, "lvgl-window-manager-module")
add_module(target_path, "service-module")
module_names = shared.read_module_list(os.path.join('Buildscripts', 'release-sdk-modules.txt'))
for module_name in module_names:
add_module(target_path, module_name)
# Final scripts - copied verbatim
generate_tactility_sdk_cmake(target_path)
generate_tactility_sdk_top_cmakelists(target_path)
shared.generate_tactility_sdk_cmake(target_path, 'esp32')
shared.generate_tactility_sdk_top_cmakelists(target_path)
# Output ESP-IDF SDK version to file
with open(os.path.join(target_path, "idf-version.txt"), "a") as f:
+7
View File
@@ -0,0 +1,7 @@
app-module
crypt-module
gps-module
http-module
lvgl-module
lvgl-window-manager-module
service-module
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
import os
import sys
import importlib.util
from textwrap import dedent
_shared_spec = importlib.util.spec_from_file_location("release_sdk_shared", os.path.join("Buildscripts", "release-sdk-shared.py"))
shared = importlib.util.module_from_spec(_shared_spec)
_shared_spec.loader.exec_module(shared)
def get_module_mappings(module_name):
return [
{'src': f'Modules/{module_name}/include/**', 'dst': f'Modules/{module_name}/include/'},
{'src': f'Modules/{module_name}/*.md', 'dst': f'Modules/{module_name}/'},
{'src': f'buildsim/Modules/{module_name}/lib{module_name}.a', 'dst': f'Modules/{module_name}/binary/lib{module_name}.a'},
]
def create_module_cmakelists(module_name):
return dedent(f'''
cmake_minimum_required(VERSION 3.20)
add_library({module_name} STATIC IMPORTED)
set_target_properties({module_name} PROPERTIES
IMPORTED_LOCATION "${{CMAKE_CURRENT_LIST_DIR}}/binary/lib{module_name}.a"
INTERFACE_INCLUDE_DIRECTORIES "${{CMAKE_CURRENT_LIST_DIR}}/include"
)
''')
def add_module(target_path, module_name):
mappings = get_module_mappings(module_name)
shared.map_copy(mappings, target_path)
cmakelists_content = create_module_cmakelists(module_name)
shared.write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content)
def main():
if len(sys.argv) < 2:
print("Usage: release-sdk-posix.py [target_path]")
print("Example: release-sdk-posix.py release/TactilitySDK")
sys.exit(1)
esp_idf_version = os.environ.get("ESP_IDF_VERSION", "")
if esp_idf_version:
print("Error: ESP_IDF_VERSION environment variable is set - this script packages the POSIX/simulator build, run it outside an ESP-IDF environment")
sys.exit(1)
target_path = os.path.abspath(sys.argv[1])
os.makedirs(target_path, exist_ok=True)
# Mapping logic
mappings = [
{'src': 'version.txt', 'dst': ''},
# TactilityFreeRtos
{'src': 'TactilityFreeRtos/Include/**', 'dst': 'Libraries/TactilityFreeRtos/Include/'},
{'src': 'TactilityFreeRtos/CMakeLists.txt', 'dst': 'Libraries/TactilityFreeRtos/'},
{'src': 'TactilityFreeRtos/LICENSE*.*', 'dst': 'Libraries/TactilityFreeRtos/'},
# TactilityKernel
{'src': 'buildsim/TactilityKernel/libTactilityKernel.a', 'dst': 'Libraries/TactilityKernel/binary/'},
{'src': 'TactilityKernel/include/**', 'dst': 'Libraries/TactilityKernel/include/'},
{'src': 'TactilityKernel/CMakeLists.txt', 'dst': 'Libraries/TactilityKernel/'},
{'src': 'TactilityKernel/*.md', 'dst': 'Libraries/TactilityKernel/'},
# 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"
# component and Kconfig-generated FreeRTOSConfig.h are already part of every project.
{'src': 'Libraries/FreeRTOS-Kernel/include/**', 'dst': 'Libraries/FreeRTOS-Kernel/include/'},
{'src': 'Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/*.h', 'dst': 'Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/'},
{'src': 'Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/utils/*.h', 'dst': 'Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/utils/'},
{'src': 'Libraries/FreeRTOS-Kernel/LICENSE*.*', 'dst': 'Libraries/FreeRTOS-Kernel/'},
{'src': 'Devices/simulator/Source/FreeRTOSConfig.h', 'dst': 'Libraries/FreeRTOS-Kernel/include/'},
# lvgl (basics)
{'src': 'buildsim/Libraries/lvgl/lib/liblvgl.a', 'dst': 'Libraries/lvgl/binary/liblvgl.a'},
{'src': 'Libraries/lvgl/lvgl.h', 'dst': 'Libraries/lvgl/include/'},
{'src': 'Libraries/lvgl/lv_version.h', 'dst': 'Libraries/lvgl/include/'},
{'src': 'Libraries/lvgl/LICENCE*.*', 'dst': 'Libraries/lvgl/'},
{'src': 'lv_conf.h', 'dst': 'Libraries/lvgl/include/'},
{'src': 'Libraries/lvgl/src/**/*.h', 'dst': 'Libraries/lvgl/include/src/'},
# minitar
{'src': 'buildsim/Libraries/minitar/libminitar.a', 'dst': 'Libraries/minitar/binary/'},
{'src': 'Libraries/minitar/minitar/minitar.h', 'dst': 'Libraries/minitar/include/'},
{'src': 'Libraries/minitar/minitar/LICENSE*', 'dst': 'Libraries/minitar/'},
# minmea
{'src': 'buildsim/Libraries/minmea/libminmea.a', 'dst': 'Libraries/minmea/binary/'},
{'src': 'Libraries/minmea/Include/**', 'dst': 'Libraries/minmea/include/'},
{'src': 'Libraries/minmea/CMakeLists.txt', 'dst': 'Libraries/minmea/'},
{'src': 'Libraries/minmea/README.md', 'dst': 'Libraries/minmea/'},
{'src': 'Libraries/minmea/LICENSE*.*', 'dst': 'Libraries/minmea/'},
{'src': 'Libraries/minmea/COPYING', 'dst': 'Libraries/minmea/'},
]
shared.map_copy(mappings, target_path)
# Modules
module_names = shared.read_module_list(os.path.join('Buildscripts', 'release-sdk-modules.txt'))
for module_name in module_names:
add_module(target_path, module_name)
# Final scripts - copied verbatim
shared.generate_tactility_sdk_cmake(target_path, 'posix')
shared.generate_tactility_sdk_top_cmakelists(target_path)
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Functions shared between release-sdk-esp32.py and release-sdk-posix.py. Not runnable on its
# own; loaded by those scripts via importlib (its hyphenated filename isn't a valid Python
# module name for a plain "import").
import os
import shutil
import glob
import sys
def map_copy(mappings, target_base):
"""
Helper function to map input files/directories to output files/directories.
mappings: list of dicts with 'src' (glob pattern) and 'dst' (relative to target_base or absolute)
'src' can be a single file or a directory (if it ends with /).
"""
for mapping in mappings:
src_pattern = mapping['src']
dst_rel = mapping['dst']
dst_path = os.path.join(target_base, dst_rel)
# To preserve directory structure, we need to know where the wildcard starts
# or have a way to determine the "base" of the search.
# We'll split the pattern into a fixed base and a pattern part.
# Simple heuristic: find the first occurrence of '*' or '?'
wildcard_idx = -1
for i, char in enumerate(src_pattern):
if char in '*?':
wildcard_idx = i
break
if wildcard_idx != -1:
# Found a wildcard. The base is the directory containing it.
pattern_base = os.path.dirname(src_pattern[:wildcard_idx])
else:
# No wildcard. If it's a directory, we might want to preserve its name?
# For now, let's treat no-wildcard as no relative structure needed.
pattern_base = None
src_files = glob.glob(src_pattern, recursive=True)
if not src_files:
continue
for src in src_files:
if os.path.isdir(src):
continue
if pattern_base and src.startswith(pattern_base):
# Calculate relative path from the base of the glob pattern
rel_src = os.path.relpath(src, pattern_base)
# If dst_rel ends with /, it's a target directory
if dst_rel.endswith('/') or os.path.isdir(dst_path):
final_dst = os.path.join(dst_path, rel_src)
else:
# If dst_rel is a file, we can't really preserve structure
# unless we join it. But usually it's a dir if structure is preserved.
final_dst = dst_path
else:
final_dst = dst_path if not (dst_rel.endswith('/') or os.path.isdir(dst_path)) else os.path.join(dst_path, os.path.basename(src))
os.makedirs(os.path.dirname(final_dst), exist_ok=True)
shutil.copy2(src, final_dst)
def write_module_cmakelists(path, content):
with open(path, 'w') as f:
f.write(content)
def read_module_list(path):
"""Reads a newline-separated module name list, skipping empty lines, and checks that each
named module actually exists under Modules/ - exits the process with an error if not."""
with open(path, 'r') as f:
module_names = [line.strip() for line in f if line.strip()]
for module_name in module_names:
if not os.path.isdir(os.path.join('Modules', module_name)):
print(f"Error: Modules/{module_name} does not exist (listed in {path})")
sys.exit(1)
return module_names
def generate_tactility_sdk_cmake(target_path, variant):
"""variant selects Buildscripts/TactilitySDK/TactilitySDK.{variant}.cmake (e.g. "esp32" or
"posix") - always copied into the SDK as the platform-neutral name TactilitySDK.cmake."""
src = os.path.join('Buildscripts', 'TactilitySDK', f'TactilitySDK.{variant}.cmake')
shutil.copy2(src, os.path.join(target_path, 'TactilitySDK.cmake'))
def generate_tactility_sdk_top_cmakelists(target_path):
src = os.path.join('Buildscripts', 'TactilitySDK', 'CMakeLists.txt')
shutil.copy2(src, os.path.join(target_path, 'CMakeLists.txt'))
+1
View File
@@ -129,6 +129,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
add_subdirectory(Modules/pthread-module)
add_subdirectory(Modules/service-module)
add_subdirectory(Modules/app-module)
add_subdirectory(Modules/app-posix-module)
add_subdirectory(Modules/lvgl-window-manager-module)
add_subdirectory(Drivers/gps-generic-module)
add_subdirectory(Drivers/gps-meshtastic-module)
+105 -38
View File
@@ -15,50 +15,46 @@ constexpr auto* TAG = "SdlDisplay";
#define GET_CONFIG(device) (static_cast<const SdlDisplayConfig*>((device)->config))
struct SdlDisplayInternal {
bool initialized;
bool init_failed;
SDL_Window* window;
SDL_Renderer* renderer;
SDL_Texture* texture;
};
// Only one sdl-display device exists in the simulator; sdl_input.cpp uses this to map window
// coordinates back to the fixed logical resolution SDL_RenderSetLogicalSize() scales to the window.
static SdlDisplayInternal* g_display_internal = nullptr;
SDL_Renderer* sdl_display_get_renderer(void) {
return g_display_internal != nullptr ? g_display_internal->renderer : nullptr;
}
// Re-blits the already-drawn texture at the renderer's current (possibly just-resized) scale.
// No new pixel data needed: the window resizing doesn't change what LVGL last rendered, only how
// large it should appear, and SDL only applies that until the next SDL_RenderPresent() call.
void sdl_display_present_now(void) {
if (g_display_internal == nullptr) {
return;
}
SDL_RenderClear(g_display_internal->renderer);
SDL_RenderCopy(g_display_internal->renderer, g_display_internal->texture, nullptr, nullptr);
SDL_RenderPresent(g_display_internal->renderer);
}
// region Driver lifecycle
static error_t start(Device* device) {
const auto* config = GET_CONFIG(device);
auto* internal = static_cast<SdlDisplayInternal*>(malloc(sizeof(SdlDisplayInternal)));
if (internal == nullptr) {
return ERROR_OUT_OF_MEMORY;
}
if (SDL_InitSubSystem(SDL_INIT_VIDEO) != 0) {
LOG_E(TAG, "SDL_InitSubSystem failed: %s", SDL_GetError());
free(internal);
return ERROR_RESOURCE;
}
internal->window = SDL_CreateWindow(
"Tactility",
SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
config->horizontal_resolution, config->vertical_resolution,
SDL_WINDOW_SHOWN
);
internal->renderer = internal->window != nullptr
? SDL_CreateRenderer(internal->window, -1, SDL_RENDERER_ACCELERATED)
: nullptr;
internal->texture = internal->renderer != nullptr
? SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING,
config->horizontal_resolution, config->vertical_resolution)
: nullptr;
if (internal->window == nullptr || internal->renderer == nullptr || internal->texture == nullptr) {
LOG_E(TAG, "Failed to create SDL window: %s", SDL_GetError());
if (internal->texture != nullptr) SDL_DestroyTexture(internal->texture);
if (internal->renderer != nullptr) SDL_DestroyRenderer(internal->renderer);
if (internal->window != nullptr) SDL_DestroyWindow(internal->window);
SDL_QuitSubSystem(SDL_INIT_VIDEO);
free(internal);
return ERROR_RESOURCE;
}
internal->initialized = false;
internal->init_failed = false;
internal->window = nullptr;
internal->renderer = nullptr;
internal->texture = nullptr;
device_set_driver_data(device, internal);
return ERROR_NONE;
@@ -67,10 +63,16 @@ static error_t start(Device* device) {
static error_t stop(Device* device) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
SDL_DestroyTexture(internal->texture);
SDL_DestroyRenderer(internal->renderer);
SDL_DestroyWindow(internal->window);
SDL_QuitSubSystem(SDL_INIT_VIDEO);
if (internal->initialized) {
SDL_DestroyTexture(internal->texture);
SDL_DestroyRenderer(internal->renderer);
SDL_DestroyWindow(internal->window);
SDL_QuitSubSystem(SDL_INIT_VIDEO);
}
if (g_display_internal == internal) {
g_display_internal = nullptr;
}
free(internal);
device_set_driver_data(device, nullptr);
@@ -84,18 +86,83 @@ static error_t stop(Device* device) {
static error_t sdl_display_reset(Device*) { return ERROR_NONE; }
static error_t sdl_display_init(Device*) { return ERROR_NONE; }
static float sdl_display_get_dpi_scale() {
float hdpi = 96.0f;
if (SDL_GetDisplayDPI(0, nullptr, &hdpi, nullptr) != 0 || hdpi <= 0.0f) {
return 1.0f;
}
return hdpi / 96.0f;
}
static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal) {
const auto* config = GET_CONFIG(device);
if (SDL_InitSubSystem(SDL_INIT_VIDEO) != 0) {
LOG_E(TAG, "SDL_InitSubSystem failed: %s", SDL_GetError());
return false;
}
// Only the window's initial on-screen footprint scales here - the render/logical resolution
// (config->horizontal_resolution/vertical_resolution, used below for the texture and
// SDL_RenderSetLogicalSize()) is unaffected, same as any other resize the user does by hand.
const float dpi_scale = sdl_display_get_dpi_scale();
const int initial_width = static_cast<int>(config->horizontal_resolution * dpi_scale);
const int initial_height = static_cast<int>(config->vertical_resolution * dpi_scale);
internal->window = SDL_CreateWindow(
"Tactility",
SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
initial_width, initial_height,
SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI
);
internal->renderer = internal->window != nullptr
? SDL_CreateRenderer(internal->window, -1, SDL_RENDERER_ACCELERATED)
: nullptr;
// Lets the window be resized freely while the renderer scales/letterboxes the fixed-resolution
// texture below to fit - LVGL keeps rendering at horizontal_resolution x vertical_resolution.
if (internal->renderer != nullptr) {
SDL_RenderSetLogicalSize(internal->renderer, config->horizontal_resolution, config->vertical_resolution);
}
internal->texture = internal->renderer != nullptr
? SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING,
config->horizontal_resolution, config->vertical_resolution)
: nullptr;
if (internal->window == nullptr || internal->renderer == nullptr || internal->texture == nullptr) {
LOG_E(TAG, "Failed to create SDL window: %s", SDL_GetError());
if (internal->texture != nullptr) SDL_DestroyTexture(internal->texture);
if (internal->renderer != nullptr) SDL_DestroyRenderer(internal->renderer);
if (internal->window != nullptr) SDL_DestroyWindow(internal->window);
SDL_QuitSubSystem(SDL_INIT_VIDEO);
return false;
}
g_display_internal = internal;
return true;
}
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
if (internal->init_failed) {
return ERROR_RESOURCE;
}
if (!internal->initialized) {
if (!sdl_display_lazy_init(device, internal)) {
internal->init_failed = true;
return ERROR_RESOURCE;
}
internal->initialized = true;
}
SDL_Rect rect = { x_start, y_start, x_end - x_start, y_end - y_start };
// RGB565 = 2 bytes/pixel.
if (SDL_UpdateTexture(internal->texture, &rect, color_data, (x_end - x_start) * 2) != 0) {
return ERROR_RESOURCE;
}
SDL_RenderClear(internal->renderer);
SDL_RenderCopy(internal->renderer, internal->texture, nullptr, nullptr);
SDL_RenderPresent(internal->renderer);
sdl_display_present_now();
return ERROR_NONE;
}
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <SDL2/SDL.h>
#ifdef __cplusplus
extern "C" {
#endif
@@ -12,6 +14,22 @@ struct SdlDisplayConfig {
uint16_t vertical_resolution;
};
/**
* @return the display's renderer, or NULL if the display hasn't drawn its first frame yet
* (see sdl_display_lazy_init() in sdl_display.cpp). Used by sdl_input.cpp to map window
* coordinates back to the fixed logical resolution the renderer scales to fit the window.
*/
SDL_Renderer* sdl_display_get_renderer(void);
/**
* @brief Re-presents the already-drawn frame at the renderer's current scale. Call this when the
* window is resized: the window size change alone doesn't make SDL re-blit the last frame at
* the new scale until something calls SDL_RenderPresent() again, and LVGL won't do that on
* its own since nothing it's tracking actually changed. No-op if the display hasn't drawn its
* first frame yet.
*/
void sdl_display_present_now(void);
#ifdef __cplusplus
}
#endif
+34 -4
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_input.h"
#include "sdl_display.h"
#include <tactility/drivers/keyboard.h>
@@ -34,6 +35,23 @@ void push_key(uint32_t key) {
// all of these back to LVGL's own sentinels (CODEPOINT_ESCAPE/BACKSPACE/DELETE already equal their
// LV_KEY_* counterpart numerically, so no translation is needed for those). Printable characters
// arrive separately via SDL_TEXTINPUT.
// The window can be freely resized (see sdl_display.cpp's SDL_WINDOW_RESIZABLE/
// SDL_RenderSetLogicalSize()), so raw SDL mouse coordinates are in window-pixel space, not the
// fixed logical resolution LVGL renders at. SDL_RenderWindowToLogical() is the renderer's own
// inverse of that scaling, accounting for both the scale factor and any letterbox offset.
void set_pointer_position(int32_t window_x, int32_t window_y) {
SDL_Renderer* renderer = sdl_display_get_renderer();
if (renderer == nullptr) {
pointer_state.x = window_x;
pointer_state.y = window_y;
return;
}
float logical_x, logical_y;
SDL_RenderWindowToLogical(renderer, window_x, window_y, &logical_x, &logical_y);
pointer_state.x = static_cast<int32_t>(logical_x);
pointer_state.y = static_cast<int32_t>(logical_y);
}
uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) {
switch (sdl_key) {
case SDLK_RIGHT: return CODEPOINT_ARROW_RIGHT;
@@ -64,13 +82,17 @@ void sdl_input_pump() {
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_MOUSEMOTION:
pointer_state.x = event.motion.x;
pointer_state.y = event.motion.y;
set_pointer_position(event.motion.x, event.motion.y);
break;
case SDL_MOUSEBUTTONDOWN:
if (event.button.button == SDL_BUTTON_LEFT) {
pointer_state.x = event.button.x;
pointer_state.y = event.button.y;
// event.button.x/y can be stale immediately after a window resize (an
// SDL/X11 event-queue quirk - confirmed by comparing against a live
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
// OS for the current pointer position directly, sidestepping that entirely.
int live_x, live_y;
SDL_GetMouseState(&live_x, &live_y);
set_pointer_position(live_x, live_y);
pointer_state.pressed = true;
}
break;
@@ -86,6 +108,14 @@ void sdl_input_pump() {
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
push_key(static_cast<uint8_t>(event.text.text[0]));
break;
case SDL_WINDOWEVENT:
// Resizing doesn't change what LVGL last rendered, only how large it should
// appear - re-present the existing frame at the new scale immediately, rather
// than leaving stale-looking content on screen until the next LVGL-driven flush.
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
sdl_display_present_now();
}
break;
case SDL_QUIT:
exit(0);
default:
+1 -1
View File
@@ -29,7 +29,7 @@ static Driver* const simulator_drivers[] = {
// These devices have no real bus to attach to (SDL has no notion of one), but every non-root
// device is still expected to have a parent (see Device::parent) - they're parented to root once
// it's available below.
static const SdlDisplayConfig sdl_display_config = { 320, 240 };
static const SdlDisplayConfig sdl_display_config = { 640, 480 };
static Device sdl_display_device {};
static Device sdl_pointer_device {};
static Device sdl_keyboard_device {};
@@ -27,11 +27,7 @@ static error_t start(Device* device) {
auto* parent = device_get_parent(device);
check(device_get_type(parent) == &I2C_CONTROLLER_TYPE);
auto address = GET_CONFIG(device)->address;
if (i2c_controller_has_device_at_address(parent, address, I2C_TIMEOUT) != ERROR_NONE) {
LOG_E(TAG, "No device found on I2C bus at address 0x%02X", address);
return ERROR_RESOURCE;
}
// We don't check whether the device is present, because it doesn't respond reliably at boot
auto* internal = static_cast<TdeckKeyboardInternal*>(malloc(sizeof(TdeckKeyboardInternal)));
if (internal == nullptr) {
@@ -28,11 +28,7 @@ static error_t start(Device* device) {
auto* parent = device_get_parent(device);
check(device_get_type(parent) == &I2C_CONTROLLER_TYPE);
auto address = GET_CONFIG(device)->address;
if (i2c_controller_has_device_at_address(parent, address, I2C_TIMEOUT) != ERROR_NONE) {
LOG_E(TAG, "No device found on I2C bus at address 0x%02X", address);
return ERROR_RESOURCE;
}
// We don't check whether the device is present, because it doesn't respond reliably at boot
auto* internal = static_cast<TdeckKeyboardBacklightInternal*>(malloc(sizeof(TdeckKeyboardBacklightInternal)));
if (internal == nullptr) {
@@ -43,6 +39,8 @@ static error_t start(Device* device) {
auto brightness_default = GET_CONFIG(device)->brightness_default;
auto address = GET_CONFIG(device)->address;
// Configures the keyboard controller's own persisted default, used by its onboard ALT+B toggle.
if (i2c_controller_write_register(parent, address, CMD_DEFAULT_BRIGHTNESS, &brightness_default, 1, I2C_TIMEOUT) != ERROR_NONE) {
LOG_E(TAG, "Failed to set default brightness");
+9
View File
@@ -10,3 +10,12 @@ tactility_add_module(app-module
INCLUDE_DIRS include/
REQUIRES TactilityKernel service-module minitar
)
# Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close()
# rather than calling ::read/::write/::close() directly, on every platform whose build wraps
# those symbols (ESP-IDF always; POSIX except macOS, whose linker doesn't support --wrap) -
# see Tactility/CMakeLists.txt and the top-level CMakeLists.txt for where that's applied.
if (NOT APPLE)
tactility_get_module_name(app-module MODULE_NAME)
target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO)
endif ()
+17
View File
@@ -111,6 +111,23 @@ struct AppStreamBinding {
*/
error_t app_manager_start_with_streams(const char* id, const struct AppStreamBinding* bindings, size_t binding_count, AppInstanceId* out_app_instance_id);
/**
* Combines app_manager_start_for_result() and app_manager_start_with_streams(): starts @a id as
* a modal child of @a parent_instance_id (see app_manager_start_for_result()'s own doc for the
* result-delivery contract) with @a bindings installed into its fd table before its task begins
* executing (see app_manager_start_with_streams()'s own doc for stream ownership). For a child
* that needs to hand back more than an int32_t (e.g. a path) via its own stdout instead of the
* "get last result" getter pattern (see app_manager_start_for_result()) - see e.g.
* tt::app::fileselection::startForExistingFile().
* @param[in] argv see app_manager_start_for_result().
* @param[in] bindings see app_manager_start_with_streams().
* @retval ERROR_NOT_FOUND no manifest with this id is registered, or no AppLoaderApi is registered
* @retval ERROR_OUT_OF_RANGE a binding's producer_fd is out of range
* @retval ERROR_RESOURCE a binding's event_group has no free bits left to claim
* @retval ERROR_NONE on success
*/
error_t app_manager_start_for_result_with_streams(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], const struct AppStreamBinding* bindings, size_t binding_count, AppInstanceId* out_app_instance_id);
/**
* Stop an app instance permanently. Emits APP_EVENT_CLOSE and bound-waits for its task to exit
* if it was running.
+16 -4
View File
@@ -7,7 +7,7 @@
#include <cerrno>
#ifdef ESP_PLATFORM
#if defined(TT_APP_IO_WRAPS_STDIO)
extern "C" {
ssize_t __real_read(int fd, void* buffer, size_t size);
ssize_t __real_write(int fd, const void* buffer, size_t size);
@@ -56,7 +56,7 @@ ssize_t app_io_read(int fd, void* buffer, size_t size) {
errno = EBADF;
return -1;
}
#ifdef ESP_PLATFORM
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_read(fd, buffer, size);
#else
return ::read(fd, buffer, size);
@@ -71,13 +71,25 @@ ssize_t app_io_write(int fd, const void* buffer, size_t size) {
if (file.ops->release != nullptr) {
file.ops->release(file.object);
}
// Tee to the real fd too: a bound stream only exists because a parent explicitly asked
// to capture this app instance's own output (see AppStreamBinding), but generic code
// running on that same instance's thread - most commonly the platform's own logging
// (LOG_I/etc, which calls write() the same as anything else) - has no way to know its
// output is currently being intercepted. Without this, a log line emitted while any app
// instance has its stdout captured would vanish from the console entirely instead of
// just also being visible to the capturing parent.
#if defined(TT_APP_IO_WRAPS_STDIO)
__real_write(fd, buffer, size);
#else
::write(fd, buffer, size);
#endif
return result;
}
if (table != nullptr && app_fd_table_is_app_owned(table, fd)) {
errno = EBADF;
return -1;
}
#ifdef ESP_PLATFORM
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_write(fd, buffer, size);
#else
return ::write(fd, buffer, size);
@@ -96,7 +108,7 @@ int app_io_close(int fd) {
return -1;
}
}
#ifdef ESP_PLATFORM
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_close(fd);
#else
return ::close(fd);
+4
View File
@@ -178,6 +178,10 @@ error_t app_manager_start_with_streams(const char* id, const AppStreamBinding* b
return start_internal(id, 0, 0, nullptr, bindings, binding_count, out_app_instance_id);
}
error_t app_manager_start_for_result_with_streams(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], const AppStreamBinding* bindings, size_t binding_count, AppInstanceId* out_app_instance_id) {
return start_internal(id, parent_instance_id, argc, copy_arguments(argc, argv), bindings, binding_count, out_app_instance_id);
}
error_t app_manager_stop(AppInstanceId app_instance_id) {
return app_scheduler_stop(app_instance_id, pdMS_TO_TICKS(2000));
}
+1
View File
@@ -38,6 +38,7 @@ static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(app_manager_start_with_parameters),
DEFINE_MODULE_SYMBOL(app_manager_start_for_result),
DEFINE_MODULE_SYMBOL(app_manager_start_with_streams),
DEFINE_MODULE_SYMBOL(app_manager_start_for_result_with_streams),
DEFINE_MODULE_SYMBOL(app_manager_stop),
DEFINE_MODULE_SYMBOL(app_manager_get_state),
DEFINE_MODULE_SYMBOL(app_manager_find_manifest),
+7
View File
@@ -199,12 +199,19 @@ void app_task_main(void* context) {
check(pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX) == nullptr);
vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, reinterpret_cast<void*>(static_cast<uintptr_t>(ctx->app_instance_id)));
// Debug logging so it's invisible by default
// When logging happens, it can distort the application stdout, which breaks apps that use
// stdout to output important information, such as the file selection dialog app.
LOG_I(TAG, "[instance %lu] Task started", ctx->app_instance_id);
set_state(ctx->app_instance_id, APP_INSTANCE_STATE_ACTIVE);
int32_t result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, ctx->argc, ctx->argv);
// The platform might buffer stdout (e.g. esp-idf with newlib)
// Do a manual flush to ensure data has been written:
fflush(stdout);
vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, nullptr);
ctx->loader->unload(ctx->runtime);
+21
View File
@@ -6,10 +6,31 @@ enable_language(C CXX ASM)
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
add_executable(AppModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
if (NOT APPLE)
# Provides __wrap_read/write/close for the -Wl,--wrap= below (see Tactility/CMakeLists.txt
# for the canonical pairing of this file with those flags).
target_sources(AppModuleTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../../../Tactility/Source/AppStdioWrap.cpp)
endif ()
target_include_directories(AppModuleTests PRIVATE ${DOCTESTINC})
add_test(NAME AppModuleTests COMMAND AppModuleTests)
# Matches app-module's own TT_APP_IO_WRAPS_STDIO (see Modules/app-module/CMakeLists.txt):
# io.cpp calls __real_read/write/close() on non-Apple POSIX, so any final link of it needs
# these wraps too, or those symbols go unresolved. The printf-family/getc-family flags are
# needed for the same reason: AppStdioWrap.cpp compiles those __wrap_* functions unconditionally
# on this platform (see its own #if guard), and they reference __real_vfprintf/fputs/fputc/
# fgetc/fgets - those only exist once the matching --wrap flag is passed.
if (NOT APPLE)
target_link_options(AppModuleTests PRIVATE
"-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
target_link_libraries(AppModuleTests PUBLIC
TactilityKernel
app-module
+17
View File
@@ -0,0 +1,17 @@
cmake_minimum_required(VERSION 3.20)
include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake")
file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
tactility_add_module(app-posix-module
SRCS ${SOURCE_FILES}
INCLUDE_DIRS include/
REQUIRES TactilityKernel app-module service-module
PRIV_REQUIRES ${CMAKE_DL_LIBS}
)
# Baked in at compile time, mirroring ESP32's CONFIG_IDF_TARGET - names the installed-app binary
# (e.g. "elf/posix-x86_64.so") this Tactility process's own architecture can dlopen(), letting one
# .app package bundle a variant per posix architecture, same as one .app bundles a .elf per chip.
target_compile_definitions(app-posix-module PRIVATE "TACTILITY_POSIX_ARCH=\"${CMAKE_SYSTEM_PROCESSOR}\"")
@@ -0,0 +1,195 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### Terms and Conditions for use, reproduction, and distribution
#### 1. Definitions
“License” shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
“Licensor” shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
“Legal Entity” shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, “control” means **(i)** the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
outstanding shares, or **(iii)** beneficial ownership of such entity.
“You” (or “Your”) shall mean an individual or Legal Entity exercising
permissions granted by this License.
“Source” form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
“Object” form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
“Work” shall mean the work of authorship, whether in Source or Object form, made
available under the License, as indicated by a copyright notice that is included
in or attached to the work (an example is provided in the Appendix below).
“Derivative Works” shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
“Contribution” shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
“submitted” means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as “Not a Contribution.”
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
#### 2. Grant of Copyright License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.
#### 3. Grant of Patent License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable (except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
such license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by combination
of their Contribution(s) with the Work to which such Contribution(s) was
submitted. If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under this License
for that Work shall terminate as of the date such litigation is filed.
#### 4. Redistribution
You may reproduce and distribute copies of the Work or Derivative Works thereof
in any medium, with or without modifications, and in Source or Object form,
provided that You meet the following conditions:
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
this License; and
* **(b)** You must cause any modified files to carry prominent notices stating that You
changed the files; and
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
all copyright, patent, trademark, and attribution notices from the Source form
of the Work, excluding those notices that do not pertain to any part of the
Derivative Works; and
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
#### 5. Submission of Contributions
Unless You explicitly state otherwise, any Contribution intentionally submitted
for inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the terms of
any separate license agreement you may have executed with Licensor regarding
such Contributions.
#### 6. Trademarks
This License does not grant permission to use the trade names, trademarks,
service marks, or product names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
#### 7. Disclaimer of Warranty
Unless required by applicable law or agreed to in writing, Licensor provides the
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
including, without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise of
permissions under this License.
#### 8. Limitation of Liability
In no event and under no legal theory, whether in tort (including negligence),
contract, or otherwise, unless required by applicable law (such as deliberate
and grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, incidental,
or consequential damages of any character arising as a result of this License or
out of the use or inability to use the Work (including but not limited to
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
any and all other commercial damages or losses), even if such Contributor has
been advised of the possibility of such damages.
#### 9. Accepting Warranty or Additional Liability
While redistributing the Work or Derivative Works thereof, You may choose to
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
other liability obligations and/or rights consistent with this License. However,
in accepting such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and only if You
agree to indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
_END OF TERMS AND CONDITIONS_
### APPENDIX: How to apply the Apache License to your work
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets `[]` replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on
the same “printed page” as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
extern struct Module app_posix_module;
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: Apache-2.0
#include <app/loader.h>
#include <app/location.h>
#include <tactility/error.h>
#include <tactility/log.h>
#include <service/manager.h>
#include <dlfcn.h>
#include <sys/stat.h>
#include <new>
#include <string>
constexpr auto* TAG = "app_posix_loader";
namespace {
/** load()-allocated state, passed back through run()/unload(). */
struct PosixAppRuntime {
void* handle = nullptr;
};
bool is_regular_file(const std::string& path) {
struct stat path_stat {};
return ::stat(path.c_str(), &path_stat) == 0 && S_ISREG(path_stat.st_mode);
}
// location.location can be either an app's install directory or the .so file directly; the
// former resolves to the per-architecture binary at {dir}/elf/posix-{TACTILITY_POSIX_ARCH}.so,
// mirroring app_esp32_loader_service.cpp's resolve_elf_path().
error_t resolve_app_path(const std::string& path, std::string& resolvedPath) {
if (path.ends_with(".so")) {
resolvedPath = path;
return ERROR_NONE;
}
std::string shared_object_path = path + "/elf/posix-" TACTILITY_POSIX_ARCH ".so";
if (!is_regular_file(shared_object_path)) {
return ERROR_NOT_FOUND;
}
resolvedPath = shared_object_path;
return ERROR_NONE;
}
error_t api_load(AppLocation location, AppRuntime* out_runtime) {
if (location.type != APP_LOCATION_PATH) {
LOG_E(TAG, "Unsupported location type");
return ERROR_NOT_SUPPORTED;
}
std::string app_path;
auto error = resolve_app_path(static_cast<const char*>(location.location), app_path);
if (error != ERROR_NONE) {
LOG_E(TAG, "Failed to resolve app path: %s", location.location);
return error;
}
LOG_I(TAG, "Loading %s", app_path.c_str());
// RTLD_NOW: a missing symbol fails here, not mid-run(). RTLD_LOCAL: this app's own exported
// symbols (if any beyond its entry point) don't leak into the process's global scope and
// clash with a different app's.
void* handle = dlopen(app_path.c_str(), RTLD_NOW | RTLD_LOCAL);
if (handle == nullptr) {
LOG_E(TAG, "dlopen(%s) failed: %s", app_path.c_str(), dlerror());
return ERROR_NOT_FOUND;
}
auto* runtime = new (std::nothrow) PosixAppRuntime { .handle = handle };
if (runtime == nullptr) {
LOG_E(TAG, "Out of memory");
dlclose(handle);
return ERROR_OUT_OF_MEMORY;
}
*out_runtime = runtime;
return ERROR_NONE;
}
int32_t api_run(AppRuntime runtime_ptr, uint32_t /*app_instance_id*/, int argc, char* argv[]) {
auto* runtime = static_cast<PosixAppRuntime*>(runtime_ptr);
dlerror(); // clear any pending error, per dlsym(3)'s own recommended idiom for telling a NULL
// symbol address apart from a real lookup failure
void* symbol = dlsym(runtime->handle, "main");
const char* lookup_error = dlerror();
if (symbol == nullptr || lookup_error != nullptr) {
LOG_E(TAG, "dlsym(\"main\") failed: %s", lookup_error != nullptr ? lookup_error : "not found");
return -1;
}
auto* main_fn = reinterpret_cast<AppMainFn>(symbol);
return main_fn(argc, argv);
}
void api_unload(AppRuntime runtime_ptr) {
auto* runtime = static_cast<PosixAppRuntime*>(runtime_ptr);
dlclose(runtime->handle);
delete runtime;
}
AppLoaderApi loader_api = {
.load = api_load,
.run = api_run,
.unload = api_unload,
};
void* create_service(const ServiceManifest*) {
return &loader_api;
}
void destroy_service(const ServiceManifest*, void*) {
}
} // namespace
ServiceManifest loader_service_manifest = {
.id = APP_LOADER_PATH_SERVICE_ID,
.create_service = create_service,
.destroy_service = destroy_service,
.on_start = nullptr,
.on_stop = nullptr,
};
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: Apache-2.0
#include <app_posix/module.h>
#include <service/manager.h>
#include <tactility/error.h>
#include <tactility/module.h>
extern "C" {
extern ServiceManifest loader_service_manifest;
static error_t start() {
return service_manager_add(&loader_service_manifest, /*auto_start=*/true);
}
static error_t stop() {
return service_manager_remove(loader_service_manifest.id);
}
Module app_posix_module = {
.name = "app-posix",
.start = start,
.stop = stop,
.drivers = nullptr,
.symbols = nullptr,
.internal = nullptr,
};
}
@@ -0,0 +1,37 @@
project(AppPosixModuleTests)
enable_language(C CXX ASM)
# Fixture: a tiny shared object with a main() the test dlopen()s through app-posix-module's real
# loader service. Deliberately not linked against app-module, so its call into
# app_scheduler_current_app_id() stays undefined until dlopen() resolves it against
# AppPosixModuleTests's own copy, exactly like a real Tactility-embedded app would resolve
# against the running Tactility binary.
add_library(app_posix_module_test_fixture SHARED EXCLUDE_FROM_ALL ${CMAKE_CURRENT_LIST_DIR}/fixtures/fixture_app.cpp)
target_include_directories(app_posix_module_test_fixture PRIVATE ${CMAKE_SOURCE_DIR}/Modules/app-module/include)
set_target_properties(app_posix_module_test_fixture PROPERTIES POSITION_INDEPENDENT_CODE ON)
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
add_executable(AppPosixModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
add_dependencies(AppPosixModuleTests app_posix_module_test_fixture)
target_include_directories(AppPosixModuleTests PRIVATE ${DOCTESTINC})
target_compile_definitions(AppPosixModuleTests PRIVATE
FIXTURE_APP_PATH="$<TARGET_FILE:app_posix_module_test_fixture>"
)
add_test(NAME AppPosixModuleTests COMMAND AppPosixModuleTests)
target_link_libraries(AppPosixModuleTests PUBLIC
TactilityKernel
app-module
app-posix-module
service-module
platform-posix
freertos_kernel
${CMAKE_DL_LIBS}
)
# So the fixture's undefined app_scheduler_current_app_id() reference can resolve against this
# test binary's own copy at dlopen() time. See app-posix-module's own ENABLE_EXPORTS comment on
# the real Tactility executable for why this is needed.
set_target_properties(AppPosixModuleTests PROPERTIES ENABLE_EXPORTS ON)
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
#include <app/scheduler.h>
#include <cstdint>
// Deliberately not linked against app-module: app_scheduler_current_app_id() stays an undefined
// symbol in this .so, resolved at dlopen() time against the loading process's own copy. Proves
// app-posix-module's loader lets a loaded app call straight back into Tactility without linking
// its own copy of it.
extern "C" int32_t main(int, char*[]) {
return static_cast<int32_t>(app_scheduler_current_app_id());
}
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: Apache-2.0
#include "doctest.h"
#include <app/event.h>
#include <app/loader.h>
#include <app/manager.h>
#include <app/scheduler.h>
#include <service/manager.h>
#include <tactility/delay.h>
#include <atomic>
extern ServiceManifest loader_service_manifest; // app-posix-module's own
extern ServiceManifest app_internal_loader_service_manifest; // app-module's real memory loader
namespace {
void ensure_path_loader_registered() {
if (service_manager_find_instance(APP_LOADER_PATH_SERVICE_ID) == nullptr) {
service_manager_add(&loader_service_manifest, /*auto_start=*/true);
}
}
void ensure_memory_loader_registered() {
if (service_manager_find_instance(APP_LOADER_MEMORY_SERVICE_ID) == nullptr) {
service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true);
}
}
bool wait_for_state(AppInstanceId id, AppInstanceState target, uint32_t timeout_ms) {
uint32_t waited = 0;
while (waited < timeout_ms) {
if (app_manager_get_state(id) == target) {
return true;
}
delay_millis(10);
waited += 10;
}
return app_manager_get_state(id) == target;
}
std::atomic<int32_t> g_fixture_result { -1 };
std::atomic<bool> g_fixture_result_received { false };
// Starts the dlopen()ed fixture as its own modal child and stashes its returned result, so the
// test can inspect that result from the (in-process, directly readable) parent's own task.
int32_t parent_app_main(int, char*[]) {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppEventSubscription sub {};
app_event_subscribe(&sub, &event_group);
AppInstanceId self_id = app_scheduler_current_app_id();
AppManifest fixture_manifest {
"test.posix.fixture", "Fixture", APP_CATEGORY_USER,
{ APP_LOCATION_PATH, const_cast<char*>(FIXTURE_APP_PATH) }
};
app_manager_add(&fixture_manifest);
AppInstanceId fixture_id = 0;
app_manager_start_for_result("test.posix.fixture", self_id, 0, nullptr, &fixture_id);
while (true) {
if (task_event_group_wait_any(&event_group, nullptr, pdMS_TO_TICKS(5000)) != ERROR_NONE) {
break; // safety net so a bug here can't hang the test suite
}
AppEvent event {};
bool got_result = false;
while (app_event_poll(&sub, &event) == ERROR_NONE) {
if (event.type == APP_EVENT_RESULT && event.result.launch_id == fixture_id) {
g_fixture_result.store(event.result.result, std::memory_order_release);
got_result = true;
}
}
if (got_result) {
break;
}
}
g_fixture_result_received.store(true, std::memory_order_release);
app_manager_remove("test.posix.fixture");
app_event_unsubscribe(&sub);
task_event_group_destruct(&event_group);
return 0;
}
} // namespace
TEST_CASE("app-posix-module's loader-path service dlopen()s a .so and calls its main(), which resolves a real Tactility symbol against the host") {
ensure_path_loader_registered();
ensure_memory_loader_registered();
g_fixture_result.store(-1, std::memory_order_relaxed);
g_fixture_result_received.store(false, std::memory_order_relaxed);
AppManifest parent_manifest { "test.posix.parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(parent_app_main) } };
REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE);
AppInstanceId parent_id = 0;
REQUIRE_EQ(app_manager_start("test.posix.parent", &parent_id), ERROR_NONE);
REQUIRE(wait_for_state(parent_id, APP_INSTANCE_STATE_STOPPED, 3000));
CHECK(g_fixture_result_received.load(std::memory_order_acquire));
// A positive AppInstanceId proves the fixture's dlopen()ed main() actually resolved and
// called app_scheduler_current_app_id() against the host process, not just "ran and returned
// a hardcoded value" - 0 would mean it thought it wasn't running as an app instance at all.
CHECK_GT(g_fixture_result.load(std::memory_order_acquire), 0);
app_manager_remove("test.posix.parent");
}
@@ -0,0 +1,54 @@
#define DOCTEST_CONFIG_IMPLEMENT
#include "doctest.h"
#include <cassert>
#include "FreeRTOS.h"
#include "task.h"
typedef struct {
int argc;
char** argv;
int result;
} TestTaskData;
void test_task(void* parameter) {
auto* data = (TestTaskData*)parameter;
doctest::Context context;
context.applyCommandLine(data->argc, data->argv);
// overrides
context.setOption("no-breaks", true); // don't break in the debugger when assertions fail
data->result = context.run();
vTaskEndScheduler();
vTaskDelete(nullptr);
}
int main(int argc, char** argv) {
TestTaskData data = {
.argc = argc,
.argv = argv,
.result = 0
};
BaseType_t task_result = xTaskCreate(
test_task,
"test_task",
8192,
&data,
1,
nullptr
);
if (task_result != pdPASS) {
return 1;
}
vTaskStartScheduler();
return data.result;
}
@@ -28,6 +28,11 @@ static uint32_t task_max_sleep_ms = 10;
static TaskHandle_t lvgl_task_handle = NULL;
static bool lvgl_task_interrupt_requested = false;
// ESP32 gets LVGL's tick driven for free by esp_lvgl_port; POSIX has no such
// helper, so lv_tick_get() would otherwise stay at 0 forever and every timer
// (indev polling included) would look permanently "not due yet".
static uint32_t lvgl_last_tick_millis = 0;
#define LVGL_STOP_POLL_INTERVAL 10
#define LVGL_STOP_TIMEOUT 5000
@@ -80,7 +85,13 @@ static void lvgl_task(void* arg) {
// on_start must be called from the task, otherwise the display doesn't work
if (lvgl_module_config.on_start) lvgl_module_config.on_start();
lvgl_last_tick_millis = (uint32_t)get_millis();
while (!lvgl_task_is_interrupt_requested()) {
uint32_t now_millis = (uint32_t)get_millis();
lv_tick_inc(now_millis - lvgl_last_tick_millis);
lvgl_last_tick_millis = now_millis;
if (lvgl_try_lock(10)) {
task_delay_ms = lv_timer_handler();
lvgl_unlock();
@@ -8,6 +8,7 @@ extern "C" {
static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(window_manager_create),
DEFINE_MODULE_SYMBOL(window_manager_create_ext),
DEFINE_MODULE_SYMBOL(window_manager_remove),
DEFINE_MODULE_SYMBOL(window_manager_get_state),
DEFINE_MODULE_SYMBOL(window_manager_await_state_change),
@@ -0,0 +1,25 @@
#include <pthread.h>
#include <stddef.h>
/**
* Linked in with -Wl,--wrap=pthread_attr_setstack (see Tactility/CMakeLists.txt).
*
* FreeRTOS's POSIX port (Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/port.c)
* hands every task a stack carved out of its own heap (pvPortMalloc) via this call.
* pthread_attr_setstack requires page alignment, which pvPortMalloc doesn't guarantee;
* when it happens to succeed anyway (allocator alignment can vary run to run), the
* task's real pthread stack ends up living inside that small FreeRTOS heap region -
* fine for typical embedded task code, but a desktop GL driver doing on-the-fly shader
* compilation on that thread (e.g. Mesa on first frame present) can overflow it and
* silently corrupt adjacent heap_4 objects.
*
* Wrapping the call out entirely (rather than patching the vendored port.c) leaves every
* task's pthread_attr_t at its pthread_attr_init() default, so pthread_create() always
* gives it a real, properly allocated default-size stack instead.
*/
int __wrap_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t stacksize) {
(void)attr;
(void)stackaddr;
(void)stacksize;
return 0;
}
+29
View File
@@ -88,6 +88,7 @@ else ()
list(APPEND REQUIRES_LIST
platform-posix
app-posix-module
freertos_kernel
cJSON
lvgl
@@ -181,6 +182,34 @@ else ()
target_include_directories(Tactility PUBLIC Include/)
target_include_directories(Tactility PRIVATE Private/)
target_link_libraries(Tactility PRIVATE ${TACTILITY_REQUIRES_LIST})
# Exports Tactility's own symbols (-rdynamic) so a dlopen()ed app-posix-module app can resolve
# calls back into it - the OS-native equivalent of app-esp32-module's custom symbol resolver.
set_target_properties(Tactility PROPERTIES ENABLE_EXPORTS ON)
# Routes every pthread_attr_setstack() call (FreeRTOS's POSIX port hands each task a stack
# carved out of its own heap through this) to __wrap_pthread_attr_setstack() in
# Platforms/platform-posix/source/pthread_stack_wrap.c, which no-ops it - see that file for why.
# --wrap is a GNU ld option; Apple's linker doesn't support it.
if (NOT APPLE)
target_link_options(Tactility PRIVATE "-Wl,--wrap=pthread_attr_setstack")
# Routes every read()/write()/close() call to Tactility/Source/AppStdioWrap.cpp's
# __wrap_read/write/close(), which forward into app_io_read/write/close() - the fd-table
# dispatch that lets an app's own stdio (e.g. a fileselection dialog's printf'd result
# path) reach an AppStream a parent bound via app_manager_start_with_streams(). Mirrors
# what ESP-IDF's build already does for the ESP32 target (see top-level CMakeLists.txt).
target_link_options(Tactility PRIVATE "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close")
# glibc's printf/fprintf/etc don't call the public write() symbol internally (they're
# already compiled into libc.so, out of --wrap's reach), so the read/write/close wrap
# above can't see them. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the
# wrappable syscall stubs - so this block is POSIX-only. Wrapping these symbols instead
# redirects OUR OWN calls to them (the only ones --wrap can rewrite) through
# AppStdioWrap.cpp's __wrap_* functions, which check the target stream (stdout/stdin) and
# fall back to the real libc function for any other FILE*.
target_link_options(Tactility PRIVATE
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
endif ()
#
@@ -1,29 +1,36 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <app/stream.h>
#include <tactility/concurrent/task_event_group.h>
namespace tt::app::fileselection {
/**
* Show a file selection dialog that allows the user to select an existing file, as a modal
* child of @a callerAppInstanceId (see app_manager_start_for_result()). Result (0 = Ok,
* 1 = Cancelled) is delivered back via APP_EVENT_RESULT once this app's thread exits - call
* getLastPath() right after receiving it, on result == 0. The caller must call
* app_manager_stop() on the returned instance id once that event arrives, to fully reap this
* instance.
* child of @a callerAppInstanceId (see app_manager_start_for_result_with_streams()). Result
* (0 = Ok, 1 = Cancelled) is delivered back via APP_EVENT_RESULT once this app's thread exits.
* On result == 0, read the picked path with app_stream_read(&stream, ...) then
* app_stream_unsubscribe(&stream); on any other result, just app_stream_unsubscribe(&stream).
* The caller must call app_manager_stop() on the returned instance id once that event arrives,
* to fully reap this instance.
* @param[in,out] stream caller-owned storage bound to the started app's stdout; must stay valid
* until app_stream_unsubscribe() is called on it (see above).
* @param[in] buffer caller-owned backing storage for @a stream's ring buffer; must stay valid
* for the same duration as @a stream.
* @param[in] bufferCapacity size of @a buffer in bytes.
* @param[in] eventGroup the caller's own event group, reused for the stream's readiness bits
* (see app_stream_subscribe()) - the caller isn't required to actually wait on them itself.
* @return the new app instance id
*/
uint32_t startForExistingFile(uint32_t callerAppInstanceId);
uint32_t startForExistingFile(uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup);
/**
* Same as startForExistingFile(), but also allows picking a path that doesn't exist yet (for
* "save as"-style flows).
*/
uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId);
/** @return the path picked by the last FileSelection dialog that closed with result == Ok. Only
* one dialog is expected to be open at a time. */
std::string getLastPath();
uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup);
} // namespace
@@ -0,0 +1,11 @@
#pragma once
#ifndef ESP_PLATFORM
namespace tt {
bool initPartitionsPosix();
} // namespace
#endif // ESP_PLATFORM
+178 -2
View File
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
#ifdef ESP_PLATFORM
// Paired with -Wl,--wrap=read/write/close - see Tactility/CMakeLists.txt (POSIX) and the
// top-level CMakeLists.txt (ESP32) for where that's applied. On a platform where it isn't
// (currently: macOS, whose linker doesn't support --wrap), these are simply never called - real
// read()/write()/close() calls go straight through unredirected.
#include <app/io.h>
#include <sys/types.h>
@@ -21,4 +24,177 @@ int __wrap_close(int fd) {
}
#endif // ESP_PLATFORM
// region glibc stdio wraps
//
// glibc's printf/fprintf/etc are compiled into libc.so and call an internal, non-exported write()
// alias - --wrap=write (above) can't reach that call, only calls WE make to the public symbol.
// These wraps instead redirect calls WE make to printf/fprintf/etc, the same trick as read/write/
// close above. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the wrappable syscall
// stubs - so Tactility/CMakeLists.txt only applies the matching -Wl,--wrap= flags on POSIX.
//
// Scoped to the printf/getc families only: fread/fwrite take an arbitrary FILE* and are already
// used sitewide for real file I/O (e.g. File.cpp's readBinaryInternal), so wrapping them would
// route every such call through this file's stdin/stdout check - a correctness risk for unrelated
// code that isn't worth taking here. putc/getc are excluded too since glibc defines them as
// macros, not real calls, so wrapping those symbols wouldn't reliably intercept them.
#if !defined(ESP_PLATFORM) && !defined(__APPLE__)
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <memory>
#include <unistd.h>
extern "C" {
int __real_vfprintf(FILE* stream, const char* format, va_list args);
int __real_fputs(const char* s, FILE* stream);
int __real_fputc(int c, FILE* stream);
int __real_fgetc(FILE* stream);
char* __real_fgets(char* buffer, int size, FILE* stream);
}
namespace {
void writeAllToStdout(const void* data, size_t size) {
const auto* bytes = static_cast<const char*>(data);
size_t remaining = size;
while (remaining > 0) {
ssize_t written = app_io_write(STDOUT_FILENO, bytes, remaining);
if (written <= 0) {
break;
}
bytes += written;
remaining -= static_cast<size_t>(written);
}
}
// Formats into stdout via app_io_write() rather than through a FILE*'s own buffering, since that
// buffering is exactly what glibc's internal write() call sidesteps --wrap for in the first place.
int formatToStdout(const char* format, va_list args) {
char stackBuffer[256];
va_list argsForStack;
va_copy(argsForStack, args);
int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack);
va_end(argsForStack);
if (needed < 0) {
return needed;
}
if (static_cast<size_t>(needed) < sizeof(stackBuffer)) {
writeAllToStdout(stackBuffer, static_cast<size_t>(needed));
return needed;
}
auto heapBuffer = std::make_unique<char[]>(static_cast<size_t>(needed) + 1);
va_list argsForHeap;
va_copy(argsForHeap, args);
vsnprintf(heapBuffer.get(), static_cast<size_t>(needed) + 1, format, argsForHeap);
va_end(argsForHeap);
writeAllToStdout(heapBuffer.get(), static_cast<size_t>(needed));
return needed;
}
int readOneFromStdin(char& out) {
return static_cast<int>(app_io_read(STDIN_FILENO, &out, 1));
}
} // namespace
extern "C" {
int __wrap_vprintf(const char* format, va_list args) {
return formatToStdout(format, args);
}
int __wrap_printf(const char* format, ...) {
va_list args;
va_start(args, format);
int result = formatToStdout(format, args);
va_end(args);
return result;
}
int __wrap_vfprintf(FILE* stream, const char* format, va_list args) {
if (stream == stdout) {
return formatToStdout(format, args);
}
return __real_vfprintf(stream, format, args);
}
int __wrap_fprintf(FILE* stream, const char* format, ...) {
va_list args;
va_start(args, format);
int result = (stream == stdout) ? formatToStdout(format, args) : __real_vfprintf(stream, format, args);
va_end(args);
return result;
}
int __wrap_puts(const char* s) {
writeAllToStdout(s, strlen(s));
writeAllToStdout("\n", 1);
return 0;
}
int __wrap_fputs(const char* s, FILE* stream) {
if (stream == stdout) {
writeAllToStdout(s, strlen(s));
return 0;
}
return __real_fputs(s, stream);
}
int __wrap_putchar(int c) {
auto ch = static_cast<char>(c);
writeAllToStdout(&ch, 1);
return c;
}
int __wrap_fputc(int c, FILE* stream) {
if (stream == stdout) {
return __wrap_putchar(c);
}
return __real_fputc(c, stream);
}
int __wrap_getchar() {
char c;
return readOneFromStdin(c) == 1 ? static_cast<unsigned char>(c) : EOF;
}
int __wrap_fgetc(FILE* stream) {
if (stream == stdin) {
return __wrap_getchar();
}
return __real_fgetc(stream);
}
char* __wrap_fgets(char* buffer, int size, FILE* stream) {
if (stream != stdin) {
return __real_fgets(buffer, size, stream);
}
if (size <= 0) {
return nullptr;
}
int i = 0;
for (; i < size - 1; ++i) {
char c;
if (readOneFromStdin(c) != 1) {
break;
}
buffer[i] = c;
if (c == '\n') {
++i;
break;
}
}
if (i == 0) {
return nullptr;
}
buffer[i] = '\0';
return buffer;
}
}
#endif // !ESP_PLATFORM && !__APPLE__
// endregion
-4
View File
@@ -52,11 +52,7 @@ std::string getUserDataRootPath() {
}
std::string getDataPath() {
#ifdef ESP_PLATFORM
return getUserDataRootPath() + "/tactility";
#else
return "data";
#endif
}
std::string getTempPath() {
+5 -1
View File
@@ -18,7 +18,11 @@ std::vector<dirent> getFileSystemDirents() {
if (!file_system_is_mounted(fs)) return true;
char path[128];
if (file_system_get_path(fs, path, sizeof(path)) != ERROR_NONE) return true;
auto mount_name = std::string(path).substr(1);
// ESP32 mount paths are short names ("/system"); POSIX file systems can return a full
// absolute host path instead, so take the last path component either way.
auto path_str = std::string(path);
auto slash_pos = path_str.find_last_of('/');
auto mount_name = slash_pos == std::string::npos ? path_str : path_str.substr(slash_pos + 1);
if (!config::SHOW_SYSTEM_PARTITION && mount_name.starts_with(SYSTEM_PARTITION_NAME)) return true;
auto dir_entry = dirent {
.d_ino = 2,
+103
View File
@@ -0,0 +1,103 @@
#ifndef ESP_PLATFORM
#include <Tactility/PartitionsPosix.h>
#include <Tactility/MountPoints.h>
#include <tactility/error.h>
#include <tactility/filesystem/file_system.h>
#include <tactility/log.h>
#include <cerrno>
#include <climits>
#include <cstdlib>
#include <cstring>
namespace tt {
constexpr auto* TAG = "Partitions";
// region file_system stub
// A plain host directory has no real mount/unmount step to perform - "mounted" here just tracks
// whether file_system_remove()'s precondition (must be unmounted first) has been satisfied.
struct DirectoryFsData {
char path[PATH_MAX];
bool mounted;
};
static DirectoryFsData system_fs_data;
static DirectoryFsData data_fs_data;
static FileSystem* system_fs = nullptr;
static FileSystem* data_fs = nullptr;
static error_t mount(void* data) {
static_cast<DirectoryFsData*>(data)->mounted = true;
return ERROR_NONE;
}
static error_t unmount(void* data) {
static_cast<DirectoryFsData*>(data)->mounted = false;
return ERROR_NONE;
}
static bool is_mounted(void* data) {
return static_cast<DirectoryFsData*>(data)->mounted;
}
static error_t get_path(void* data, char* out_path, size_t out_path_size) {
auto* fs_data = static_cast<DirectoryFsData*>(data);
if (strlen(fs_data->path) >= out_path_size) {
return ERROR_BUFFER_OVERFLOW;
}
strcpy(out_path, fs_data->path);
return ERROR_NONE;
}
static const FileSystemApi directory_fs_api = {
.mount = mount,
.unmount = unmount,
.is_mounted = is_mounted,
.get_path = get_path,
};
// endregion file_system stub
// relative_path is resolved against the process' current working directory (the simulator is
// expected to run with Data/ as its working directory, so file::SYSTEM_PARTITION_NAME/
// DATA_PARTITION_NAME here match file::MOUNT_POINT_SYSTEM/MOUNT_POINT_DATA).
static FileSystem* registerDirectoryFs(const char* relativePath, DirectoryFsData* outData) {
if (realpath(relativePath, outData->path) == nullptr) {
LOG_E(TAG, "Failed to resolve '%s' to an absolute path: %s", relativePath, strerror(errno));
return nullptr;
}
outData->mounted = true;
return file_system_add(&directory_fs_api, outData);
}
static void unregisterDirectoryFs(FileSystem* fs) {
if (fs == nullptr) {
return;
}
file_system_unmount(fs);
file_system_remove(fs);
}
bool initPartitionsPosix() {
system_fs = registerDirectoryFs(file::SYSTEM_PARTITION_NAME, &system_fs_data);
if (system_fs == nullptr) {
return false;
}
data_fs = registerDirectoryFs(file::DATA_PARTITION_NAME, &data_fs_data);
if (data_fs == nullptr) {
unregisterDirectoryFs(system_fs);
system_fs = nullptr;
return false;
}
return true;
}
} // namespace
#endif // ESP_PLATFORM
+24 -3
View File
@@ -4,13 +4,27 @@
#include <app_esp32/module.h>
#endif
#if __has_include(<unistd.h>) && not defined(ESP_PLATFORM)
#define TT_IS_POSIX 1
#else
#define TT_IS_POSIX 0
#endif
#if TT_IS_POSIX or defined(ESP_PLATFORM) // esp-idf supports certain posix symbols
#include <posix_symbols/module.h>
#endif
#if TT_IS_POSIX
#include <app_posix/module.h>
#include <Tactility/PartitionsPosix.h>
#endif
#include <format>
#include <memory>
#include <string>
#include <vector>
#include <app/event.h>
#include <app/install.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/module.h>
@@ -19,7 +33,6 @@
#include <Tactility/CpuAffinity.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/LogMessages.h>
#include <Tactility/MountPoints.h>
#include <Tactility/TactilityConfig.h>
#include <Tactility/bluetooth/Bluetooth.h>
@@ -35,7 +48,10 @@
#include <Tactility/service/audio/Audio.h>
#include <Tactility/settings/DisplaySettings.h>
#include <Tactility/settings/TimePrivate.h>
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
#include <Tactility/settings/TouchCalibrationSettings.h>
#endif
#include <c_symbols/module.h>
#include <cpp_symbols/module.h>
@@ -47,7 +63,6 @@
#include <gps_meshtastic/module.h>
#include <http/module.h>
#include <mbedtls/module.h>
#include <posix_symbols/module.h>
#include <pthread/module.h>
#include <crypt/module.h>
@@ -501,7 +516,9 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) {
// C/C++/Posix symbols
check(module_ensure_started(&c_symbols_module) == ERROR_NONE);
#if TT_IS_POSIX or defined(ESP_PLATFORM) // esp-idf supports certain posix symbols
check(module_ensure_started(&posix_symbols_module) == ERROR_NONE);
#endif
check(module_ensure_started(&cpp_symbols_module) == ERROR_NONE);
// OS level symbols
check(module_ensure_started(&freertos_module) == ERROR_NONE);
@@ -516,10 +533,14 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) {
check(module_ensure_started(&gps_meshtastic_module) == ERROR_NONE);
#ifdef ESP_PLATFORM
check(module_ensure_started(&app_esp32_module) == ERROR_NONE);
#elif TT_IS_POSIX
check(module_ensure_started(&app_posix_module) == ERROR_NONE);
#endif
#ifdef ESP_PLATFORM
initEsp();
#elif TT_IS_POSIX
check(initPartitionsPosix(), "Failed to init partitions");
#endif
settings::initTimeZone();
+5 -25
View File
@@ -24,6 +24,7 @@
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <format>
#include <unistd.h>
namespace tt::app::files {
@@ -174,43 +175,22 @@ static bool copyRecursive(const std::string& src, const std::string& dst) {
void View::viewFile(const std::string& path, const std::string& filename) {
std::string file_path = path + "/" + filename;
// For PC we need to make the path relative to the current work directory,
// because that's how LVGL maps its 'drive letter' to the file system.
std::string processed_filepath;
if (kernel::getPlatform() == kernel::PlatformSimulator) {
char cwd[PATH_MAX];
if (getcwd(cwd, sizeof(cwd)) == nullptr) {
LOG_E(TAG, "Failed to get current working directory");
return;
}
if (!file_path.starts_with(cwd)) {
LOG_E(TAG, "Can only work with files in working directory %s", cwd);
return;
}
processed_filepath = file_path.substr(strlen(cwd));
} else {
processed_filepath = file_path;
}
LOG_I(TAG, "Clicked %s", file_path.c_str());
if (isSupportedAppFile(filename)) {
#ifdef ESP_PLATFORM
// install(filename);
auto message = std::format("Do you want to install {}?", filename);
installAppPath = processed_filepath;
installAppPath = file_path;
auto choices = std::vector<std::string> {"Yes", "No"};
installDialogId = alertdialog::start(appInstanceId, "Install?", message, choices);
#endif
} else if (isSupportedImageFile(filename)) {
imageviewer::start(processed_filepath);
imageviewer::start(file_path);
} else if (isSupportedTextFile(filename)) {
if (kernel::getPlatform() == kernel::PlatformEsp) {
notes::start(processed_filepath);
notes::start(file_path);
} else {
// Remove forward slash, because we need a relative path
notes::start(processed_filepath.substr(1));
notes::start(file_path.substr(1));
}
} else {
LOG_W(TAG, "Opening files of this type is not supported");
@@ -1,18 +1,23 @@
#include "Tactility/app/fileselection/FileSelection.h"
#include "Tactility/app/fileselection/FileSelectionPrivate.h"
#include "Tactility/app/fileselection/View.h"
#include "Tactility/app/fileselection/State.h"
#include <app/event.h>
#include <app/io.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <app/stream.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <cstdio>
#include <memory>
#include <string>
#include <unistd.h>
namespace tt::app::fileselection {
@@ -27,20 +32,11 @@ struct Context {
Mode mode;
std::shared_ptr<State> state;
std::unique_ptr<View> view;
// 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
// 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 file
std::string resultPath;
int32_t resultCode = 1; // 1 means Cancelled
};
// The last picked path. Static rather than per-instance: simple, and in practice only one
// FileSelection dialog is ever open at a time. Written on the LVGL thread (View's select-button
// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastPath() after
// receiving that event - safe without a lock for the same reason Context::result is (see
// AlertDialog.cpp).
std::string lastPath;
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->view->init(parent, ctx->mode);
@@ -52,16 +48,11 @@ int32_t appMain(int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.mode = (argc > 0 && std::string(argv[0]) == "existing_or_new") ? Mode::ExistingOrNew : Mode::Existing;
ctx.mode = (argc > 0 && std::string(argv[0]) == "--existing-or-new") ? Mode::ExistingOrNew : Mode::Existing;
ctx.state = std::make_shared<State>();
ctx.view = std::make_unique<View>(appInstanceId, ctx.state, [&ctx, appInstanceId](const std::string& path) {
// Runs on the LVGL task (View::onSelectButtonPressed) - must NOT call app_manager_stop()
// here: that bound-waits (thread_join) for this app's own thread to finish, which needs
// the LVGL lock (window_manager_remove()) - but this callback runs ON the LVGL task,
// which would deadlock against itself. The caller reaps this instance via
// app_manager_stop() after it receives the APP_EVENT_RESULT instead.
lastPath = path;
ctx.result = 0;
ctx.resultPath = path;
ctx.resultCode = 0;
app_event_emit_close(appInstanceId);
});
@@ -91,27 +82,42 @@ int32_t appMain(int argc, char* argv[]) {
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);
return ctx.result;
if (ctx.resultCode == 0) {
// The parent captures this via an AppStream bound to our stdout (see startWithMode()) -
// see AppStdioWrap.cpp for how printf() itself gets routed there on POSIX.
LOG_I(TAG, "Result: %s", ctx.resultPath.c_str());
printf("%s", ctx.resultPath.c_str());
}
return ctx.resultCode;
}
} // namespace
std::string getLastPath() {
return lastPath;
}
namespace {
uint32_t startForExistingFile(uint32_t callerAppInstanceId) {
const char* argv[] = { "existing" };
uint32_t startWithMode(const char* modeArg, uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup) {
const char* argv[] = { modeArg };
AppStreamBinding binding = {
.producer_fd = STDOUT_FILENO,
.stream = &stream,
.buffer = buffer,
.buffer_capacity = bufferCapacity,
.event_group = eventGroup,
};
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId);
app_manager_start_for_result_with_streams(manifest.id, callerAppInstanceId, 1, argv, &binding, 1, &instanceId);
return instanceId;
}
uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId) {
const char* argv[] = { "existing_or_new" };
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId);
return instanceId;
} // namespace
uint32_t startForExistingFile(uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup) {
return startWithMode("--existing", callerAppInstanceId, stream, buffer, bufferCapacity, eventGroup);
}
uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup) {
return startWithMode("--existing-or-new", callerAppInstanceId, stream, buffer, bufferCapacity, eventGroup);
}
extern const ::AppManifest manifest = {
+2 -4
View File
@@ -33,7 +33,7 @@ std::string State::getSelectedChildPath() const {
}
bool State::setEntriesForPath(const std::string& path) {
LOG_I(TAG, "Changing path: %s -> %s", current_path.c_str(), path.c_str());
LOG_D(TAG, "Changing path: %s -> %s", current_path.c_str(), path.c_str());
auto lock = mutex.asScopedLock();
if (!lock.lock(100)) {
@@ -47,7 +47,6 @@ bool State::setEntriesForPath(const std::string& path) {
*/
bool show_custom_root = (kernel::getPlatform() == kernel::PlatformEsp) && (path == "/");
if (show_custom_root) {
LOG_I(TAG, "Setting custom root");
dir_entries = file::getFileSystemDirents();
current_path = path;
selected_child_entry = "";
@@ -56,7 +55,6 @@ bool State::setEntriesForPath(const std::string& path) {
dir_entries.clear();
int count = file::scandir(path, dir_entries, &file::direntFilterDotEntries, file::direntSortAlphaAndType);
if (count >= 0) {
LOG_I(TAG, "%s has %d entries", path.c_str(), count);
current_path = path;
selected_child_entry = "";
return true;
@@ -69,7 +67,7 @@ bool State::setEntriesForPath(const std::string& path) {
bool State::setEntriesForChildPath(const std::string& childPath) {
auto path = file::getChildPath(current_path, childPath);
LOG_I(TAG, "Navigating from %s to %s", current_path.c_str(), path.c_str());
LOG_D(TAG, "Navigating from %s to %s", current_path.c_str(), path.c_str());
return setEntriesForPath(path);
}
+7 -4
View File
@@ -60,12 +60,15 @@ void View::onTapFile(const std::string& path, const std::string& filename) {
LOG_E(TAG, "Can only work with files in working directory %s", cwd);
return;
}
processed_filepath = file_path.substr(strlen(cwd));
// MountPoints.h's MOUNT_POINT_DATA/MOUNT_POINT_SYSTEM have no leading slash on POSIX
// (fopen() resolves relative to cwd there), unlike ESP32's real VFS mount points - so
// strip the separator too, not just cwd itself.
processed_filepath = file_path.substr(strlen(cwd) + 1);
} else {
processed_filepath = file_path;
}
LOG_I(TAG, "Clicked %s", processed_filepath.c_str());
LOG_D(TAG, "Clicked %s", processed_filepath.c_str());
lv_textarea_set_text(path_textarea, processed_filepath.c_str());
}
@@ -73,7 +76,7 @@ void View::onTapFile(const std::string& path, const std::string& filename) {
void View::onDirEntryPressed(uint32_t index) {
dirent dir_entry;
if (state->getDirent(index, dir_entry)) {
LOG_I(TAG, "Pressed %s %d", dir_entry.d_name, (int)dir_entry.d_type);
LOG_D(TAG, "Pressed %s %d", dir_entry.d_name, (int)dir_entry.d_type);
state->setSelectedChildEntry(dir_entry.d_name);
using namespace tt::file;
switch (dir_entry.d_type) {
@@ -146,7 +149,7 @@ void View::createDirEntryWidget(lv_obj_t* list, dirent& dir_entry) {
void View::onNavigateUpPressed() {
if (state->getCurrentPath() != "/") {
LOG_I(TAG, "Navigating upwards");
LOG_D(TAG, "Navigating upwards");
std::string new_absolute_path;
if (string::getPathParent(state->getCurrentPath(), new_absolute_path)) {
state->setEntriesForPath(new_absolute_path);
+28 -6
View File
@@ -1,4 +1,7 @@
#include <Tactility/app/notes/Notes.h>
#include "Tactility/app/alertdialog/AlertDialog.h"
#include <Tactility/app/fileselection/FileSelection.h>
#include <Tactility/file/File.h>
@@ -6,6 +9,7 @@
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <app/stream.h>
#include <lvgl_window_manager/window_manager.h>
@@ -25,6 +29,7 @@ namespace {
struct Context {
uint32_t appInstanceId;
TaskEventGroup* eventGroup = nullptr;
lv_obj_t* uiCurrentFileName = nullptr;
lv_obj_t* uiDropDownMenu = nullptr;
@@ -35,6 +40,10 @@ struct Context {
uint32_t loadFileLaunchId = 0;
uint32_t saveFileLaunchId = 0;
AppStream loadResultStream {};
uint8_t loadResultBuffer[256] {};
AppStream saveResultStream {};
uint8_t saveResultBuffer[256] {};
};
@@ -90,11 +99,11 @@ void appNotesEventCb(lv_event_t* e) {
lvgl_lock();
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
lvgl_unlock();
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId);
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId, ctx->saveResultStream, ctx->saveResultBuffer, sizeof(ctx->saveResultBuffer), ctx->eventGroup);
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
break;
case 3: // Load
ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId);
ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId, ctx->loadResultStream, ctx->loadResultBuffer, sizeof(ctx->loadResultBuffer), ctx->eventGroup);
LOG_I(TAG, "launched with id %u", ctx->loadFileLaunchId);
break;
}
@@ -102,7 +111,7 @@ void appNotesEventCb(lv_event_t* e) {
auto* cont = lv_event_get_current_target_obj(e);
if (obj == cont) return;
if (lv_obj_get_child(cont, 1)) {
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId);
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId, ctx->saveResultStream, ctx->saveResultBuffer, sizeof(ctx->saveResultBuffer), ctx->eventGroup);
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
} else { //Reset
resetFileContent(ctx);
@@ -189,6 +198,7 @@ int32_t appMain(int argc, char* argv[]) {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
ctx.eventGroup = &event_group;
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
@@ -206,23 +216,35 @@ int32_t appMain(int argc, char* argv[]) {
shouldClose = true;
break;
case APP_EVENT_RESULT:
LOG_I(TAG, "Result for launch id %u", event.result.launch_id);
LOG_I(TAG, "Result for launch id %u = %u", event.result.launch_id, event.result.result);
if (event.result.launch_id == ctx.loadFileLaunchId) {
ctx.loadFileLaunchId = 0;
if (event.result.result == 0 /* Ok */) {
auto path = fileselection::getLastPath();
char destination[sizeof(ctx.loadResultBuffer)];
size_t length = app_stream_read(&ctx.loadResultStream, destination, sizeof(destination));
app_stream_unsubscribe(&ctx.loadResultStream);
auto path = std::string(destination, length);
LOG_I(TAG, "Path: '%s'", path.c_str());
if (!path.empty()) {
openFile(&ctx, path);
}
} else {
app_stream_unsubscribe(&ctx.loadResultStream);
}
} else if (event.result.launch_id == ctx.saveFileLaunchId) {
ctx.saveFileLaunchId = 0;
if (event.result.result == 0 /* Ok */) {
auto path = fileselection::getLastPath();
char destination[sizeof(ctx.saveResultBuffer)];
size_t length = app_stream_read(&ctx.saveResultStream, destination, sizeof(destination));
app_stream_unsubscribe(&ctx.saveResultStream);
auto path = std::string(destination, length);
// Must re-open file, because the UI was cleared after opening the dialog.
LOG_I(TAG, "Path: '%s'", path.c_str());
if (!path.empty() && saveFile(&ctx, path)) {
openFile(&ctx, path);
}
} else {
app_stream_unsubscribe(&ctx.saveResultStream);
}
}
app_manager_stop(event.result.launch_id);
-2
View File
@@ -66,7 +66,6 @@ int scandir(
ScandirFilter filterMethod,
ScandirSort sortMethod
) {
LOG_I(TAG, "scandir start");
DIR* dir = opendir(path.c_str());
if (dir == nullptr) {
LOG_E(TAG, "Failed to open dir %s", path.c_str());
@@ -86,7 +85,6 @@ int scandir(
std::ranges::sort(outList, sortMethod);
}
LOG_I(TAG, "scandir finish");
return outList.size();
}
+14
View File
@@ -19,11 +19,25 @@ target_include_directories(TactilityTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../P
add_test(NAME TactilityTests COMMAND TactilityTests)
# Matches Tactility/CMakeLists.txt's own set of --wrap flags: this binary also compiles
# AppStdioWrap.cpp and links app-module (whose io.cpp calls __real_read/write/close() on
# non-Apple POSIX - see TT_APP_IO_WRAPS_STDIO in Modules/app-module/CMakeLists.txt), so it
# needs the same wraps applied or those go unresolved.
if (NOT APPLE)
target_link_options(TactilityTests PRIVATE
"-Wl,--wrap=pthread_attr_setstack" "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
target_link_libraries(TactilityTests PRIVATE
TactilityKernel
TactilityKernelCpp
TactilityFreeRtos
platform-posix
app-posix-module
lvgl-module
lvgl-window-manager-module
app-module
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// Fixed capacity of one queued log line (256 bytes * 24-deep queue = 6KB, negligible against the
// hundreds of KB of free heap typically available - generous enough to hold a full log line with
// color/timestamp/tag prefix plus a realistic message, e.g. a full SD-card path in an error log).
#define LOG_QUEUE_MESSAGE_MAX_LENGTH 256U
/**
* Starts the shared log queue and its dedicated drain (writer) task. Idempotent - safe to call
* more than once (only the first call has any effect). Must be called as early as possible in
* boot - see kernel_init.cpp, which calls this as its very first statement.
*/
void log_queue_init(void);
/**
* Enqueues an already-fully-formatted, ready-to-write log line (NOT a printf-style format
* string) so the dedicated drain task can write it via a path that never goes through
* app-module's fd-table redirection (see Modules/app-module/source/io.cpp's app_io_write()) -
* this is the ONLY function log-producing code should call to reach the console; it must never
* itself call write()/printf()/etc, since those would be intercepted whenever called from an app
* instance's own task, which is exactly the bug this exists to structurally prevent.
*
* Non-blocking: if the queue is full, the message is dropped and a counter is incremented; the
* drain task prepends a "N messages dropped" notice before its next write. If called before
* log_queue_init() has run, performs a direct synchronous write instead of enqueueing - safe,
* since no app instance (and so no fd-table redirection) can exist yet that early in boot.
* Truncates messages longer than LOG_QUEUE_MESSAGE_MAX_LENGTH - 1.
*/
void log_queue_write(const char* data, size_t length);
#ifdef __cplusplus
}
#endif
+3
View File
@@ -2,6 +2,7 @@
#include <tactility/device.h>
#include <tactility/log.h>
#include <tactility/log_queue.h>
#ifdef __cplusplus
extern "C" {
@@ -46,6 +47,8 @@ Module kernel_module = {
};
error_t kernel_init(Module* const dts_modules[], const DtsDevice dts_devices[]) {
log_queue_init();
LOG_I(TAG, "init");
if (module_construct_add_start(&kernel_module) != ERROR_NONE) {
+41 -9
View File
@@ -3,6 +3,7 @@
#ifndef ESP_PLATFORM
#include <tactility/log.h>
#include <tactility/log_queue.h>
#include <mutex>
#include <inttypes.h>
@@ -11,7 +12,11 @@
#include <stdarg.h>
#include <sys/time.h>
static const char* get_log_color(LogLevel level) {
namespace {
constexpr auto MINIMUM_LOG_LEVEL = LOG_LEVEL_DEBUG;
const char* get_log_color(LogLevel level) {
using enum LogLevel;
switch (level) {
case LOG_LEVEL_ERROR:
@@ -29,7 +34,7 @@ static const char* get_log_color(LogLevel level) {
}
}
static inline char get_log_prefix(LogLevel level) {
inline char get_log_prefix(LogLevel level) {
using enum LogLevel;
switch (level) {
case LOG_LEVEL_ERROR:
@@ -47,7 +52,7 @@ static inline char get_log_prefix(LogLevel level) {
}
}
static uint64_t get_log_timestamp() {
uint64_t get_log_timestamp() {
static uint64_t base = 0U;
static std::once_flag init_flag;
std::call_once(init_flag, []() {
@@ -61,15 +66,42 @@ static uint64_t get_log_timestamp() {
return now - base;
}
}
extern "C" {
void log_generic(enum LogLevel level, const char* tag, const char* format, ...) {
va_list args;
va_start(args, format);
printf("%s %c (%" PRIu64 ") %s ", get_log_color(level), get_log_prefix(level), get_log_timestamp(), tag);
vprintf(format, args);
printf("\033[0m\n");
va_end(args);
if (MINIMUM_LOG_LEVEL >= level) {
char buffer[LOG_QUEUE_MESSAGE_MAX_LENGTH];
size_t offset = 0;
int prefix_len = snprintf(buffer, sizeof(buffer), "%s %c (%" PRIu64 ") %s ",
get_log_color(level), get_log_prefix(level), get_log_timestamp(), tag);
if (prefix_len > 0) {
offset = static_cast<size_t>(prefix_len) < sizeof(buffer) ? static_cast<size_t>(prefix_len) : sizeof(buffer) - 1;
}
if (offset < sizeof(buffer)) {
va_list args;
va_start(args, format);
int written = vsnprintf(buffer + offset, sizeof(buffer) - offset, format, args);
va_end(args);
if (written > 0) {
size_t remaining = sizeof(buffer) - offset;
offset += static_cast<size_t>(written) < remaining ? static_cast<size_t>(written) : remaining - 1;
}
}
if (offset < sizeof(buffer)) {
int tail_len = snprintf(buffer + offset, sizeof(buffer) - offset, "\033[0m\n");
if (tail_len > 0) {
size_t remaining = sizeof(buffer) - offset;
offset += static_cast<size_t>(tail_len) < remaining ? static_cast<size_t>(tail_len) : remaining - 1;
}
}
log_queue_write(buffer, offset);
}
}
}
+184
View File
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: Apache-2.0
#include <tactility/log_queue.h>
#include <tactility/freertos/queue.h>
#include <tactility/freertos/task.h>
#include <tactility/memory.h>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#if defined(ESP_PLATFORM)
#include <esp_log.h>
#endif
namespace {
constexpr size_t LOG_QUEUE_DEPTH = 8;
constexpr size_t DRAIN_TASK_STACK_DEPTH = 4096 / sizeof(StackType_t);
struct LogQueueMessage {
uint16_t length;
char text[LOG_QUEUE_MESSAGE_MAX_LENGTH];
};
std::atomic<QueueHandle_t> g_queue { nullptr };
// The one and only place a real console write happens for log output - deliberately never
// through app_io_write()'s fd-table redirection (Modules/app-module/source/io.cpp). That
// redirection only ever triggers for a task app_task_main() (Modules/app-module/source/
// scheduler.cpp) set up as a running app instance; this drain task is never that, so
// app_scheduler_current_app_id() always reads 0 on it and app_io_write()'s lookup is skipped
// unconditionally - a plain write() already reaches the real syscall, on every platform, with no
// wrap-bypassing needed here.
void write_real(const char* data, size_t length) {
if (length == 0) {
return;
}
#if defined(ESP_PLATFORM)
constexpr int fd = 1; // ESP-IDF's default console fd
#else
constexpr int fd = 2; // matches log_generic()'s stderr choice
#endif
::write(fd, data, length);
}
void drain_task_main(void* context) {
// The queue handle is already valid by the time this task starts (it's created before the
// task is), but reading it back from g_queue here would race: g_queue is only published
// (log_queue_init()'s g_queue.store()) AFTER task creation returns, so this task can start
// running before that store happens. Take it directly as the task's own argument instead.
auto queue = static_cast<QueueHandle_t>(context);
LogQueueMessage message;
while (true) {
if (xQueueReceive(queue, &message, portMAX_DELAY) == pdTRUE) {
write_real(message.text, message.length);
}
}
}
#if defined(ESP_PLATFORM)
// ESP-IDF's own log macros pre-format the entire line (color, level letter, timestamp, tag,
// message, color reset, newline - see esp_log_write()/LOG_FORMAT() in esp_log.h) before handing
// it to the installed vprintf_like_t as fmt+args, so this hook only needs one vsnprintf - no
// prefix building of its own (unlike log_generic() in log.cpp, which builds its own prefix).
// Replaces (does not chain through) the previously-installed vprintf: the whole point is
// removing the direct-to-stdout path, not adding a second consumer of it.
int log_queue_vprintf_hook(const char* format, va_list args) {
char buffer[LOG_QUEUE_MESSAGE_MAX_LENGTH];
int written = vsnprintf(buffer, sizeof(buffer), format, args);
if (written > 0) {
size_t length = static_cast<size_t>(written) < sizeof(buffer) ? static_cast<size_t>(written) : sizeof(buffer) - 1;
log_queue_write(buffer, length);
}
return written;
}
#endif
} // namespace
extern "C" {
void log_queue_init(void) {
if (g_queue.load(std::memory_order_relaxed) != nullptr) {
return; // already initialized
}
#if defined(ESP_PLATFORM)
// Message storage: prefer PSRAM/external memory, fall back to internal automatically if
// unavailable (MemoryPolicy's documented semantics) - this is the bulk of the queue's memory
// (24 * 256 bytes), so it's the part worth offloading to PSRAM when there is any.
MemoryPolicy storage_policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 };
auto* queue_storage = static_cast<uint8_t*>(memory_alloc_with_policy(LOG_QUEUE_DEPTH * sizeof(LogQueueMessage), &storage_policy));
if (queue_storage == nullptr) {
return;
}
// The queue's own control block and the drain task's TCB are both small, fixed-size kernel
// objects (not the bulk data) - keep them in internal RAM like scheduler.cpp's
// APP_TASK_TCB_POLICY already does for app instance tasks.
MemoryPolicy internal_policy = { .required = MEMORY_CAPABILITY_INTERNAL, .desired = 0, .alignment = 0 };
auto* queue_struct = static_cast<StaticQueue_t*>(memory_alloc_with_policy(sizeof(StaticQueue_t), &internal_policy));
if (queue_struct == nullptr) {
memory_free(queue_storage);
return;
}
QueueHandle_t queue = xQueueCreateStatic(LOG_QUEUE_DEPTH, sizeof(LogQueueMessage), queue_storage, queue_struct);
if (queue == nullptr) {
memory_free(queue_struct);
memory_free(queue_storage);
return;
}
// Stack: same PSRAM-preferred/internal-fallback policy as the message storage above.
MemoryPolicy stack_policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 };
auto* stack_buffer = static_cast<StackType_t*>(memory_alloc_with_policy(DRAIN_TASK_STACK_DEPTH * sizeof(StackType_t), &stack_policy));
if (stack_buffer == nullptr) {
vQueueDelete(queue);
memory_free(queue_struct);
memory_free(queue_storage);
return;
}
auto* task_tcb = static_cast<StaticTask_t*>(memory_alloc_with_policy(sizeof(StaticTask_t), &internal_policy));
if (task_tcb == nullptr) {
memory_free(stack_buffer);
vQueueDelete(queue);
memory_free(queue_struct);
memory_free(queue_storage);
return;
}
TaskHandle_t task_handle = xTaskCreateStatic(drain_task_main, "log_drain", DRAIN_TASK_STACK_DEPTH, queue, tskIDLE_PRIORITY + 1, stack_buffer, task_tcb);
if (task_handle == nullptr) {
memory_free(task_tcb);
memory_free(stack_buffer);
vQueueDelete(queue);
memory_free(queue_struct);
memory_free(queue_storage);
return;
}
#else
QueueHandle_t queue = xQueueCreate(LOG_QUEUE_DEPTH, sizeof(LogQueueMessage));
if (queue == nullptr) {
return;
}
TaskHandle_t task_handle = nullptr;
if (xTaskCreate(drain_task_main, "log_drain", DRAIN_TASK_STACK_DEPTH, queue, tskIDLE_PRIORITY + 1, &task_handle) != pdPASS) {
vQueueDelete(queue);
return;
}
#endif
g_queue.store(queue, std::memory_order_release); // published last
#if defined(ESP_PLATFORM)
esp_log_set_vprintf(log_queue_vprintf_hook);
#endif
}
void log_queue_write(const char* data, size_t length) {
if (data == nullptr || length == 0) {
return;
}
QueueHandle_t queue = g_queue.load(std::memory_order_acquire);
if (queue == nullptr) {
write_real(data, length); // pre-init fallback
return;
}
LogQueueMessage message;
size_t copy_length = length < sizeof(message.text) ? length : sizeof(message.text) - 1;
memcpy(message.text, data, copy_length);
message.length = static_cast<uint16_t>(copy_length);
xQueueSend(queue, &message, portMAX_DELAY);
}
} // extern "C"
-9
View File
@@ -42,7 +42,6 @@ static error_t paths_get_data_root_path(char* out_path, size_t out_path_size) {
extern "C" {
error_t paths_get_data_path(char* out_path, size_t out_path_size) {
#ifdef ESP_PLATFORM
char root[64];
error_t error = paths_get_data_root_path(root, sizeof(root));
if (error != ERROR_NONE) {
@@ -53,14 +52,6 @@ error_t paths_get_data_path(char* out_path, size_t out_path_size) {
return ERROR_BUFFER_OVERFLOW;
}
return ERROR_NONE;
#else
const char* fixed_path = "data";
if (std::strlen(fixed_path) + 1 > out_path_size) {
return ERROR_BUFFER_OVERFLOW;
}
std::strcpy(out_path, fixed_path);
return ERROR_NONE;
#endif
}
} // extern "C"
+6 -6
View File
@@ -5,22 +5,22 @@
#include <tactility/paths.h>
// The simulator target is never built with ESP_PLATFORM, so paths_get_data_path()
// always takes the fixed "data" path branch here, guarded by a buffer-size check.
// always takes the fixed "data" root, with "/tactility" appended, guarded by a buffer-size check.
TEST_CASE("paths_get_data_path succeeds when the buffer exactly fits") {
char buffer[16] = { 0 };
char buffer[32] = { 0 };
CHECK_EQ(paths_get_data_path(buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "data"), 0);
CHECK_EQ(std::strcmp(buffer, "data/tactility"), 0);
}
TEST_CASE("paths_get_data_path succeeds with a buffer sized to exactly fit the string and terminator") {
char buffer[5] = { 0 }; // strlen("data") + 1
char buffer[15] = { 0 }; // strlen("data/tactility") + 1
CHECK_EQ(paths_get_data_path(buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "data"), 0);
CHECK_EQ(std::strcmp(buffer, "data/tactility"), 0);
}
TEST_CASE("paths_get_data_path reports a buffer overflow when the buffer is one byte too small") {
char buffer[4] = { 0 }; // strlen("data"), no room for the terminator
char buffer[14] = { 0 }; // strlen("data/tactility"), no room for the terminator
CHECK_EQ(paths_get_data_path(buffer, sizeof(buffer)), ERROR_BUFFER_OVERFLOW);
}
+2
View File
@@ -9,6 +9,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/TactilityKernel/tests ${CMAKE_CURRENT_BINAR
add_subdirectory(${CMAKE_SOURCE_DIR}/Tactility/Tests ${CMAKE_CURRENT_BINARY_DIR}/Tactility)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_BINARY_DIR}/crypt-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-posix-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module)
add_custom_target(build-tests)
@@ -18,4 +19,5 @@ add_dependencies(build-tests TactilityTests)
add_dependencies(build-tests TactilityKernelTests)
add_dependencies(build-tests CryptModuleTests)
add_dependencies(build-tests AppModuleTests)
add_dependencies(build-tests AppPosixModuleTests)
add_dependencies(build-tests HttpModuleTests)
+7 -10
View File
@@ -1,16 +1,13 @@
# tactility-cmakelists-version: 1
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH ../../release/TactilitySDK)
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
if (NOT DEFINED ENV{TACTILITY_SDK_PATH})
message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set")
endif()
get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE)
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH} ${TACTILITY_SDK_PATH}/Modules)
project(SdkTest)
tactility_project(SdkTest)
tactility_project_pre(tactility.sdktest)
project(tactility.sdktest)
tactility_project_post(tactility.sdktest)
+6 -6
View File
@@ -1,7 +1,7 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
if (NOT DEFINED TACTILITY_SDK_PATH)
get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_LIST_DIR}/..")
endif ()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK
app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module
)
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
tactility_component_register(SRCS ${SOURCE_FILES} INCLUDE_DIRS include)
+3 -1
View File
@@ -9,6 +9,8 @@
#include <lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <greeting.h>
#include <stdbool.h>
static void create_widgets(lv_obj_t* parent, void* userData) {
@@ -16,7 +18,7 @@ static void create_widgets(lv_obj_t* parent, void* userData) {
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_t* label = lv_label_create(parent);
lv_label_set_text(label, "Hello, world!");
lv_label_set_text(label, SDK_TEST_GREETING);
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
}
@@ -0,0 +1,5 @@
#pragma once
// Only exists to prove tactility_component_register()'s INCLUDE_DIRS parameter resolves this
// directory on both platforms - see main/CMakeLists.txt.
#define SDK_TEST_GREETING "Hello, world!"
+2 -2
View File
@@ -1,7 +1,7 @@
manifest.version=0.2
target.sdk=0.0.0
target.platforms=esp32,esp32s3,esp32c6,esp32p4
app.id=one.tactility.sdktest
target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64
app.id=tactility.sdktest
app.version.name=0.1.0
app.version.code=1
app.name=SDK Test
+126 -32
View File
@@ -12,7 +12,7 @@ import tarfile
from urllib.parse import urlparse
ttbuild_path = ".tactility"
ttbuild_version = "4.1.0"
ttbuild_version = "5.0.0"
ttbuild_cdn = "https://cdn.tactilityproject.org"
ttbuild_sdk_json_validity = 3600 # seconds
ttport = 6666
@@ -20,6 +20,10 @@ verbose = False
use_local_sdk = False
local_base_path = None
http_timeout_seconds = 10
# App install uploads the whole package over HTTP and the device only responds once it's
# fully received, extracted and registered - large packages (e.g. bundled fonts/assets) can
# easily take well over http_timeout_seconds on a slow SD card, so give it a lot more room.
install_timeout_seconds = 120
shell_color_red = "\033[91m"
shell_color_orange = "\033[93m"
@@ -183,24 +187,26 @@ def update_tool_json():
def should_fetch_sdkconfig_files(platform_targets):
for platform in platform_targets:
sdkconfig_filename = f"sdkconfig.app.{platform}"
if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)):
return True
if not platform.startswith("posix"):
sdkconfig_filename = f"sdkconfig.app.{platform}"
if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)):
return True
return False
def fetch_sdkconfig_files(platform_targets):
for platform in platform_targets:
sdkconfig_filename = f"sdkconfig.app.{platform}"
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
exit_with_error(f"Failed to download sdkconfig file for {platform}")
if not platform.startswith("posix"):
sdkconfig_filename = f"sdkconfig.app.{platform}"
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
exit_with_error(f"Failed to download sdkconfig file for {platform}")
#endregion SDK helpers
#region Validation
def validate_environment():
if os.environ.get("IDF_PATH") is None:
def validate_environment(platforms):
if any(not platform.startswith("posix") for platform in platforms) and os.environ.get("IDF_PATH") is None:
if sys.platform == "win32":
exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1")
else:
@@ -311,6 +317,44 @@ def sdk_download_all(version, platforms):
#endregion SDK download
#region CMakeLists scaffolding
# Bump whenever CMAKELISTS_TEMPLATE below changes, so existing apps' generated CMakeLists.txt get
# regenerated on their next build instead of silently going stale.
CMAKELISTS_VERSION = 1
CMAKELISTS_TEMPLATE = """# tactility-cmakelists-version: %(version)d
cmake_minimum_required(VERSION 3.20)
if (NOT DEFINED ENV{TACTILITY_SDK_PATH})
message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set")
endif()
get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE)
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
tactility_project_pre(%(app_id)s)
project(%(app_id)s)
tactility_project_post(%(app_id)s)
"""
def cmakelists_version_marker():
return f"# tactility-cmakelists-version: {CMAKELISTS_VERSION}"
def ensure_cmakelists_up_to_date(manifest):
marker = cmakelists_version_marker()
if os.path.exists("CMakeLists.txt"):
with open("CMakeLists.txt", "r") as file:
first_line = file.readline().rstrip("\n")
if first_line == marker:
return
print(f"Updating CMakeLists.txt to {marker}")
content = CMAKELISTS_TEMPLATE % {"version": CMAKELISTS_VERSION, "app_id": manifest["app.id"]}
with open("CMakeLists.txt", "w") as file:
file.write(content)
#endregion CMakeLists scaffolding
#region Building
def get_cmake_path(platform):
@@ -318,18 +362,65 @@ def get_cmake_path(platform):
def find_elf_file(platform):
cmake_dir = get_cmake_path(platform)
if os.path.exists(cmake_dir):
for file in os.listdir(cmake_dir):
if file.endswith(".app.elf"):
return os.path.join(cmake_dir, file)
if not os.path.exists(cmake_dir):
return None
# POSIX apps are dlopen()ed shared objects (app-posix-module), not idf.py/elf_loader
# relocatable images, so they land as a plain ".so" instead of "*.app.elf".
suffix = ".so" if platform.startswith("posix") else ".app.elf"
for file in os.listdir(cmake_dir):
if file.endswith(suffix):
return os.path.join(cmake_dir, file)
return None
def get_posix_build_env(sdk_dir):
# A dev shell may already have ESP-IDF's export.sh sourced; strip it so the SDK's plain-CMake
# top-level CMakeLists.txt takes the POSIX branch instead of the idf.py one.
env = os.environ.copy()
env.pop("ESP_IDF_VERSION", None)
env.pop("IDF_PATH", None)
env["TACTILITY_SDK_PATH"] = sdk_dir
return env
def build_posix(version, platform, skip_build):
sdk_dir = get_sdk_dir(version, platform)
if verbose:
print(f"Using SDK at {sdk_dir}")
if skip_build:
return True
env = get_posix_build_env(sdk_dir)
cmake_path = get_cmake_path(platform)
print_status_busy(f"Building {platform}")
configure_command = ["cmake", "-S", ".", "-B", cmake_path, "-G", "Ninja"]
if verbose:
print(f"Running command: {' '.join(configure_command)}")
configure_result = subprocess.run(configure_command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if configure_result.returncode != 0:
print(configure_result.stdout.decode("UTF-8"), end="")
print_status_error(f"Configuring {platform}")
return False
build_command = ["cmake", "--build", cmake_path]
if verbose:
print(f"Running command: {' '.join(build_command)}")
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) as process:
build_output = wait_for_process(process)
if process.returncode == 0:
print_status_success(f"Building {platform}")
return True
else:
for line in build_output:
print(line, end="")
print_status_error(f"Building {platform}")
return False
def build_all(version, platforms, skip_build):
for platform in platforms:
if platform.startswith("posix"):
if not build_posix(version, platform, skip_build):
return False
# First build command must be "idf.py build", otherwise it fails to execute "idf.py elf"
# We check if the ELF file exists and run the correct command
# This can lead to code caching issues, so sometimes a clean build is required
if find_elf_file(platform) is None:
elif find_elf_file(platform) is None:
if not build_first(version, platform, skip_build):
return False
else:
@@ -368,7 +459,8 @@ def build_first(version, platform, skip_build):
print(f"Using SDK at {sdk_dir}")
os.environ["TACTILITY_SDK_PATH"] = sdk_dir
sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}")
shutil.copy(sdkconfig_path, "sdkconfig")
if not platform.startswith("posix"):
shutil.copy(sdkconfig_path, "sdkconfig")
elf_path = find_elf_file(platform)
# Remove previous elf file: re-creation of the file is used to measure if the build succeeded,
# as the actual build job will always fail due to technical issues with the elf cmake script
@@ -444,7 +536,10 @@ def package_intermediate_binaries(target_path, platforms):
if elf_path is None:
print_error(f"ELF file not found for {platform}")
return False
shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}.elf"))
# app-posix-module's loader resolves an installed app to "elf/posix-<arch>.so", matching
# its own compile-time architecture, not "*.elf".
extension = ".so" if platform.startswith("posix") else ".elf"
shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}{extension}"))
return True
def package_intermediate_assets(target_path):
@@ -463,12 +558,10 @@ def package_intermediate(platforms):
package_intermediate_assets(target_path)
return True
def package_name(platforms):
elf_path = find_elf_file(platforms[0])
elf_base_name = os.path.basename(elf_path).removesuffix(".app.elf")
return os.path.join("build", f"{elf_base_name}.app")
def package_name(manifest):
return os.path.join("build", f"{manifest['app.id']}.app")
def package_all(platforms):
def package_all(manifest, platforms):
status = f"Building package with {platforms}"
print_status_busy(status)
if not package_intermediate(platforms):
@@ -476,7 +569,7 @@ def package_all(platforms):
return False
# Create build/something.app
try:
tar_path = package_name(platforms)
tar_path = package_name(manifest)
with tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT) as tar:
tar.add(os.path.join("build", "package-intermediate"), arcname="")
print_status_success(status)
@@ -492,10 +585,11 @@ def setup_environment():
os.makedirs(ttbuild_path, exist_ok=True)
def build_action(manifest, platform_arg, skip_build):
# Environment validation
validate_environment()
ensure_cmakelists_up_to_date(manifest)
platforms_to_build = get_manifest_target_platforms(manifest, platform_arg)
# Environment validation
validate_environment(platforms_to_build)
if use_local_sdk:
global local_base_path
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
@@ -515,7 +609,7 @@ def build_action(manifest, platform_arg, skip_build):
if not build_all(sdk_version, platforms_to_build, skip_build): # Environment validation
return False
if not skip_build:
if not package_all(platforms_to_build):
if not package_all(manifest, platforms_to_build):
return False
return True
@@ -570,14 +664,14 @@ def run_action(manifest, ip):
except requests.RequestException as e:
print_status_error(f"Running request failed: {e}")
def install_action(ip, platforms):
def install_action(manifest, ip, platforms):
print_status_busy("Installing")
for platform in platforms:
elf_path = find_elf_file(platform)
if elf_path is None:
print_status_error(f"ELF file not built for {platform}")
return False
package_path = package_name(platforms)
package_path = package_name(manifest)
# print(f"Installing {package_path} to {ip}")
url = get_url(ip, "/app/install")
try:
@@ -586,7 +680,7 @@ def install_action(ip, platforms):
files = {
'elf': file
}
response = requests.put(url, files=files, timeout=http_timeout_seconds)
response = requests.put(url, files=files, timeout=install_timeout_seconds)
if response.status_code != 200:
print_status_error("Install failed")
return False
@@ -691,7 +785,7 @@ if __name__ == "__main__":
if len(sys.argv) >= 4:
platform = sys.argv[3]
platforms_to_install = [platform]
install_action(sys.argv[2], platforms_to_install)
install_action(manifest, sys.argv[2], platforms_to_install)
elif action_arg == "uninstall":
if len(sys.argv) < 3:
print_help()
@@ -707,7 +801,7 @@ if __name__ == "__main__":
platform = sys.argv[3]
platforms_to_install = [platform]
if build_action(manifest, platform, skip_build):
if install_action(sys.argv[2], platforms_to_install):
if install_action(manifest, sys.argv[2], platforms_to_install):
run_action(manifest, sys.argv[2])
else:
print_help()