diff --git a/.claude/rules/key-conventions.md b/.claude/rules/key-conventions.md index 7188787ef..e75121ade 100644 --- a/.claude/rules/key-conventions.md +++ b/.claude/rules/key-conventions.md @@ -4,5 +4,7 @@ Code in `Platforms/platform-esp32/` is already ESP-only and does not need guards around ESP-IDF includes. - The `Drivers/` directory contains hardware drivers (display controllers, touch controllers, PMICs, etc.) — each is its own CMake component. - `Modules/` contains cross-cutting modules. e.g.`lvgl-module` (LVGL task management). -- `Data/system/` and `Data/data/` are flashed as FAT filesystem images on ESP32 devices that don't have an SD card. +- `Data/data/` is flashed as a FAT filesystem image on ESP32 devices that don't have an SD card. +- `Data/system/` is embedded in the Tactility project as generated C byte arrays (`generated_assets.h`, see `Buildscripts/generate-assets.py`). +- The binfont files of the default font size are generated at build time into `generated_fonts.h` (see `Buildscripts/generate-fonts.py`). - Translations are in `Translations/` as CSV files, generated via `generate.py`. diff --git a/Buildscripts/BinfontTool/binfont_gen.cpp b/Buildscripts/BinfontTool/binfont_gen.cpp new file mode 100644 index 000000000..da27cfe89 --- /dev/null +++ b/Buildscripts/BinfontTool/binfont_gen.cpp @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +// Host tool that rasterizes TrueType fonts into binfont files with the same generator as the firmware. +// +// Usage: binfont_gen +// Each line of the jobs file has tab separated fields: name, TTF path, size, bpp and a codepoints file. +// The codepoints file has one decimal codepoint per line, "-" means all codepoints of the TTF. +// The result of a job is written to /.bin. + +#include + +#include + +#include +#include +#include +#include +#include +#include + +// Normally defined by the kernel's memory.cpp, which depends on more than this tool needs +extern "C" const struct MemoryPolicy MEMORY_POLICY_DEFAULT = { 0, 0, 0 }; + +namespace { + +bool readCodepoints(const std::string& path, std::vector& codepoints) { + std::ifstream file(path); + if (!file) { + return false; + } + std::string line; + while (std::getline(file, line)) { + if (!line.empty()) { + codepoints.push_back(static_cast(std::stoul(line))); + } + } + return true; +} + +bool runJob(const std::string& line, const std::string& outputFolder) { + std::istringstream fields(line); + std::string name, ttfPath, size, bpp, codepointsPath; + if (!std::getline(fields, name, '\t') || !std::getline(fields, ttfPath, '\t') || !std::getline(fields, size, '\t') || + !std::getline(fields, bpp, '\t') || !std::getline(fields, codepointsPath, '\t')) { + fprintf(stderr, "Invalid job: %s\n", line.c_str()); + return false; + } + + std::vector codepoints; + if (codepointsPath != "-" && !readCodepoints(codepointsPath, codepoints)) { + fprintf(stderr, "Failed to read %s\n", codepointsPath.c_str()); + return false; + } + + const BinFontGeneratorConfig config = { + .ttf_path = ttfPath.c_str(), + .ttf_data = nullptr, + .ttf_size = 0, + .size = static_cast(std::stoul(size)), + .bpp = static_cast(std::stoul(bpp)), + .codepoints = codepoints.empty() ? nullptr : codepoints.data(), + .codepoint_count = codepoints.size(), + }; + + uint8_t* data = nullptr; + size_t dataSize = 0; + const error_t error = binfont_generate(&config, &data, &dataSize); + if (error != ERROR_NONE) { + fprintf(stderr, "Failed to generate %s (error %d)\n", name.c_str(), static_cast(error)); + return false; + } + + const std::string outputPath = outputFolder + "/" + name + ".bin"; + FILE* output = fopen(outputPath.c_str(), "wb"); + const bool written = output != nullptr && fwrite(data, 1, dataSize, output) == dataSize; + if (output != nullptr) { + fclose(output); + } + memory_free(data); + if (!written) { + fprintf(stderr, "Failed to write %s\n", outputPath.c_str()); + } + return written; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 3) { + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + std::ifstream jobs(argv[1]); + if (!jobs) { + fprintf(stderr, "Failed to read %s\n", argv[1]); + return 1; + } + std::string line; + while (std::getline(jobs, line)) { + if (!line.empty() && !runJob(line, argv[2])) { + return 1; + } + } + return 0; +} diff --git a/Buildscripts/generate-assets.py b/Buildscripts/generate-assets.py new file mode 100644 index 000000000..d282c7d29 --- /dev/null +++ b/Buildscripts/generate-assets.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +Embeds every file of an asset folder as a C byte array. + +Usage: generate-assets.py + +Writes generated_assets.h and generated_assets.c. A file "fonts/AdwaitaSans.ttf" becomes the symbols +asset_fonts_AdwaitaSans_ttf (the bytes) and asset_fonts_AdwaitaSans_ttf_size (the byte count). +The size is also available as the constant asset_fonts_AdwaitaSans_ttf_SIZE for use in constant initializers. +Text files get an extra terminating 0 byte that is not part of the size, so they can be used as C strings. +Output files are only rewritten when their content changes, to avoid needless recompiles. +""" +import os +import re +import sys + +TEXT_EXTENSIONS = {".pem", ".csv", ".html", ".json", ".codepoints", ".i18n", ".txt"} + + +def symbol_name(relative_path: str) -> str: + return "asset_" + re.sub(r"[^A-Za-z0-9]", "_", relative_path) + + +def write_if_changed(path: str, content: str): + if os.path.exists(path): + with open(path, "r", encoding="utf-8", newline="") as file: + if file.read() == content: + return + with open(path, "w", encoding="utf-8", newline="") as file: + file.write(content) + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + asset_folder = os.path.abspath(sys.argv[1]) + output_folder = os.path.abspath(sys.argv[2]) + os.makedirs(output_folder, exist_ok=True) + + relative_paths = [] + for directory, _, file_names in os.walk(asset_folder): + for file_name in file_names: + relative_paths.append(os.path.relpath(os.path.join(directory, file_name), asset_folder).replace(os.sep, "/")) + relative_paths.sort() + + header = [ + "// Generated by Buildscripts/generate-assets.py. Do not edit.", + "#pragma once", + "", + "#include ", + "#include ", + "", + "#ifdef __cplusplus", + 'extern "C" {', + "#endif", + "", + ] + source = [ + "// Generated by Buildscripts/generate-assets.py. Do not edit.", + '#include "generated_assets.h"', + "", + ] + for relative_path in relative_paths: + with open(os.path.join(asset_folder, relative_path), "rb") as file: + data = file.read() + symbol = symbol_name(relative_path) + size = len(data) + if os.path.splitext(relative_path)[1].lower() in TEXT_EXTENSIONS: + data += b"\0" + header.append(f"// {relative_path}") + header.append(f"extern const uint8_t {symbol}[];") + header.append(f"extern const size_t {symbol}_size;") + header.append(f"#define {symbol}_SIZE {size}") + source.append(f"// {relative_path}") + source.append(f"const uint8_t {symbol}[] = {{") + for offset in range(0, len(data), 24): + source.append(" " + ", ".join(f"0x{byte:02x}" for byte in data[offset:offset + 24]) + ",") + if len(data) == 0: + source.append(" 0") + source.append("};") + source.append(f"const size_t {symbol}_size = {size};") + source.append("") + header += ["", "#ifdef __cplusplus", "}", "#endif", ""] + + write_if_changed(os.path.join(output_folder, "generated_assets.h"), "\n".join(header)) + write_if_changed(os.path.join(output_folder, "generated_assets.c"), "\n".join(source)) + + +if __name__ == "__main__": + main() diff --git a/Buildscripts/generate-fonts.py b/Buildscripts/generate-fonts.py new file mode 100644 index 000000000..a3b568016 --- /dev/null +++ b/Buildscripts/generate-fonts.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +""" +Generates the binfont files that a device needs at its default font size, as C byte arrays. + +Usage: generate-fonts.py + +The fonts are generated by Buildscripts/BinfontTool, built with the host compiler. It uses the same +generator as the firmware, so the result is identical to generating the fonts on the device. +Writes generated_fonts.c and generated_fonts.h to the output folder. The entries are found by the file name that +the firmware uses for its font cache. The table is empty when no host compiler is available. +The fonts, sizes and bits per pixel must match Tactility/Source/lvgl/Fonts.cpp, FontSizes.cpp and FontCache.cpp. +""" +import os +import re +import shutil +import struct +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FONTS = os.path.join(ROOT, "Data", "system", "fonts") +DEFAULT_FONT_SIZE = 14 + + +def read_text(path: str) -> str: + with open(path, "r", encoding="utf-8") as file: + return file.read() + + +def read_properties(path: str) -> dict: + properties = {} + for line in read_text(path).splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + properties[key.strip()] = value.strip().strip('"') + return properties + + +def float32(value: float) -> float: + return struct.unpack("f", struct.pack("f", value))[0] + + +def scale(size: int, factor: float) -> int: + # Same as lround(size * factor) with a float factor, in float arithmetic + product = float32(size * float32(factor)) + return int(product + 0.5) if product >= 0 else int(product - 0.5) + + +def scale_double(size: int, factor: float) -> int: + # Same as lround(size * factor) for a double factor + product = size * factor + return int(product + 0.5) + + +def get_defines(path: str) -> dict: + return {name: int(value.rstrip("uU"), 0) for name, value in re.findall(r"#define\s+(\w+)\s+(0x[0-9A-Fa-f]+u?|\d+u?)", read_text(path))} + + +def get_icon_names() -> dict: + source = read_text(os.path.join(ROOT, "Modules", "lvgl-module", "source", "icon_names.c")) + names = {} + for key in ("shared", "statusbar", "launcher"): + body = re.search(r"lvgl_icon_%s_names\[\]\s*=\s*\{(.*?)\};" % key, source, re.S).group(1) + names[key] = re.findall(r'"([^"]+)"', body) + return names + + +def get_icon_codepoints(names: list) -> list: + codepoints = {} + for line in read_text(os.path.join(FONTS, "MaterialSymbolsRounded.codepoints")).splitlines(): + parts = line.split(" ") + if len(parts) == 2: + codepoints[parts[0]] = int(parts[1], 16) + return [codepoints[name] for name in names if name in codepoints] + + +def get_device_settings(device_folder: str, simulator: bool) -> tuple: + """@return the default font size and the bits per pixel""" + properties = read_properties(os.path.join(device_folder, "device.properties")) + font_size = int(properties.get("font.defaultSize") or properties.get("lvgl.fontSize") or DEFAULT_FONT_SIZE) + has_psram = properties.get("hardware.spiRam", "false") == "true" and not simulator + monochrome = any( + re.search(r"ssd1306|st7305", read_text(os.path.join(device_folder, name))) + for name in os.listdir(device_folder) if name.endswith(".dts") + ) + # Same choice as getGeneratedFontBpp() + bpp = 1 if monochrome else (4 if has_psram else 2) + return font_size, bpp + + +def get_jobs(font_size: int, bpp: int) -> list: + """@return a list of (file name, ttf file, size, codepoints or None)""" + text_version = get_defines(os.path.join(ROOT, "Tactility", "Private", "Tactility", "lvgl", "FontVersions.h")) + icon_version = get_defines(os.path.join(ROOT, "Modules", "lvgl-module", "include", "lvgl", "icons", "names.h"))["LVGL_ICON_FONT_VERSION"] + icon_names = get_icon_names() + + def file_name(prefix: str, name: str, size: int, version: int) -> str: + return f"{prefix}{name}_{size}_{bpp}bpp_{version:08x}.bin" + + jobs = [] + sans = os.path.join(FONTS, "AdwaitaSans.ttf") + text_fonts = [("small", scale(font_size, 0.75)), ("default", font_size), ("large", scale(font_size, 1.25))] + for name, size in text_fonts: + jobs.append((file_name("text_", name, size, text_version["TT_TEXT_FONT_VERSION"]), sans, size, None)) + jobs.append((file_name("mono_", "mono", font_size, text_version["TT_MONO_FONT_VERSION"]), os.path.join(FONTS, "AdwaitaMono.ttf"), font_size, None)) + + shared_size = scale_double(font_size, 1.25) + icon_fonts = [ + ("statusbar", scale(font_size, 1.15), icon_names["statusbar"]), + ("launcher", scale(font_size, 2.6), icon_names["launcher"]), + ("shared_default", shared_size, icon_names["shared"]), + ("shared_large", shared_size * 2, icon_names["shared"]), + ] + for name, size, names in icon_fonts: + jobs.append((file_name("material_symbols_", name, size, icon_version), os.path.join(FONTS, "MaterialSymbolsRounded.ttf"), size, get_icon_codepoints(names))) + return jobs + + +def build_tool(work_folder: str): + """@return the path of the tool, or None when there's no host compiler""" + compiler = next((found for found in (shutil.which(name) for name in ("c++", "g++", "clang++")) if found), None) + if compiler is None: + return None + tool = os.path.join(work_folder, "binfont_gen") + command = [ + compiler, "-O2", "-std=c++20", + "-I" + os.path.join(ROOT, "Modules", "binfont-module", "include"), + "-I" + os.path.join(ROOT, "Modules", "binfont-module", "private"), + "-I" + os.path.join(ROOT, "TactilityKernel", "include"), + os.path.join(ROOT, "Buildscripts", "BinfontTool", "binfont_gen.cpp"), + os.path.join(ROOT, "Modules", "binfont-module", "source", "generator.cpp"), + os.path.join(ROOT, "TactilityKernel", "source", "memory_posix.cpp"), + "-o", tool, + ] + if subprocess.run(command).returncode != 0: + return None + return tool + + +def write_if_changed(path: str, content: str): + if os.path.exists(path) and read_text(path) == content: + return + with open(path, "w", encoding="utf-8", newline="") as file: + file.write(content) + + +def main(): + if len(sys.argv) != 4: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + device_folder, simulator, output_folder = sys.argv[1], sys.argv[2] == "1", sys.argv[3] + os.makedirs(output_folder, exist_ok=True) + + font_size, bpp = get_device_settings(device_folder, simulator) + jobs = get_jobs(font_size, bpp) + + fonts = [] + with tempfile.TemporaryDirectory() as work_folder: + tool = build_tool(work_folder) + if tool is None: + print("WARNING: no host C++ compiler found, fonts are not embedded in the firmware") + else: + lines = [] + for index, (name, ttf, size, codepoints) in enumerate(jobs): + codepoints_path = "-" + if codepoints is not None: + codepoints_path = os.path.join(work_folder, f"{index}.codepoints") + with open(codepoints_path, "w") as file: + file.write("".join(f"{codepoint}\n" for codepoint in codepoints)) + lines.append(f"{index}\t{ttf}\t{size}\t{bpp}\t{codepoints_path}\n") + jobs_path = os.path.join(work_folder, "jobs.txt") + with open(jobs_path, "w") as file: + file.writelines(lines) + if subprocess.run([tool, jobs_path, work_folder]).returncode != 0: + sys.exit(1) + for index, (name, _, _, _) in enumerate(jobs): + with open(os.path.join(work_folder, f"{index}.bin"), "rb") as file: + fonts.append((name, file.read())) + + header = [ + "// Generated by Buildscripts/generate-fonts.py. Do not edit.", + "#pragma once", + "", + "#include ", + "#include ", + "", + "#ifdef __cplusplus", + 'extern "C" {', + "#endif", + "", + "/** A binfont file that is part of the firmware, found by the file name that the font cache uses */", + "struct GeneratedFont {", + " const char* file_name;", + " const uint8_t* data;", + " size_t size;", + "};", + "", + "extern const struct GeneratedFont generated_fonts[];", + "extern const size_t generated_fonts_count;", + "", + "#ifdef __cplusplus", + "}", + "#endif", + "", + ] + source = ['// Generated by Buildscripts/generate-fonts.py. Do not edit.', '#include "generated_fonts.h"', ""] + for index, (name, data) in enumerate(fonts): + source.append(f"static const uint8_t font_{index}[] = {{") + for offset in range(0, len(data), 24): + source.append(" " + ", ".join(f"0x{byte:02x}" for byte in data[offset:offset + 24]) + ",") + source.append("};") + source.append("") + source.append("const struct GeneratedFont generated_fonts[] = {") + for index, (name, data) in enumerate(fonts): + source.append(f' {{ "{name}", font_{index}, {len(data)} }},') + if not fonts: + source.append(" { 0, 0, 0 }") + source.append("};") + source.append(f"const size_t generated_fonts_count = {len(fonts)};") + source.append("") + + write_if_changed(os.path.join(output_folder, "generated_fonts.h"), "\n".join(header)) + write_if_changed(os.path.join(output_folder, "generated_fonts.c"), "\n".join(source)) + print(f"Generated {len(fonts)} fonts ({sum(len(data) for _, data in fonts)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/Buildscripts/generate-ui-assets.py b/Buildscripts/generate-ui-assets.py new file mode 100644 index 000000000..7d5830210 --- /dev/null +++ b/Buildscripts/generate-ui-assets.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Generates the spinner and cursor images in Data/system/ui/ from Data/system_sources/: +- spinner_.png from TactilitySpinner.svg +- cursor_.png from cursor.svg + +The sizes are in pixels. They must match the ones in Modules/lvgl-module/source/ui_assets.c. + +Requires the cairosvg package: pip install cairosvg +""" +import os +import re + +import cairosvg + +SPINNER_SIZES = [12, 16, 20, 24, 32, 36, 40] +CURSOR_SIZES = [12, 16, 20, 24, 32, 40] + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SOURCES = os.path.join(ROOT, "Data", "system_sources") +OUTPUT = os.path.join(ROOT, "Data", "system", "ui") + + +def load_svg(svg_name: str) -> bytes: + """ + Inkscape leaves x, y, width and height attributes on paths that came from rectangles. + cairosvg applies them to the path, which displaces and distorts it, so they're removed. + """ + with open(os.path.join(SOURCES, svg_name), "r", encoding="utf-8") as file: + svg = file.read() + svg = re.sub( + r"", + lambda tag: re.sub(r'\s+(?:x|y|width|height)="[^"]*"', "", tag[0]), + svg, + flags=re.S, + ) + return svg.encode("utf-8") + + +def render(svg_name: str, output_name: str, size: int): + cairosvg.svg2png( + bytestring=load_svg(svg_name), + write_to=os.path.join(OUTPUT, output_name), + output_width=size, + output_height=size, + ) + print(output_name) + + +def main(): + os.makedirs(OUTPUT, exist_ok=True) + for size in SPINNER_SIZES: + render("TactilitySpinner.svg", f"spinner_{size}.png", size) + for size in CURSOR_SIZES: + render("cursor.svg", f"cursor_{size}.png", size) + + +if __name__ == "__main__": + main() diff --git a/Buildscripts/release-simulator.sh b/Buildscripts/release-simulator.sh index e1545c41e..01fb7f06e 100755 --- a/Buildscripts/release-simulator.sh +++ b/Buildscripts/release-simulator.sh @@ -13,5 +13,4 @@ mkdir -p "$target_path" cp version.txt "$target_path" cp "$build_path/Tactility/Tactility" "$target_path/" -cp -r Data/data "$target_path/" -cp -r Data/system "$target_path/" +mkdir -p "$target_path/data" diff --git a/Data/data/tactility/service/webserver/settings.properties b/Data/data/tactility/service/webserver/settings.properties deleted file mode 100644 index fceba4296..000000000 --- a/Data/data/tactility/service/webserver/settings.properties +++ /dev/null @@ -1,27 +0,0 @@ -# Web Server Settings -# WiFi and HTTP server configuration - -# WiFi Enable (0=disabled, 1=enabled) -wifiEnabled=0 - -# WiFi Mode (0=Station/Client, 1=Access Point) -wifiMode=0 - -# Access Point Mode Settings (create own WiFi network) -# apSsid will be auto-generated as Tactility-XXXX if empty -# apPassword will be auto-generated if empty or insecure (WPA2 requires 8-63 chars) -# apOpenNetwork: if 1, create open network without password (ignores apPassword) -apSsid= -apPassword= -apOpenNetwork=0 -apChannel=1 - -# Web Server Settings -webServerEnabled=0 -webServerPort=80 - -# HTTP Basic Authentication (optional) -# When auth is enabled with empty/insecure credentials, strong random credentials are auto-generated -webServerAuthEnabled=0 -webServerUsername= -webServerPassword= diff --git a/Data/system/app/LocaleSettings/i18n/en-GB.i18n b/Data/system/app/LocaleSettings/i18n/en-GB.i18n deleted file mode 100644 index ab750d45f..000000000 --- a/Data/system/app/LocaleSettings/i18n/en-GB.i18n +++ /dev/null @@ -1,7 +0,0 @@ -Dutch (Netherlands) -Dutch (Belgium) -English (United States) -English (United Kingdom) -French (France) -Region -Language diff --git a/Data/system/app/LocaleSettings/i18n/en-US.i18n b/Data/system/app/LocaleSettings/i18n/en-US.i18n deleted file mode 100644 index ab750d45f..000000000 --- a/Data/system/app/LocaleSettings/i18n/en-US.i18n +++ /dev/null @@ -1,7 +0,0 @@ -Dutch (Netherlands) -Dutch (Belgium) -English (United States) -English (United Kingdom) -French (France) -Region -Language diff --git a/Data/system/app/LocaleSettings/i18n/fr-FR.i18n b/Data/system/app/LocaleSettings/i18n/fr-FR.i18n deleted file mode 100644 index 209b263a3..000000000 --- a/Data/system/app/LocaleSettings/i18n/fr-FR.i18n +++ /dev/null @@ -1,7 +0,0 @@ -Néerlandais (Pays-Bas) -Néerlandais (Belgique) -Anglais (États-Unis) -Anglais (Royaume-Uni) -Français (France) -Région -Langue diff --git a/Data/system/app/LocaleSettings/i18n/nl-BE.i18n b/Data/system/app/LocaleSettings/i18n/nl-BE.i18n deleted file mode 100644 index 39900b4b1..000000000 --- a/Data/system/app/LocaleSettings/i18n/nl-BE.i18n +++ /dev/null @@ -1,7 +0,0 @@ -Nederlands (Nederland) -Nederlands (België) -Engels (Verenigde Staten) -Engels (Verenigd Koninkrijk) -Frans (Frankrijk) -Regio -Taal diff --git a/Data/system/app/LocaleSettings/i18n/nl-NL.i18n b/Data/system/app/LocaleSettings/i18n/nl-NL.i18n deleted file mode 100644 index 39900b4b1..000000000 --- a/Data/system/app/LocaleSettings/i18n/nl-NL.i18n +++ /dev/null @@ -1,7 +0,0 @@ -Nederlands (Nederland) -Nederlands (België) -Engels (Verenigde Staten) -Engels (Verenigd Koninkrijk) -Frans (Frankrijk) -Regio -Taal diff --git a/Data/system/app/WebServer/dashboard.html b/Data/system/app/WebServer/dashboard.html index d7e75ec51..4e86ff133 100644 --- a/Data/system/app/WebServer/dashboard.html +++ b/Data/system/app/WebServer/dashboard.html @@ -811,26 +811,6 @@ async function loadWifiStatus() { } } -async function syncAssets(btn) { - const status = document.getElementById('actionStatus'); - btn.disabled = true; - btn.textContent = 'Syncing...'; - try { - const r = await fetch('/admin/sync', { method: 'POST' }); - if (status) { - status.textContent = r.ok ? 'Assets synchronized!' : 'Sync failed'; - status.style.color = r.ok ? '#2ecc71' : '#e74c3c'; - } - } catch (e) { - if (status) { - status.textContent = 'Error: ' + e.message; - status.style.color = '#e74c3c'; - } - } - btn.disabled = false; - btn.textContent = 'Sync Assets'; -} - async function captureScreenshot(btn) { const status = document.getElementById('actionStatus'); const container = document.getElementById('screenshotContainer'); diff --git a/Data/system/cursor.png b/Data/system/cursor.png deleted file mode 100644 index 7f35bc39f..000000000 Binary files a/Data/system/cursor.png and /dev/null differ diff --git a/Data/system/i18n/core/en-GB.i18n b/Data/system/i18n/core/en-GB.i18n deleted file mode 100644 index a04e3142b..000000000 --- a/Data/system/i18n/core/en-GB.i18n +++ /dev/null @@ -1,7 +0,0 @@ -OK -Yes -No -Cancel -Retry -Close -Open diff --git a/Data/system/i18n/core/en-US.i18n b/Data/system/i18n/core/en-US.i18n deleted file mode 100644 index a04e3142b..000000000 --- a/Data/system/i18n/core/en-US.i18n +++ /dev/null @@ -1,7 +0,0 @@ -OK -Yes -No -Cancel -Retry -Close -Open diff --git a/Data/system/i18n/core/fr-FR.i18n b/Data/system/i18n/core/fr-FR.i18n deleted file mode 100644 index 2f0350139..000000000 --- a/Data/system/i18n/core/fr-FR.i18n +++ /dev/null @@ -1,7 +0,0 @@ -OK -Oui -Non -Annuler -Réessayer -Fermer -Ouvrir diff --git a/Data/system/i18n/core/nl-BE.i18n b/Data/system/i18n/core/nl-BE.i18n deleted file mode 100644 index 1a5002f4d..000000000 --- a/Data/system/i18n/core/nl-BE.i18n +++ /dev/null @@ -1,7 +0,0 @@ -OK -Ja -Nee -Annuleren -Opnieuw -Sluiten -Openen diff --git a/Data/system/i18n/core/nl-NL.i18n b/Data/system/i18n/core/nl-NL.i18n deleted file mode 100644 index 1a5002f4d..000000000 --- a/Data/system/i18n/core/nl-NL.i18n +++ /dev/null @@ -1,7 +0,0 @@ -OK -Ja -Nee -Annuleren -Opnieuw -Sluiten -Openen diff --git a/Data/system/spinner.png b/Data/system/spinner.png deleted file mode 100644 index 714847dff..000000000 Binary files a/Data/system/spinner.png and /dev/null differ diff --git a/Data/system/ui/cursor_12.png b/Data/system/ui/cursor_12.png new file mode 100644 index 000000000..579df4a05 Binary files /dev/null and b/Data/system/ui/cursor_12.png differ diff --git a/Data/system/ui/cursor_16.png b/Data/system/ui/cursor_16.png new file mode 100644 index 000000000..1f2d3bb89 Binary files /dev/null and b/Data/system/ui/cursor_16.png differ diff --git a/Data/system/ui/cursor_20.png b/Data/system/ui/cursor_20.png new file mode 100644 index 000000000..2e989609c Binary files /dev/null and b/Data/system/ui/cursor_20.png differ diff --git a/Data/system/ui/cursor_24.png b/Data/system/ui/cursor_24.png new file mode 100644 index 000000000..12e5f4bf9 Binary files /dev/null and b/Data/system/ui/cursor_24.png differ diff --git a/Data/system/ui/cursor_32.png b/Data/system/ui/cursor_32.png new file mode 100644 index 000000000..ad77ee847 Binary files /dev/null and b/Data/system/ui/cursor_32.png differ diff --git a/Data/system/ui/cursor_40.png b/Data/system/ui/cursor_40.png new file mode 100644 index 000000000..d49a78adb Binary files /dev/null and b/Data/system/ui/cursor_40.png differ diff --git a/Data/system/ui/spinner_12.png b/Data/system/ui/spinner_12.png new file mode 100644 index 000000000..478056e05 Binary files /dev/null and b/Data/system/ui/spinner_12.png differ diff --git a/Data/system/ui/spinner_16.png b/Data/system/ui/spinner_16.png new file mode 100644 index 000000000..f3cb6d49a Binary files /dev/null and b/Data/system/ui/spinner_16.png differ diff --git a/Data/system/ui/spinner_20.png b/Data/system/ui/spinner_20.png new file mode 100644 index 000000000..527f56ed9 Binary files /dev/null and b/Data/system/ui/spinner_20.png differ diff --git a/Data/system/ui/spinner_24.png b/Data/system/ui/spinner_24.png new file mode 100644 index 000000000..e0e83a094 Binary files /dev/null and b/Data/system/ui/spinner_24.png differ diff --git a/Data/system/ui/spinner_32.png b/Data/system/ui/spinner_32.png new file mode 100644 index 000000000..92582c16f Binary files /dev/null and b/Data/system/ui/spinner_32.png differ diff --git a/Data/system/ui/spinner_36.png b/Data/system/ui/spinner_36.png new file mode 100644 index 000000000..97cc9966e Binary files /dev/null and b/Data/system/ui/spinner_36.png differ diff --git a/Data/system/ui/spinner_40.png b/Data/system/ui/spinner_40.png new file mode 100644 index 000000000..e3bb9f021 Binary files /dev/null and b/Data/system/ui/spinner_40.png differ diff --git a/Devices/simulator/Source/Simulator.h b/Devices/simulator/Source/Simulator.h index d1e900c5f..901f32cea 100644 --- a/Devices/simulator/Source/Simulator.h +++ b/Devices/simulator/Source/Simulator.h @@ -5,6 +5,7 @@ #include #include +#include #include namespace simulator { @@ -19,6 +20,9 @@ void app_main(); // ESP-IDF's main function, implemented in the application } int main() { + // The folder for the "data" file system is not in the repository + mkdir("data", 0755); + // The FreeRTOS POSIX port arms a process-wide SIGALRM timer for its tick and expects every one // of its task pthreads to have all signals but SIGINT blocked. // (see prvSetupSignalsAndSchedulerPolicy() in FreeRTOS-Kernel's Posix port.c) diff --git a/Documentation/ideas.md b/Documentation/ideas.md index b12c6ac0b..cb8309756 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,11 +7,9 @@ ## Higher Priority -- Experiment with LVGL stack in SPIRAM - LVGL shouldn't do file IO anyway. -- Experiment with default malloc() to SPIRAM +- Move EspNowRadio etc to Platforms/platform-esp32/ - GPIO app: fix for monochrome display by using filled vs outline box in secondary colour - BlueTooth as radio backend for MeshCore/Meshtastic? -- ESP-NOW as radio backend. Use it for MeshCore/Meshtastic? - Find out why the first build of an app with a clean build cache folder always fails - Fix BT: an esp32 device connecting to a peripheral isn't working - "update all" functionality for app hub module, including commandline app. add button to app hub toolbar if there are 1 or more apps to update (does "update all"). @@ -22,7 +20,6 @@ - Cursor is hard-coded, needs size based on DPI - Move Bluetooth code from kernel to Modules/bluetooth-module, and have includes in path bluetooth/ then make a bluetooth_command.cpp like wifi_command.cpp - Inspect code for memory improvements: replace `std::*` with `tt::std::*` -- esp_lvgl_port settings has a large stack size (~9kB) to fix stackoverflow when LVGL events (e.g. button click) do actions like file operations do actions like file operations. Can we reduce the callstack? - Improve posix support: use user home path and system /tmp folder. - NimBLE looses pairing key after reboot - Pick different font to improve monochrome screen readability (or change the weight). @@ -37,6 +34,8 @@ ## Medium Priority +- Tactility/Source/app/imageviewer/ImageViewer.cpp: Limit memory used to open an image. + loadFile() now resizes fileData to the entire file size before displaying the image. start accepts a file path without a size limit, so opening a large image can exhaust memory on an embedded device before LVGL decodes it. Reject files above a supported limit or retain a bounded file-backed decoding path. - A device profile selection screen for Simulator: select device profiles such as T-Lora Pager, T-Deck Plus, CL-32, Cardputer and T-Embed which changes the resolution and theming defaults after restarting. - A fullscreen tiny keyboard that works with single stroke gestures (similar to Palm OS Graffiti) - CL-32 display driver performance diff --git a/Modules/app-hub-module/include/app_hub/module.h b/Modules/app-hub-module/include/app_hub/module.h index 0b13b2628..c5194adb4 100644 --- a/Modules/app-hub-module/include/app_hub/module.h +++ b/Modules/app-hub-module/include/app_hub/module.h @@ -9,6 +9,12 @@ extern "C" { extern struct Module app_hub_module; +/** + * @brief Sets the root certificate that verifies the app catalog server. + * @param[in] certificate_pem PEM text that must stay valid forever + */ +void app_hub_configure_certificate(const char* certificate_pem); + #ifdef __cplusplus } #endif diff --git a/Modules/app-hub-module/private/app_hub/app_hub_catalog.h b/Modules/app-hub-module/private/app_hub/app_hub_catalog.h index aa6d5d821..14a0fba31 100644 --- a/Modules/app-hub-module/private/app_hub/app_hub_catalog.h +++ b/Modules/app-hub-module/private/app_hub/app_hub_catalog.h @@ -56,8 +56,8 @@ const char* app_hub_catalog_get_platform(); /** @return true when this device can install and run the entry's app (target platform, device and RAM requirements) */ bool app_hub_catalog_is_compatible(const AppHubCatalogEntry& entry); -/** @return the path of the certificate that verifies the catalog server, in the "system" file system, or an empty string when it isn't available */ -std::string app_hub_catalog_get_certificate_path(); +/** @return the PEM text of the certificate that verifies the catalog server, or an empty string when it isn't configured */ +const char* app_hub_catalog_get_certificate(); std::string app_hub_catalog_get_url(); diff --git a/Modules/app-hub-module/source/app_hub_catalog.cpp b/Modules/app-hub-module/source/app_hub_catalog.cpp index 2dfb980b3..cef9fa448 100644 --- a/Modules/app-hub-module/source/app_hub_catalog.cpp +++ b/Modules/app-hub-module/source/app_hub_catalog.cpp @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #include -#include #include #include @@ -32,14 +32,14 @@ static std::string get_version_without_postfix() { return (index == std::string::npos) ? version : version.substr(0, index); } -std::string app_hub_catalog_get_certificate_path() { - // The system file system is mounted at "/system" on ESP32, but it's a folder elsewhere on the simulator - FileSystem* system = file_system_find_by_name("system"); - char path[FILE_MAX_PATH_STRING_LENGTH]; - if (system == nullptr || file_system_get_path(system, path, sizeof(path)) != ERROR_NONE) { - return ""; - } - return std::string(path) + "/certificates/GTS_Root_R4.pem"; +static const char* certificate_pem = ""; + +void app_hub_configure_certificate(const char* pem) { + certificate_pem = pem; +} + +const char* app_hub_catalog_get_certificate() { + return certificate_pem; } std::string app_hub_catalog_get_url() { diff --git a/Modules/app-hub-module/source/app_hub_service.cpp b/Modules/app-hub-module/source/app_hub_service.cpp index 5f350f29c..2762e9b50 100644 --- a/Modules/app-hub-module/source/app_hub_service.cpp +++ b/Modules/app-hub-module/source/app_hub_service.cpp @@ -19,17 +19,17 @@ #include #include -#include +#include +#include +#include #include #include #include #include #include -#include #include #include -#include namespace { @@ -53,7 +53,7 @@ struct InstalledPackage { AppId first_app_id; }; -using AppList = std::vector>; +using AppList = tt::std::vector; struct GlobalMutex { Mutex handle {}; @@ -69,7 +69,7 @@ void unlock() { mutex_unlock(&globalMutex.handle); } // region State, guarded by the global lock struct State { - std::deque> queue; + tt::std::deque queue; Operation runningOperation {}; bool operationRunning = false; uint32_t nextOperationId = 1; @@ -191,10 +191,10 @@ void copyString(char* target, size_t targetSize, const char* source) { } /** Copies the installed packages, as app_manager_for_each_package() holds the app manager's lock */ -std::vector getInstalledPackages() { - std::vector packages; +tt::std::vector getInstalledPackages() { + tt::std::vector packages; app_manager_for_each_package([](const AppPackage* package, void* context) { - auto& packages = *static_cast*>(context); + auto& packages = *static_cast*>(context); InstalledPackage installed {}; installed.package = package->package; if (package->app_id_count > 0) { @@ -232,7 +232,7 @@ const Operation* findPendingOperation(const char* id) { } /** Requires the global lock */ -AppList collectApps(const std::vector& installedPackages) { +AppList collectApps(const tt::std::vector& installedPackages) { AppList apps; char catalogPath[FILE_MAX_PATH_STRING_LENGTH]; if (getCatalogFile(catalogPath, sizeof(catalogPath))) { @@ -308,7 +308,7 @@ error_t download(const char* url, const char* targetPath) { if (http_download_subscribe(&sub, &state->workerEvents) != ERROR_NONE) { return ERROR_RESOURCE; } - if (http_download_start(url, app_hub_catalog_get_certificate_path().c_str(), targetPath, &sub) != ERROR_NONE) { + if (http_download_start(url, app_hub_catalog_get_certificate(), targetPath, &sub) != ERROR_NONE) { http_download_unsubscribe(&sub); return ERROR_RESOURCE; } @@ -395,8 +395,8 @@ error_t executeInstall(const char* id) { return error; } const auto separator = file.find_last_of('/'); - const std::string fileName = (separator == std::string::npos) ? file : file.substr(separator + 1); - const std::string tempPath = std::string(tempDirectory) + "/" + fileName; + const tt::std::string fileName = (separator == std::string::npos) ? tt::std::string(file.c_str()) : tt::std::string(file.c_str() + separator + 1); + const tt::std::string tempPath = tt::std::string(tempDirectory) + "/" + fileName; error = download(app_hub_catalog_get_download_url(file).c_str(), tempPath.c_str()); if (error == ERROR_NONE) { @@ -413,7 +413,7 @@ error_t executeInstall(const char* id) { error_t executeUpdateAll(const Operation& operation) { const auto installed = getInstalledPackages(); - std::vector ids; + tt::std::vector ids; lock(); for (const auto& app : collectApps(installed)) { if (app.update_available) { diff --git a/Modules/app-module/source/resources.cpp b/Modules/app-module/source/resources.cpp index b783aa983..31a806fcd 100644 --- a/Modules/app-module/source/resources.cpp +++ b/Modules/app-module/source/resources.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include #include @@ -12,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -85,7 +86,7 @@ size_t task_count(AppInstanceId app_instance_id) { } // namespace error_t app_resources_register(AppInstanceId app_instance_id) { - auto* tracker = new (std::nothrow) Tracker(); + auto* tracker = tt::newObject(); if (tracker == nullptr) { return ERROR_OUT_OF_MEMORY; } @@ -165,7 +166,7 @@ void app_resources_release(AppInstanceId app_instance_id) { static_cast(tracker->allocations.size())); } - delete tracker; + tt::deleteObject(tracker); } extern "C" { diff --git a/Modules/binfont-module/include/binfont/generator.h b/Modules/binfont-module/include/binfont/generator.h index 713d6f210..92e6b22e4 100644 --- a/Modules/binfont-module/include/binfont/generator.h +++ b/Modules/binfont-module/include/binfont/generator.h @@ -11,8 +11,12 @@ extern "C" { #endif typedef struct BinFontGeneratorConfig { - /** Path to a TrueType (.ttf) file */ + /** Path to a TrueType (.ttf) file. Ignored when ttf_data is set */ const char* ttf_path; + /** A TrueType (.ttf) file in memory that must stay valid during the call, or NULL to read ttf_path */ + const uint8_t* ttf_data; + /** Size of ttf_data in bytes */ + size_t ttf_size; /** Font size in pixels (em height) */ uint16_t size; /** Bits per pixel: 1, 2, 3 or 4 */ diff --git a/Modules/binfont-module/source/generator.cpp b/Modules/binfont-module/source/generator.cpp index d2339e0dc..7dd2e65db 100644 --- a/Modules/binfont-module/source/generator.cpp +++ b/Modules/binfont-module/source/generator.cpp @@ -538,20 +538,26 @@ error_t generate(const BinFontGeneratorConfig* config, const uint32_t* codepoint extern "C" { error_t binfont_generate(const BinFontGeneratorConfig* config, uint8_t** out_data, size_t* out_size) { - if (config->ttf_path == nullptr || config->size == 0 || config->bpp < 1 || config->bpp > 4 || (config->codepoints == nullptr) != (config->codepoint_count == 0)) { + const bool has_ttf_data = config->ttf_data != nullptr && config->ttf_size > 0; + if ((config->ttf_path == nullptr && !has_ttf_data) || config->size == 0 || config->bpp < 1 || config->bpp > 4 || (config->codepoints == nullptr) != (config->codepoint_count == 0)) { return ERROR_INVALID_ARGUMENT; } - size_t ttf_size = 0; - uint8_t* ttf = read_file(config->ttf_path, ttf_size); - if (ttf == nullptr) { - return ERROR_NOT_FOUND; + size_t ttf_size = config->ttf_size; + const uint8_t* ttf = config->ttf_data; + uint8_t* owned_ttf = nullptr; + if (!has_ttf_data) { + owned_ttf = read_file(config->ttf_path, ttf_size); + if (owned_ttf == nullptr) { + return ERROR_NOT_FOUND; + } + ttf = owned_ttf; } stbtt_fontinfo info; const int font_offset = stbtt_GetFontOffsetForIndex(ttf, 0); if (font_offset < 0 || stbtt_InitFont(&info, ttf, font_offset) == 0) { - memory_free(ttf); + memory_free(owned_ttf); return ERROR_NOT_SUPPORTED; } AllocationState allocation = { .failed = false }; @@ -562,7 +568,7 @@ error_t binfont_generate(const BinFontGeneratorConfig* config, uint8_t** out_dat CodepointList all_codepoints; if (codepoints == nullptr) { if (!collect_codepoints(info, ttf_size, all_codepoints)) { - memory_free(ttf); + memory_free(owned_ttf); return all_codepoints.hasFailed() ? ERROR_OUT_OF_MEMORY : ERROR_NOT_SUPPORTED; } codepoints = all_codepoints.getData(); @@ -571,14 +577,14 @@ error_t binfont_generate(const BinFontGeneratorConfig* config, uint8_t** out_dat auto* glyphs = static_cast(memory_alloc(codepoint_count * sizeof(GlyphInfo))); if (glyphs == nullptr) { - memory_free(ttf); + memory_free(owned_ttf); return ERROR_OUT_OF_MEMORY; } ByteBuffer out; const error_t error = generate(config, codepoints, codepoint_count, info, glyphs, out); memory_free(glyphs); - memory_free(ttf); + memory_free(owned_ttf); if (error != ERROR_NONE) { return error; } diff --git a/Modules/http-module/include/http/download.h b/Modules/http-module/include/http/download.h index 36fa932ca..f5d7572a8 100644 --- a/Modules/http-module/include/http/download.h +++ b/Modules/http-module/include/http/download.h @@ -106,10 +106,10 @@ error_t http_download_poll(struct HttpDownloadSubscription* sub, struct HttpDown /** * Starts a download on its own dedicated task: issues a GET request for @a url, verifying the - * server against the PEM certificate at @a cert_path, and writes the response body to + * server against the PEM certificate @a cert_pem, and writes the response body to * @a target_path. * @param[in] url the URL to download - * @param[in] cert_path path to a PEM certificate file used to verify the server + * @param[in] cert_pem PEM certificate text used to verify the server, copied before this returns * @param[in] target_path path to write the downloaded file to * @param[in] sub subscription to notify on completion, already registered via * http_download_subscribe(); must stay alive (and stationary) at least until @@ -120,7 +120,7 @@ error_t http_download_poll(struct HttpDownloadSubscription* sub, struct HttpDown */ error_t http_download_start( const char* url, - const char* cert_path, + const char* cert_pem, const char* target_path, struct HttpDownloadSubscription* sub ); diff --git a/Modules/http-module/private/http/private/download.h b/Modules/http-module/private/http/private/download.h index 9301c76a0..a32933676 100644 --- a/Modules/http-module/private/http/private/download.h +++ b/Modules/http-module/private/http/private/download.h @@ -61,7 +61,7 @@ inline bool http_download_is_cancelled(HttpDownloadLink* link) { /** Implemented in download_esp.cpp (ESP_PLATFORM) or download_mock.cpp (otherwise). */ HttpDownloadEvent http_download_run( const std::string& url, - const std::string& certPath, + const std::string& certPem, const std::string& targetPath, HttpDownloadLink* link ); diff --git a/Modules/http-module/source/download.cpp b/Modules/http-module/source/download.cpp index 0bf16a8c1..9fa73330e 100644 --- a/Modules/http-module/source/download.cpp +++ b/Modules/http-module/source/download.cpp @@ -16,7 +16,7 @@ constexpr size_t DOWNLOAD_TASK_STACK_DEPTH = 5120; struct DownloadContext { std::string url; - std::string certPath; + std::string certPem; std::string targetPath; HttpDownloadSubscription* subscription; HttpDownloadLink* link; @@ -43,7 +43,7 @@ void download_task_main(void* raw_context) { auto* context = static_cast(raw_context); LOG_I(TAG, "Downloading %s to %s", context->url.c_str(), context->targetPath.c_str()); - auto event = http_download_run(context->url, context->certPath, context->targetPath, context->link); + auto event = http_download_run(context->url, context->certPem, context->targetPath, context->link); if (event.type == HTTP_DOWNLOAD_EVENT_ERROR) { LOG_E(TAG, "Download of %s failed: %s", context->url.c_str(), event.error.message); } else if (event.type == HTTP_DOWNLOAD_EVENT_CANCELLED) { @@ -133,7 +133,7 @@ error_t http_download_poll(HttpDownloadSubscription* sub, HttpDownloadEvent* out error_t http_download_start( const char* url, - const char* cert_path, + const char* cert_pem, const char* target_path, HttpDownloadSubscription* sub ) { @@ -144,7 +144,7 @@ error_t http_download_start( auto* context = new(std::nothrow) DownloadContext { .url = url, - .certPath = cert_path, + .certPem = cert_pem, .targetPath = target_path, .subscription = sub, .link = link diff --git a/Modules/http-module/source/download_esp.cpp b/Modules/http-module/source/download_esp.cpp index b69dda4fa..e48260d6c 100644 --- a/Modules/http-module/source/download_esp.cpp +++ b/Modules/http-module/source/download_esp.cpp @@ -50,44 +50,18 @@ public: bool isComplete() const { return esp_http_client_is_complete_data_received(client); } }; -// esp_http_client_config_t::cert_pem needs a NUL-terminated buffer. -bool read_certificate(const std::string& certPath, std::string& outCertificate) { - auto* file = fopen(certPath.c_str(), "rb"); - if (file == nullptr) { - return false; - } - - fseek(file, 0, SEEK_END); - long size = ftell(file); - fseek(file, 0, SEEK_SET); - if (size <= 0) { - fclose(file); - return false; - } - - outCertificate.resize(static_cast(size)); - size_t read_bytes = fread(outCertificate.data(), 1, static_cast(size), file); - fclose(file); - return read_bytes == static_cast(size); } -} - -HttpDownloadEvent http_download_run(const std::string& url, const std::string& certPath, const std::string& targetPath, HttpDownloadLink* link) { +HttpDownloadEvent http_download_run(const std::string& url, const std::string& certPem, const std::string& targetPath, HttpDownloadLink* link) { if (http_download_is_cancelled(link)) { return http_download_make_cancelled_event(); } - std::string certificate; - if (!read_certificate(certPath, certificate)) { - return http_download_make_error_event("Failed to read certificate file"); - } - esp_http_client_config_t config {}; config.url = url.c_str(); config.auth_type = HTTP_AUTH_TYPE_NONE; - config.cert_pem = certificate.c_str(); - config.cert_len = certificate.size() + 1; + config.cert_pem = certPem.c_str(); + config.cert_len = certPem.size() + 1; config.tls_version = ESP_HTTP_CLIENT_TLS_VER_TLS_1_2; config.method = HTTP_METHOD_GET; config.timeout_ms = 5000; diff --git a/Modules/http-module/source/download_posix.cpp b/Modules/http-module/source/download_posix.cpp index 02f4116ce..a04e88196 100644 --- a/Modules/http-module/source/download_posix.cpp +++ b/Modules/http-module/source/download_posix.cpp @@ -83,26 +83,9 @@ public: mbedtls_x509_crt_free(&ca); } - bool loadCertificate(const std::string& certPath) { - auto* file = fopen(certPath.c_str(), "rb"); - if (file == nullptr) { - return false; - } - fseek(file, 0, SEEK_END); - const long size = ftell(file); - fseek(file, 0, SEEK_SET); - if (size <= 0) { - fclose(file); - return false; - } + bool loadCertificate(const std::string& certPem) { // A PEM certificate is parsed as a NUL-terminated string - std::string certificate(static_cast(size) + 1, '\0'); - const size_t readBytes = fread(certificate.data(), 1, static_cast(size), file); - fclose(file); - if (readBytes != static_cast(size)) { - return false; - } - return mbedtls_x509_crt_parse(&ca, reinterpret_cast(certificate.data()), certificate.size()) == 0; + return mbedtls_x509_crt_parse(&ca, reinterpret_cast(certPem.c_str()), certPem.size() + 1) == 0; } bool connectSocket(const HttpUrl& url) { @@ -259,7 +242,7 @@ public: } -HttpDownloadEvent http_download_run(const std::string& url, const std::string& certPath, const std::string& targetPath, HttpDownloadLink* link) { +HttpDownloadEvent http_download_run(const std::string& url, const std::string& certPem, const std::string& targetPath, HttpDownloadLink* link) { if (http_download_is_cancelled(link)) { return http_download_make_cancelled_event(); } @@ -273,8 +256,8 @@ HttpDownloadEvent http_download_run(const std::string& url, const std::string& c } TlsConnection connection; - if (!connection.loadCertificate(certPath)) { - return http_download_make_error_event("Failed to read certificate file"); + if (!connection.loadCertificate(certPem)) { + return http_download_make_error_event("Failed to parse certificate"); } if (!connection.connectSocket(parsedUrl) || !connection.handshake(parsedUrl, link)) { return http_download_make_error_event("Failed to open connection"); diff --git a/Modules/http-module/tests/source/download_test.cpp b/Modules/http-module/tests/source/download_test.cpp index c0fb311ec..e16db4726 100644 --- a/Modules/http-module/tests/source/download_test.cpp +++ b/Modules/http-module/tests/source/download_test.cpp @@ -4,7 +4,7 @@ TEST_CASE("http_download_start requires a subscribed subscription") { HttpDownloadSubscription sub {}; - CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_INVALID_ARGUMENT); + CHECK_EQ(http_download_start("https://example.com/file", "not a certificate", "/target.bin", &sub), ERROR_INVALID_ARGUMENT); } TEST_CASE("http_download_poll times out before the download finishes") { @@ -21,13 +21,13 @@ TEST_CASE("http_download_poll times out before the download finishes") { task_event_group_destruct(&event_group); } -TEST_CASE("http_download_start fails the download when the certificate can't be read") { +TEST_CASE("http_download_start fails the download when the certificate can't be parsed") { TaskEventGroup event_group {}; task_event_group_construct(&event_group); HttpDownloadSubscription sub {}; CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); - CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE); + CHECK_EQ(http_download_start("https://example.com/file", "not a certificate", "/target.bin", &sub), ERROR_NONE); CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE); @@ -55,7 +55,7 @@ TEST_CASE("http_download_unsubscribe is safe to call right after start, without HttpDownloadSubscription sub {}; CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); - CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE); + CHECK_EQ(http_download_start("https://example.com/file", "not a certificate", "/target.bin", &sub), ERROR_NONE); // No cancel, no poll for the terminal event - just unsubscribe and tear down immediately, // racing whatever the download task is doing. @@ -77,7 +77,7 @@ TEST_CASE("http_download_cancel before start makes the download finish as cancel HttpDownloadSubscription sub {}; CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE); CHECK_EQ(http_download_cancel(&sub), ERROR_NONE); - CHECK_EQ(http_download_start("https://example.com/file", "/nonexistent/cert.pem", "/target.bin", &sub), ERROR_NONE); + CHECK_EQ(http_download_start("https://example.com/file", "not a certificate", "/target.bin", &sub), ERROR_NONE); CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE); diff --git a/Modules/location-module/include/location/module.h b/Modules/location-module/include/location/module.h index 458154739..0bfd75517 100644 --- a/Modules/location-module/include/location/module.h +++ b/Modules/location-module/include/location/module.h @@ -9,6 +9,12 @@ extern "C" { extern struct Module location_module; +/** + * @brief Sets the root certificate that verifies the IP lookup server. + * @param[in] certificate_pem PEM text that must stay valid forever + */ +void location_configure_certificate(const char* certificate_pem); + #ifdef __cplusplus } #endif diff --git a/Modules/location-module/source/ip_provider.cpp b/Modules/location-module/source/ip_provider.cpp index d6f48c8c7..b63c17e37 100644 --- a/Modules/location-module/source/ip_provider.cpp +++ b/Modules/location-module/source/ip_provider.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #include @@ -26,11 +27,16 @@ #define TAG "location_ip" +// The root certificate of the API's certificate chain +static const char* certificate_pem = ""; + +void location_configure_certificate(const char* pem) { + certificate_pem = pem; +} + namespace { constexpr auto* API_URL = "https://json.geoiplookup.io/"; -// The root certificate of the API's certificate chain -constexpr auto* API_CERTIFICATE = "certificates/GTS_Root_R4.pem"; constexpr uint64_t RETRY_INTERVAL_MS = 60 * 1000; // Bounds the request rate when the network connects repeatedly constexpr uint64_t MIN_INTERVAL_MS = 10 * 1000; @@ -81,17 +87,6 @@ uint64_t getUptimeMs() { return get_micros_since_boot() / 1000; } -bool getCertificatePath(char* out_path, size_t out_size) { - // The system file system is mounted at "/system" on ESP32, but it's a folder elsewhere on the simulator - FileSystem* system = file_system_find_by_name("system"); - char system_path[FILE_MAX_PATH_STRING_LENGTH]; - if (system == nullptr || file_system_get_path(system, system_path, sizeof(system_path)) != ERROR_NONE) { - return false; - } - const int written = snprintf(out_path, out_size, "%s/%s", system_path, API_CERTIFICATE); - return written > 0 && static_cast(written) < out_size; -} - /** * Downloads the lookup result into a temp file named @a file_name and parses it * @param[in] group the event group to wait on, with a free bit for the download @@ -99,10 +94,9 @@ bool getCertificatePath(char* out_path, size_t out_size) { * @return false when the lookup failed or was cancelled */ bool fetchLookup(const char* file_name, TaskEventGroup* group, const std::atomic& running, IpLookupResponse* out) { - char certificate_path[FILE_MAX_PATH_STRING_LENGTH]; char temp_path[FILE_MAX_PATH_STRING_LENGTH]; char target_path[FILE_MAX_PATH_STRING_LENGTH]; - if (!getCertificatePath(certificate_path, sizeof(certificate_path)) || paths_get_temp_path(temp_path, sizeof(temp_path)) != ERROR_NONE) { + if (paths_get_temp_path(temp_path, sizeof(temp_path)) != ERROR_NONE) { return false; } if (!directory_exists(temp_path)) { @@ -120,7 +114,7 @@ bool fetchLookup(const char* file_name, TaskEventGroup* group, const std::atomic if (http_download_subscribe(&subscription, group) != ERROR_NONE) { return false; } - if (http_download_start(API_URL, certificate_path, target_path, &subscription) != ERROR_NONE) { + if (http_download_start(API_URL, certificate_pem, target_path, &subscription) != ERROR_NONE) { http_download_unsubscribe(&subscription); return false; } diff --git a/Modules/lvgl-module/README.md b/Modules/lvgl-module/README.md index 2273e5747..0d7c32aaf 100644 --- a/Modules/lvgl-module/README.md +++ b/Modules/lvgl-module/README.md @@ -35,7 +35,11 @@ Icons are provided by the **Material Symbols** font, divided into three usage-sp All font sizes derive from one default text size: `font.defaultSize` in `device.properties` (`CONFIG_TT_FONT_DEFAULT_SIZE`, 14 on the simulator). Tactility calculates the other text and icon sizes from it in `Tactility/Source/lvgl/FontSizes.cpp`. -Text fonts are rasterized on device at startup from `Data/system/fonts/AdwaitaSans.ttf` and cached as `.bin` files in the data path. +The fonts of the device's default size are generated at build time by `Buildscripts/generate-fonts.py` (using a host built tool, `Buildscripts/BinfontTool`) and embedded in the firmware as `generated_fonts.c`. +They are opened in place and take no RAM. Other sizes and custom fonts use the cache below, which needs at least 2 MB of total memory: without it only the embedded fonts are used and the Appearance app hides the font settings. +When there's no host C++ compiler at build time, no fonts are embedded and the cache is used for all of them. + +Text fonts that aren't embedded are rasterized on device at startup from the embedded `Data/system/fonts/AdwaitaSans.ttf` and cached as `.bin` files in the data path. `Tactility/Fonts/generate.py` creates that TTF subset from `Tactility/Fonts/AdwaitaSans-Regular.ttf` (requires `pip install fonttools`). Icon fonts are rasterized on device at startup from `Data/system/fonts/MaterialSymbolsRounded.ttf` (a subset with only the used icons) and cached as `.bin` files in the data path, so any icon size works. diff --git a/Modules/lvgl-module/include/lvgl/lvgl.h b/Modules/lvgl-module/include/lvgl/lvgl.h index 41b7f059e..b083a0599 100644 --- a/Modules/lvgl-module/include/lvgl/lvgl.h +++ b/Modules/lvgl-module/include/lvgl/lvgl.h @@ -42,12 +42,9 @@ bool lvgl_try_lock(uint32_t timeout); void lvgl_unlock(void); /** - * @brief Runs a function with the LVGL lock held, on the LVGL task when the calling task can't do the work itself. + * @brief Runs a function with the LVGL lock held, on the calling task. * - * Code that creates widgets can read files, e.g. to decode an image. Reading from flash needs a stack in - * internal RAM, which tasks with a stack in external RAM don't have. Such tasks have the function run on the LVGL - * task, and wait for it to finish. Other tasks run it themselves. - * Don't call this while holding a lock that LVGL callbacks can wait for. + * The function must not read files. The LVGL task can have its stack in external RAM. * @param[in] function the function to run * @param[in] argument passed to @a function */ diff --git a/Modules/lvgl-module/include/lvgl/ui_assets.h b/Modules/lvgl-module/include/lvgl/ui_assets.h new file mode 100644 index 000000000..1605bedb5 --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/ui_assets.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Amount of spinner images. The sizes in pixels are 12, 16, 20, 24, 32, 36 and 40 */ +#define LVGL_UI_SPINNER_COUNT 7 + +/** Amount of cursor images. The sizes in pixels are 12, 16, 20, 24, 32 and 40 */ +#define LVGL_UI_CURSOR_COUNT 6 + +/** + * @brief PNG images for the spinner and the mouse cursor, ordered by size. + * The arrays have LVGL_UI_SPINNER_COUNT and LVGL_UI_CURSOR_COUNT entries and must stay valid forever. + */ +struct LvglUiAssets { + const lv_image_dsc_t* spinner; + const lv_image_dsc_t* cursor; +}; + +/** + * @brief Sets the images that the spinner and cursor getters pick from. + * @param[in] assets the images, copied by reference + */ +void lvgl_ui_assets_configure(const struct LvglUiAssets* assets); + +/** + * @brief Gets the spinner image that fits the default text size best. + * @return an image for lv_image_set_src(), or NULL when not configured + */ +const lv_image_dsc_t* lvgl_get_spinner_image(void); + +/** + * @brief Gets the mouse cursor image that fits the default text size best. + * @return an image for lv_image_set_src(), or NULL when not configured + */ +const lv_image_dsc_t* lvgl_get_cursor_image(void); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-module/source/arch/lvgl_esp32.c b/Modules/lvgl-module/source/arch/lvgl_esp32.c index 7f7d662e5..78dfa1a4b 100644 --- a/Modules/lvgl-module/source/arch/lvgl_esp32.c +++ b/Modules/lvgl-module/source/arch/lvgl_esp32.c @@ -43,54 +43,19 @@ void lvgl_unlock(void) { lvgl_port_unlock(); } -struct RunOnTaskRequest { - void (*function)(void*); - void* argument; - SemaphoreHandle_t done; -}; - -// Runs inside the LVGL task, which holds the LVGL lock -static void run_on_task_callback(void* data) { - struct RunOnTaskRequest* request = data; - request->function(request->argument); - xSemaphoreGive(request->done); -} - void lvgl_run_on_task(void (*function)(void*), void* argument) { - const int stack_marker = 0; - const bool is_lvgl_task = strcmp(pcTaskGetName(NULL), "taskLVGL") == 0; - SemaphoreHandle_t done = NULL; - // A stack in internal RAM can read files, and the LVGL task itself can always run the function - if (initialized && !is_lvgl_task && !esp_ptr_internal(&stack_marker)) { - done = xSemaphoreCreateBinary(); - } - if (done == NULL) { - lvgl_lock(); - function(argument); - lvgl_unlock(); - return; - } - - struct RunOnTaskRequest request = { .function = function, .argument = argument, .done = done }; lvgl_lock(); - lv_async_call(run_on_task_callback, &request); + function(argument); lvgl_unlock(); - // The LVGL task may be asleep for a while otherwise - lvgl_port_task_wake(LVGL_PORT_EVENT_USER, NULL); - while (xSemaphoreTake(done, pdMS_TO_TICKS(1000)) != pdTRUE) { - if (!initialized) { - // LVGL stopped before it ran the function - lv_async_call_cancel(run_on_task_callback, &request); - break; - } - } - vSemaphoreDelete(done); } error_t lvgl_arch_start() { + // The LVGL task must not read files: its stack in external RAM can't be used while the flash cache is off + const bool has_external_ram = heap_caps_get_total_size(MALLOC_CAP_SPIRAM) > 0; const lvgl_port_cfg_t lvgl_cfg = { .task_priority = lvgl_module_config.task_priority, .task_stack = lvgl_module_config.task_stack_size, + .task_stack_caps = has_external_ram ? (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) : (MALLOC_CAP_INTERNAL | MALLOC_CAP_DEFAULT), .task_affinity = lvgl_module_config.task_affinity, .task_max_sleep_ms = 500, .timer_period_ms = 5 diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index 38f999c66..5b11f2d8a 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -155,6 +156,11 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_qr_set_data), DEFINE_MODULE_SYMBOL(lvgl_qr_set_colors), + // ui_assets + DEFINE_MODULE_SYMBOL(lvgl_ui_assets_configure), + DEFINE_MODULE_SYMBOL(lvgl_get_spinner_image), + DEFINE_MODULE_SYMBOL(lvgl_get_cursor_image), + // lvgl_spinner DEFINE_MODULE_SYMBOL(lvgl_spinner_create), // lvgl_toolbar diff --git a/Modules/lvgl-module/source/ui_assets.c b/Modules/lvgl-module/source/ui_assets.c new file mode 100644 index 000000000..3d16bbf87 --- /dev/null +++ b/Modules/lvgl-module/source/ui_assets.c @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include + +#include + +// Must match the sizes in Buildscripts/generate-ui-assets.py +static const int32_t spinner_sizes[LVGL_UI_SPINNER_COUNT] = { 12, 16, 20, 24, 32, 36, 40 }; +static const int32_t cursor_sizes[LVGL_UI_CURSOR_COUNT] = { 12, 16, 20, 24, 32, 40 }; + +static struct LvglUiAssets ui_assets; + +void lvgl_ui_assets_configure(const struct LvglUiAssets* assets) { + ui_assets = *assets; +} + +/** @return the index of the size that is the closest to target_size */ +static int nearest_size(const int32_t* sizes, int count, int32_t target_size) { + int nearest = 0; + for (int i = 1; i < count; i++) { + if (abs(sizes[i] - target_size) < abs(sizes[nearest] - target_size)) { + nearest = i; + } + } + return nearest; +} + +const lv_image_dsc_t* lvgl_get_spinner_image(void) { + if (ui_assets.spinner == NULL) { + return NULL; + } + const int32_t text_size = (int32_t)lvgl_get_text_font_height(FONT_SIZE_DEFAULT); + return &ui_assets.spinner[nearest_size(spinner_sizes, LVGL_UI_SPINNER_COUNT, text_size * 3 / 2)]; +} + +const lv_image_dsc_t* lvgl_get_cursor_image(void) { + if (ui_assets.cursor == NULL) { + return NULL; + } + const int32_t text_size = (int32_t)lvgl_get_text_font_height(FONT_SIZE_DEFAULT); + return &ui_assets.cursor[nearest_size(cursor_sizes, LVGL_UI_CURSOR_COUNT, text_size * 3 / 2)]; +} diff --git a/Modules/lvgl-module/source/widgets/spinner.cpp b/Modules/lvgl-module/source/widgets/spinner.cpp index 3e4e79dd1..f2b430a86 100644 --- a/Modules/lvgl-module/source/widgets/spinner.cpp +++ b/Modules/lvgl-module/source/widgets/spinner.cpp @@ -1,11 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 #define LV_USE_PRIVATE_API 1 // For actual lv_obj_t declaration +#include #include -// TODO: Don't depend on asset from a different project -#define SPINNER_ASSET "A:/system/spinner.png" - static void spinner_constructor(const lv_obj_class_t* object_class, lv_obj_t* object); static const lv_obj_class_t tt_spinner_class = { @@ -27,7 +25,7 @@ lv_obj_t* lvgl_spinner_create(lv_obj_t* parent) { lv_obj_t* obj = lv_obj_class_create_obj(&tt_spinner_class, parent); lv_obj_class_init_obj(obj); - lv_image_set_src(obj, SPINNER_ASSET); + lv_image_set_src(obj, lvgl_get_spinner_image()); return obj; } diff --git a/Modules/lvgl-window-manager-module/source/window_manager.cpp b/Modules/lvgl-window-manager-module/source/window_manager.cpp index e2ec46920..814bdf022 100644 --- a/Modules/lvgl-window-manager-module/source/window_manager.cpp +++ b/Modules/lvgl-window-manager-module/source/window_manager.cpp @@ -172,7 +172,6 @@ lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_wi if (content == nullptr) { return nullptr; } - // The widgets can load files, e.g. images, so tasks without a stack for that have the LVGL task build them BuildRequest request { content, create_widgets, user_data }; lvgl_run_on_task(build_window_widget_locked, &request); return request.widget; diff --git a/Modules/service-module/CMakeLists.txt b/Modules/service-module/CMakeLists.txt index 11e419d2a..05c993777 100644 --- a/Modules/service-module/CMakeLists.txt +++ b/Modules/service-module/CMakeLists.txt @@ -8,4 +8,5 @@ tactility_add_module(service-module SRCS ${SOURCE_FILES} INCLUDE_DIRS include/ REQUIRES TactilityKernel + PRIV_REQUIRES TactilityKernelCpp ) diff --git a/Modules/service-module/source/manager.cpp b/Modules/service-module/source/manager.cpp index e13c4f0c9..c676ae3c5 100644 --- a/Modules/service-module/source/manager.cpp +++ b/Modules/service-module/source/manager.cpp @@ -2,14 +2,15 @@ #include +#include +#include +#include + +#include #include #include #include -#include -#include -#include - #define TAG "service_registration" // Defined in service_instance.cpp. Internal-only: lets the registry drive state @@ -17,7 +18,7 @@ extern "C" void service_instance_set_state(ServiceInstance* instance, ServiceState state); struct ManifestLedger { - std::unordered_map manifests; + tt::std::unordered_map manifests; Mutex mutex {}; ManifestLedger() { mutex_construct(&mutex); } @@ -25,7 +26,7 @@ struct ManifestLedger { }; struct InstanceLedger { - std::unordered_map instances; + tt::std::unordered_map instances; Mutex mutex {}; InstanceLedger() { mutex_construct(&mutex); } @@ -33,13 +34,15 @@ struct InstanceLedger { }; static ManifestLedger& get_manifest_ledger() { - static ManifestLedger ledger; - return ledger; + static ManifestLedger* ledger = tt::newObject(); + check(ledger != nullptr); + return *ledger; } static InstanceLedger& get_instance_ledger() { - static InstanceLedger ledger; - return ledger; + static InstanceLedger* ledger = tt::newObject(); + check(ledger != nullptr); + return *ledger; } #define manifest_ledger get_manifest_ledger() @@ -100,7 +103,7 @@ error_t service_manager_start(const char* id) { return ERROR_INVALID_STATE; } - auto* instance = new(std::nothrow) ServiceInstance { .manifest = nullptr, .data = nullptr, .internal = nullptr }; + auto* instance = tt::newObject(ServiceInstance { .manifest = nullptr, .data = nullptr, .internal = nullptr }); if (instance == nullptr) { mutex_unlock(&instance_ledger.mutex); return ERROR_OUT_OF_MEMORY; @@ -109,7 +112,7 @@ error_t service_manager_start(const char* id) { error_t error = service_instance_construct(instance, manifest); if (error != ERROR_NONE) { mutex_unlock(&instance_ledger.mutex); - delete instance; + tt::deleteObject(instance); return error; } @@ -137,7 +140,7 @@ error_t service_manager_start(const char* id) { mutex_unlock(&instance_ledger.mutex); service_instance_destruct(instance); - delete instance; + tt::deleteObject(instance); return ERROR_RESOURCE; } @@ -167,7 +170,7 @@ error_t service_manager_stop(const char* id) { mutex_unlock(&instance_ledger.mutex); service_instance_destruct(instance); - delete instance; + tt::deleteObject(instance); ServiceStoppedEvent stop_event = { .id = id }; system_event_emit(KERNEL_EVENT_SERVICE_STOPPED, &stop_event, sizeof(stop_event)); diff --git a/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h b/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h index 695caad48..ddb660468 100644 --- a/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h +++ b/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h @@ -18,6 +18,8 @@ extern "C" { * need (WIFI_MODE_STA or WIFI_MODE_AP); the radio is brought up on the first acquire (merging into * WIFI_MODE_APSTA if another owner is already active) and only torn down once every owner has * released. Several owners can hold the same mode bit, and the bit stays active until its last owner released. + * The default station netif is created before the radio starts when the station bit is acquired, and destroyed with the last + * station owner. Creating it after the radio started leaves it without an IP address. * * @param mode_bit WIFI_MODE_STA or WIFI_MODE_AP (not WIFI_MODE_APSTA/WIFI_MODE_NULL). * @retval true the mode bit is now active (radio is initialized, started, and its current mode diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp index d06de22d5..cd1234e29 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp @@ -51,7 +51,6 @@ struct Esp32WifiCtx { wifi_ap_record_t scanResults[WIFI_SCAN_RECORD_LIMIT] = {}; uint16_t scanResultCount = 0; - esp_netif_t* netif = nullptr; esp_event_handler_instance_t wifiEventHandler = nullptr; esp_event_handler_instance_t ipEventHandler = nullptr; @@ -80,6 +79,9 @@ wifi_mode_t g_radioMode = WIFI_MODE_NULL; // Owners per mode bit, as several owners can share one: a bit leaves the mode when its last owner released int g_staOwnerCount = 0; int g_apOwnerCount = 0; +// The station netif exists for as long as the station bit is active, and it's created before the radio starts, +// so the netif always handles the station's start event. A netif that is created after the radio started doesn't get an IP address. +esp_netif_t* g_staNetif = nullptr; // Netifs that were started and not stopped or destroyed since std::mutex g_startedNetifMutex; @@ -111,6 +113,15 @@ void mark_netif_stopped(const void* netif) { } } +// Must be called with the radio mutex held +void destroy_station_netif_if(bool destroy) { + if (destroy && g_staNetif != nullptr) { + esp_wifi_clear_default_wifi_driver_and_handlers(g_staNetif); + esp_netif_destroy(g_staNetif); + g_staNetif = nullptr; + } +} + int& modeOwnerCount(wifi_mode_t mode_bit) { return mode_bit == WIFI_MODE_AP ? g_apOwnerCount : g_staOwnerCount; } @@ -229,22 +240,10 @@ void on_wifi_or_ip_event(void* arg, esp_event_base_t event_base, int32_t event_i // ---- Work, always run inline on the caller (see note at the top) ---- error_t bring_up_wifi(Esp32WifiCtx* ctx) { - ctx->netif = esp_netif_create_default_wifi_sta(); - if (ctx->netif == nullptr) { - LOG_E(TAG, "Failed to create default STA netif"); - return ERROR_RESOURCE; - } - - // Another owner (e.g. ESP-NOW) can have the radio running already. Its WIFI_EVENT_STA_START has been - // delivered by then, or is still on its way. If it was delivered, nothing starts the new netif, so it's started below. - bool radio_already_started = false; - // Warning: this is the memory-intensive operation. It uses over 100kB of - // RAM with default settings. - if (!esp32_wifi_radio_acquire_ex(WIFI_MODE_STA, &radio_already_started)) { + // RAM with default settings. It also creates the station netif. + if (!esp32_wifi_radio_acquire(WIFI_MODE_STA)) { LOG_E(TAG, "Failed to acquire WiFi radio for station mode"); - esp_netif_destroy(ctx->netif); - ctx->netif = nullptr; return ERROR_RESOURCE; } @@ -254,8 +253,6 @@ error_t bring_up_wifi(Esp32WifiCtx* ctx) { if (err != ESP_OK) { LOG_E(TAG, "Failed to register WIFI_EVENT handler: %s", esp_err_to_name(err)); esp32_wifi_radio_release(WIFI_MODE_STA); - esp_netif_destroy(ctx->netif); - ctx->netif = nullptr; return esp_err_to_error(err); } @@ -265,16 +262,9 @@ error_t bring_up_wifi(Esp32WifiCtx* ctx) { esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, ctx->wifiEventHandler); ctx->wifiEventHandler = nullptr; esp32_wifi_radio_release(WIFI_MODE_STA); - esp_netif_destroy(ctx->netif); - ctx->netif = nullptr; return esp_err_to_error(err); } - // A start event that is still on its way starts the netif too, which __wrap_esp_netif_action_start() ignores - if (radio_already_started) { - esp_netif_action_start(ctx->netif, WIFI_EVENT, WIFI_EVENT_STA_START, nullptr); - } - mutex_lock(&ctx->mutex); ctx->radioState = WIFI_RADIO_STATE_ON; mutex_unlock(&ctx->mutex); @@ -303,13 +293,6 @@ void bring_down_wifi(Esp32WifiCtx* ctx) { esp_wifi_disconnect(); } - // Detach netif from the internal WiFi event handlers before stopping, - // otherwise esp_netif_destroy() can race with esp_wifi_stop()'s own - // netif teardown (see esp32_ble/WifiEsp.cpp for the same issue). - if (ctx->netif != nullptr) { - esp_wifi_clear_default_wifi_driver_and_handlers(ctx->netif); - } - if (ctx->wifiEventHandler != nullptr) { esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, ctx->wifiEventHandler); ctx->wifiEventHandler = nullptr; @@ -319,13 +302,9 @@ void bring_down_wifi(Esp32WifiCtx* ctx) { ctx->ipEventHandler = nullptr; } + // The station netif is destroyed with the last station owner of the radio esp32_wifi_radio_release(WIFI_MODE_STA); - if (ctx->netif != nullptr) { - esp_netif_destroy(ctx->netif); - ctx->netif = nullptr; - } - mutex_lock(&ctx->mutex); ctx->radioState = WIFI_RADIO_STATE_OFF; mutex_unlock(&ctx->mutex); @@ -703,11 +682,25 @@ bool esp32_wifi_radio_acquire_ex(wifi_mode_t mode_bit, bool* was_started) { bool first_owner = g_radioOwnerCount == 0; *was_started = !first_owner; + + // Before the radio starts, so that the netif handles the station's start event + bool created_netif = false; + if (mode_bit == WIFI_MODE_STA && g_staNetif == nullptr) { + g_staNetif = esp_netif_create_default_wifi_sta(); + if (g_staNetif == nullptr) { + LOG_E(TAG, "esp32_wifi_radio_acquire: failed to create the station netif"); + mutex_unlock(&g_radioMutex); + return false; + } + created_netif = true; + } + if (first_owner) { wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT(); esp_err_t err = esp_wifi_init(&init_config); if (err != ESP_OK) { LOG_E(TAG, "esp32_wifi_radio_acquire: esp_wifi_init failed: %s", esp_err_to_name(err)); + destroy_station_netif_if(created_netif); mutex_unlock(&g_radioMutex); return false; } @@ -721,6 +714,7 @@ bool esp32_wifi_radio_acquire_ex(wifi_mode_t mode_bit, bool* was_started) { if (first_owner) { esp_wifi_deinit(); } + destroy_station_netif_if(created_netif); mutex_unlock(&g_radioMutex); return false; } @@ -731,6 +725,7 @@ bool esp32_wifi_radio_acquire_ex(wifi_mode_t mode_bit, bool* was_started) { LOG_E(TAG, "esp32_wifi_radio_acquire: esp_wifi_start failed: %s", esp_err_to_name(err)); esp_wifi_set_mode(WIFI_MODE_NULL); esp_wifi_deinit(); + destroy_station_netif_if(created_netif); mutex_unlock(&g_radioMutex); return false; } @@ -765,6 +760,13 @@ void esp32_wifi_radio_release(wifi_mode_t mode_bit) { } g_radioOwnerCount--; + // Detach the station netif from the internal WiFi event handlers before stopping, + // otherwise esp_netif_destroy() can race with esp_wifi_stop()'s own netif teardown + const bool release_netif = mode_bit == WIFI_MODE_STA && g_staOwnerCount <= 0 && g_staNetif != nullptr; + if (release_netif) { + esp_wifi_clear_default_wifi_driver_and_handlers(g_staNetif); + } + if (g_radioOwnerCount <= 0) { esp_wifi_stop(); esp_wifi_set_mode(WIFI_MODE_NULL); @@ -777,6 +779,11 @@ void esp32_wifi_radio_release(wifi_mode_t mode_bit) { esp_wifi_set_mode(g_radioMode); } + if (release_netif) { + esp_netif_destroy(g_staNetif); + g_staNetif = nullptr; + } + mutex_unlock(&g_radioMutex); } @@ -810,9 +817,10 @@ Driver esp32_wifi_driver = { .compatible = (const char*[]) { "espressif,esp32-wifi", nullptr }, .start_device = start_device, .stop_device = stop_device, - .api = (const void*)&esp32_wifi_api, + .probe = nullptr, + .api = static_cast(&esp32_wifi_api), .device_type = &WIFI_TYPE, - .internal = nullptr + .internal = nullptr, }; } // extern "C" diff --git a/Platforms/platform-esp32/source/partitions.cpp b/Platforms/platform-esp32/source/partitions.cpp index d90400fc6..9c08b680e 100644 --- a/Platforms/platform-esp32/source/partitions.cpp +++ b/Platforms/platform-esp32/source/partitions.cpp @@ -60,6 +60,7 @@ esp_err_t initNvsFlashSafely() { wl_handle_t data_wl_handle = WL_INVALID_HANDLE; +#ifdef CONFIG_TT_USER_DATA_LOCATION_INTERNAL size_t getSectorSize() { #if defined(CONFIG_FATFS_SECTOR_512) return 512; @@ -73,6 +74,7 @@ size_t getSectorSize() { #error Not implemented #endif } +#endif } // namespace @@ -84,6 +86,7 @@ error_t platform_esp32_start_partitions() { LOG_I(TAG, "Init partitions"); ESP_ERROR_CHECK(initNvsFlashSafely()); +#ifdef CONFIG_TT_USER_DATA_LOCATION_INTERNAL const esp_vfs_fat_mount_config_t mount_config = { .format_if_mount_failed = false, .max_files = 4, @@ -92,16 +95,6 @@ error_t platform_esp32_start_partitions() { .use_one_fat = true, }; - auto system_result = esp_vfs_fat_spiflash_mount_ro("/system", "system", &mount_config); - if (system_result != ESP_OK) { - LOG_E(TAG, "Failed to mount /system (%s)", esp_err_to_name(system_result)); - return ERROR_RESOURCE; - } - LOG_I(TAG, "Mounted /system"); - static auto system_fs_data = PartitionFsData("/system"); - file_system_add("system", &partition_fs_api, &system_fs_data); - -#ifdef CONFIG_TT_USER_DATA_LOCATION_INTERNAL auto data_result = esp_vfs_fat_spiflash_mount_rw_wl("/data", "data", &mount_config, &data_wl_handle); if (data_result != ESP_OK) { LOG_E(TAG, "Failed to mount /data (%s)", esp_err_to_name(data_result)); diff --git a/Platforms/platform-posix/source/partitions.cpp b/Platforms/platform-posix/source/partitions.cpp index 7b2a22fda..fac9d0250 100644 --- a/Platforms/platform-posix/source/partitions.cpp +++ b/Platforms/platform-posix/source/partitions.cpp @@ -21,7 +21,6 @@ struct DirectoryFsData { bool mounted; }; -DirectoryFsData system_fs_data; DirectoryFsData data_fs_data; DirectoryFsData root_fs_data; @@ -56,7 +55,7 @@ const FileSystemApi directory_fs_api = { }; // relative_path is resolved against the process' current working directory. -// The simulator is expected to run with Data/ as its working directory, so "data"/"system" here match the real on-disk folder names. +// The simulator is expected to run with Data/ as its working directory, so "data" here matches the real on-disk folder names. // realpath() converts it to a real absolute path once, up front. // Callers elsewhere (e.g. the shell) that ask for this filesystem's path via file_system_get_path() then // get something meaningful regardless of their own notion of "current directory", real or virtual. @@ -72,9 +71,8 @@ FileSystem* registerDirectoryFs(const char* name, const char* relativePath, Dire } // namespace error_t platform_posix_start_partitions() { - registerDirectoryFs("system", "system", &system_fs_data); registerDirectoryFs("data", "data", &data_fs_data); - // "data"/"system" live somewhere under here (wherever the process was launched from). U + // "data" lives somewhere under here (wherever the process was launched from). U // Unlike ESP32, POSIX genuinely has a real "/", so it gets registered too. registerDirectoryFs("root", "/", &root_fs_data); return ERROR_NONE; diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index 96a5a2095..5481cc4a9 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -166,7 +166,7 @@ endif () if (DEFINED ENV{ESP_IDF_VERSION}) idf_component_register( - SRCS ${SOURCE_FILES} "${GENERATED_DIR}/devicetree.c" + SRCS ${SOURCE_FILES} "${GENERATED_DIR}/devicetree.c" "${GENERATED_DIR}/generated_assets.c" "${GENERATED_DIR}/generated_fonts.c" INCLUDE_DIRS Include/ PRIV_INCLUDE_DIRS Private/ REQUIRES ${TACTILITY_REQUIRES_LIST} @@ -179,10 +179,10 @@ if (DEFINED ENV{ESP_IDF_VERSION}) endif () if (NOT DEFINED TACTILITY_SKIP_SPIFFS) - # Read-only - fatfs_create_rawflash_image(system "${CMAKE_CURRENT_SOURCE_DIR}/../Data/system" FLASH_IN_PROJECT PRESERVE_TIME) # Read-write (skipped when user data lives on the SD card instead of internal flash) if (NOT CONFIG_TT_USER_DATA_LOCATION_SD) + # The folder is not in the repository + file(MAKE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../Data/data") fatfs_create_spiflash_image(data "${CMAKE_CURRENT_SOURCE_DIR}/../Data/data" FLASH_IN_PROJECT PRESERVE_TIME) endif () endif () @@ -224,6 +224,46 @@ target_sources(${COMPONENT_LIB} PRIVATE "${GENERATED_DIR}/devicetree.c") target_include_directories(${COMPONENT_LIB} PRIVATE "${GENERATED_DIR}") add_dependencies(${COMPONENT_LIB} Generated) +# +# Assets: Data/system embedded as C byte arrays +# + +# Rewrites its outputs only when the content changes. The assets are the whole Data/system tree, +# so no fixed DEPENDS list is kept. +add_custom_target(GeneratedAssets ALL + COMMAND python "${CMAKE_SOURCE_DIR}/Buildscripts/generate-assets.py" + "${CMAKE_SOURCE_DIR}/Data/system" "${GENERATED_DIR}" + BYPRODUCTS "${GENERATED_DIR}/generated_assets.c" "${GENERATED_DIR}/generated_assets.h" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + COMMENT "Generating asset source files..." +) +set_source_files_properties("${GENERATED_DIR}/generated_assets.c" PROPERTIES GENERATED TRUE) +set_source_files_properties("${GENERATED_DIR}/generated_assets.h" PROPERTIES GENERATED TRUE) +target_sources(${COMPONENT_LIB} PRIVATE "${GENERATED_DIR}/generated_assets.c") +add_dependencies(${COMPONENT_LIB} GeneratedAssets) + +# +# Fonts: the binfont files of the device's default font size, embedded as C byte arrays +# + +# The fonts are rasterized by a tool that is built with the host compiler. Without one the table stays empty. +if (DEFINED ENV{ESP_IDF_VERSION}) + set(fonts_simulator 0) +else () + set(fonts_simulator 1) +endif () +add_custom_target(GeneratedFonts ALL + COMMAND python "${CMAKE_SOURCE_DIR}/Buildscripts/generate-fonts.py" + "${DEVICETREE_LOCATION}" ${fonts_simulator} "${GENERATED_DIR}" + BYPRODUCTS "${GENERATED_DIR}/generated_fonts.c" "${GENERATED_DIR}/generated_fonts.h" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + COMMENT "Generating font source files..." +) +set_source_files_properties("${GENERATED_DIR}/generated_fonts.c" PROPERTIES GENERATED TRUE) +set_source_files_properties("${GENERATED_DIR}/generated_fonts.h" PROPERTIES GENERATED TRUE) +target_sources(${COMPONENT_LIB} PRIVATE "${GENERATED_DIR}/generated_fonts.c") +add_dependencies(${COMPONENT_LIB} GeneratedFonts) + # # Fonts # diff --git a/Tactility/Include/Tactility/Assets.h b/Tactility/Include/Tactility/Assets.h deleted file mode 100644 index b0ef42eec..000000000 --- a/Tactility/Include/Tactility/Assets.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -/** - * Deprecated list of LVGL asset folder paths> - */ - -#define TT_ASSET_FOLDER "A:/system/" - -#define TT_ASSET(file) TT_ASSET_FOLDER file - -// UI -#define TT_ASSETS_UI_SPINNER TT_ASSET("spinner.png") -#define TT_ASSETS_UI_CURSOR TT_ASSET("cursor.png") diff --git a/Tactility/Include/Tactility/MainDispatcher.h b/Tactility/Include/Tactility/MainDispatcher.h index d7e9ccba7..8ad8a516e 100644 --- a/Tactility/Include/Tactility/MainDispatcher.h +++ b/Tactility/Include/Tactility/MainDispatcher.h @@ -1,14 +1,16 @@ #pragma once -#include +#include +#include #include namespace tt { /** - * A thin C++ wrapper around the TactilityKernel dispatcher, allowing - * std::function (and therefore capturing lambdas) to be dispatched. + * Runs functions in order on a task that is pinned to the main dispatcher's CPU core. + * The task is created when a function is queued and no task is running, and it ends when the queue is empty. + * Its stack is in internal RAM, so the functions can access flash. */ class [[deprecated("Use an app task's stack or a custom task with its own stack")]] MainDispatcher final { @@ -16,26 +18,42 @@ public: using Function = std::function; - explicit MainDispatcher(DispatcherHandle_t handle) : handle(handle) {} - - /** - * Queue a function to be consumed on the main task. - * @param[in] function the function to execute elsewhere - * @param[in] timeout lock acquisition timeout - * @return true if dispatching was successful (timeout not reached) - */ - bool dispatch(Function function, TickType_t timeout = portMAX_DELAY) const; - private: - DispatcherHandle_t handle; + struct Node; + + ::Mutex mutex; + Node* head = nullptr; + Node* tail = nullptr; + bool running = false; + // The task that has ended or is ending, as it can't free itself + ::Thread* stale = nullptr; + + static int32_t taskMain(void* context); + Node* popOrStop(); + /** Must be called with the mutex held and no task running */ + bool startTask(); + +public: + + MainDispatcher(); + ~MainDispatcher() { mutex_destruct(&mutex); } + MainDispatcher(const MainDispatcher&) = delete; + MainDispatcher& operator=(const MainDispatcher&) = delete; + + /** + * Queue a function to be run on the main dispatcher's task. + * @param[in] function the function to execute elsewhere + * @return true if the function was queued + */ + bool dispatch(Function function); }; -/** Provides access to the dispatcher that runs on the main task. +/** Provides access to the dispatcher that runs on the main dispatcher's task. * @warning This dispatcher is used for WiFi and might block for some time during WiFi connection. - * @return the dispatcher + * @return the one dispatcher instance */ [[deprecated("Use an app task's stack or a custom task with its own stack")]] -MainDispatcher getMainDispatcher(); +MainDispatcher& getMainDispatcher(); } // namespace tt diff --git a/Tactility/Include/Tactility/MountPoints.h b/Tactility/Include/Tactility/MountPoints.h index 809c8bd46..d53eab4a7 100644 --- a/Tactility/Include/Tactility/MountPoints.h +++ b/Tactility/Include/Tactility/MountPoints.h @@ -5,14 +5,6 @@ namespace tt::file { -constexpr auto* SYSTEM_PARTITION_NAME = "system"; - -#ifdef ESP_PLATFORM -constexpr auto* MOUNT_POINT_SYSTEM = "/system"; -#else -constexpr auto* MOUNT_POINT_SYSTEM = "system"; -#endif - constexpr auto* DATA_PARTITION_NAME = "data"; #ifdef ESP_PLATFORM diff --git a/Tactility/Include/Tactility/TactilityConfig.h b/Tactility/Include/Tactility/TactilityConfig.h index a18485241..33d008983 100644 --- a/Tactility/Include/Tactility/TactilityConfig.h +++ b/Tactility/Include/Tactility/TactilityConfig.h @@ -11,9 +11,3 @@ #else // Sim #define TT_FEATURE_SCREENSHOT_ENABLED true #endif - -namespace tt::config { - -constexpr auto SHOW_SYSTEM_PARTITION = false; - -} diff --git a/Tactility/Include/Tactility/service/Service.h b/Tactility/Include/Tactility/service/Service.h index 104c14ea3..9591d75b4 100644 --- a/Tactility/Include/Tactility/service/Service.h +++ b/Tactility/Include/Tactility/service/Service.h @@ -2,6 +2,9 @@ #include +#include +#include + #include namespace tt::service { @@ -22,7 +25,10 @@ public: virtual void onStop(ServiceContext& serviceContext) {} }; +/** Creates a service for ServiceManifest::createService. The result is released with deleteObject(), see ServiceManifest. */ template -void* create(const ::ServiceManifest* /*manifest*/) { return new std::shared_ptr(std::shared_ptr(new T)); } +void* create(const ::ServiceManifest* /*manifest*/) { + return newObject<::std::shared_ptr>(::std::allocate_shared(OptExternalAllocator())); +} } \ No newline at end of file diff --git a/Tactility/Include/Tactility/service/ServiceManifest.h b/Tactility/Include/Tactility/service/ServiceManifest.h index 61b61a9b8..1c3bdd8b4 100644 --- a/Tactility/Include/Tactility/service/ServiceManifest.h +++ b/Tactility/Include/Tactility/service/ServiceManifest.h @@ -16,7 +16,11 @@ struct ServiceManifest { /** The identifier by which the app is launched by the system and other apps. */ std::string id {}; - /** Create the instance of the app */ + /** + * Creates the instance of the service: use create(), or return a std::shared_ptr that is allocated with tt::newObject(). + * The registration releases the returned pointer with tt::deleteObject() when the service is removed, + * so a pointer from plain new is released incorrectly. + */ CreateService createService = nullptr; }; diff --git a/Tactility/Include/Tactility/service/webserver/AssetVersion.h b/Tactility/Include/Tactility/service/webserver/AssetVersion.h deleted file mode 100644 index e96d9881e..000000000 --- a/Tactility/Include/Tactility/service/webserver/AssetVersion.h +++ /dev/null @@ -1,72 +0,0 @@ -#pragma once - -#include - -namespace tt::service::webserver { - -/** - * @brief Asset version tracking for web server content synchronization - * - * Manages version.json files in both Data partition and SD card to ensure - * proper synchronization and recovery of web assets. - */ -struct AssetVersion { - uint32_t version; // Integer version number - - AssetVersion() : version(0) {} - explicit AssetVersion(uint32_t v) : version(v) {} -}; - -/** - * @brief Load asset version from Data partition - * @param[out] version The version structure to populate - * @return true if version was loaded successfully - */ -bool loadDataVersion(AssetVersion& version); - -/** - * @brief Load asset version from SD card - * @param[out] version The version structure to populate - * @return true if version was loaded successfully - */ -bool loadSdVersion(AssetVersion& version); - -/** - * @brief Save asset version to Data partition - * @param[in] version The version to save - * @return true if version was saved successfully - */ -bool saveDataVersion(const AssetVersion& version); - -/** - * @brief Save asset version to SD card - * @param[in] version The version to save - * @return true if version was saved successfully - */ -bool saveSdVersion(const AssetVersion& version); - -/** - * @brief Check if Data partition has any web assets - * @return true if Data partition contains web assets - */ -bool hasDataAssets(); - -/** - * @brief Check if SD card has any web assets - * @return true if SD card contains web assets backup - */ -bool hasSdAssets(); - -/** - * @brief Synchronize assets between Data partition and SD card based on version - * - * Logic: - * - If Data is empty: Copy from SD card (recovery mode) - * - If SD version > Data version: Copy SD -> Data (firmware update) - * - If Data version > SD version: Copy Data -> SD (backup customizations) - * - * @return true if sync completed successfully - */ -bool syncAssets(); - -} // namespace tt::service::webserver diff --git a/Tactility/Private/Tactility/TactilityPrivate.h b/Tactility/Private/Tactility/TactilityPrivate.h index 14815b4c2..7a045e48a 100644 --- a/Tactility/Private/Tactility/TactilityPrivate.h +++ b/Tactility/Private/Tactility/TactilityPrivate.h @@ -1,11 +1,10 @@ #pragma once -#include - namespace tt { -/** @return the handle of the dispatcher that getMainDispatcher() wraps */ -DispatcherHandle_t getMainDispatcherHandle(); +/** Queues a function on the main dispatcher's task without using the deprecated MainDispatcher class. + * @return true if the function was queued */ +bool dispatchOnMainDispatcher(void (*function)(void* context), void* context); void prepareFileSystems(); diff --git a/Tactility/Private/Tactility/app/boot/BootScreen.h b/Tactility/Private/Tactility/app/boot/BootScreen.h index 52bc97e82..88b19bb50 100644 --- a/Tactility/Private/Tactility/app/boot/BootScreen.h +++ b/Tactility/Private/Tactility/app/boot/BootScreen.h @@ -11,6 +11,12 @@ struct PixelBuffer; namespace tt::app::boot { +/** A PNG image in memory */ +struct BootLogo { + const uint8_t* data = nullptr; + size_t size = 0; +}; + /** * Draws boot screens directly to the display, for use while LVGL isn't running. * Content is drawn in the orientation from the display settings. @@ -53,10 +59,10 @@ public: /** * Shows a logo with text lines below it, centered on a black background. - * @param[in] logoPath path to a PNG file, or empty for no logo + * @param[in] logo a PNG image, or empty for no logo * @param[in] lines text lines, wrapped when they're too long for the display */ - void show(const std::string& logoPath, const std::vector& lines); + void show(const BootLogo& logo, const std::vector& lines); /** * Shows a QR code with text lines below it, centered on a black background. The set modules are white. diff --git a/Tactility/Private/Tactility/app/launcher/Favourites.h b/Tactility/Private/Tactility/app/launcher/Favourites.h index 1bf364884..7bf90b142 100644 --- a/Tactility/Private/Tactility/app/launcher/Favourites.h +++ b/Tactility/Private/Tactility/app/launcher/Favourites.h @@ -2,49 +2,109 @@ #include +#include +#include +#include + #include +#include +#include #include #include -#include -#include namespace tt::app::launcher { -/** Favourited app ids, persisted one per line under the data root. */ +/** + * Favourited app ids, persisted one per line under the data root. + * The ids are cached in memory: ids(), isFavourite() and toggle() never touch files and can be used from any task. + * load() and save() do file operations, so they must run on the file operations task, see . + */ class Favourites final { + struct State { + Mutex mutex; + std::vector ids; + /** Toggling before the first load() would save a list without the favourites in the file */ + bool loaded = false; + + State() { mutex_construct(&mutex); } + ~State() { mutex_destruct(&mutex); } + }; + + static State& state() { + static State* instance = tt::newObject(); + check(instance != nullptr); + return *instance; + } + public: - std::vector load() const { - std::vector ids; - const std::string path = filePath(); + /** Replaces the cached ids with the ones in the file. File operation. */ + void load() const { + std::vector loaded; + const ::std::string path = filePath(); if (!path.empty()) { - file::readLines(path, /* stripNewLine */ true, [&ids](const char* line) { + file::readLines(path, /* stripNewLine */ true, [&loaded](const char* line) { if (line[0] != '\0') { - ids.emplace_back(line); + loaded.emplace_back(line); } }); } - return ids; + auto& s = state(); + mutex_lock(&s.mutex); + s.ids = ::std::move(loaded); + s.loaded = true; + mutex_unlock(&s.mutex); } - /** @return false if the data root is unavailable or the write itself failed - the previous + /** @return a copy of the cached ids */ + std::vector ids() const { + auto& s = state(); + mutex_lock(&s.mutex); + std::vector result(s.ids.begin(), s.ids.end()); + mutex_unlock(&s.mutex); + return result; + } + + /** Adds the id to the cached ids when it's not in there yet, otherwise removes it. + * @param[out] result the new cached ids, to be passed to save() + * @return false when load() hasn't completed yet, in which case nothing changed */ + bool toggle(const char* id, std::vector& result) const { + auto& s = state(); + mutex_lock(&s.mutex); + if (!s.loaded) { + mutex_unlock(&s.mutex); + return false; + } + auto it = ::std::ranges::find(s.ids, id); + if (it != s.ids.end()) { + s.ids.erase(it); + } else { + s.ids.emplace_back(id); + } + result.assign(s.ids.begin(), s.ids.end()); + mutex_unlock(&s.mutex); + return true; + } + + /** Writes the ids to the file. File operation. + * @return false if the data root is unavailable or the write itself failed. The previous * file, if any, is left untouched either way. */ bool save(const std::vector& ids) const { - const std::string path = filePath(); + const ::std::string path = filePath(); if (path.empty()) { return false; } file::findOrCreateParentDirectory(path, 0777); - std::string content; + ::std::string content; for (const auto& id : ids) { content += id; content += '\n'; } // Written to a temp file and renamed into place, so a write that fails partway through // can't truncate or corrupt the existing file. - const std::string tempPath = path + ".tmp"; + const ::std::string tempPath = path + ".tmp"; if (!file::writeString(tempPath, content)) { remove(tempPath.c_str()); return false; @@ -61,25 +121,12 @@ public: } static bool contains(const std::vector& ids, const char* id) { - return std::ranges::find(ids, id) != ids.end(); - } - - /** @return false if the new list failed to persist (see save()) - the caller should tell the - * user, since the next load() will not reflect this toggle. */ - bool toggle(const char* id) const { - auto ids = load(); - auto it = std::ranges::find(ids, id); - if (it != ids.end()) { - ids.erase(it); - } else { - ids.emplace_back(id); - } - return save(ids); + return ::std::ranges::find(ids, id) != ids.end(); } private: - static std::string filePath() { + static ::std::string filePath() { char path[128]; if (app_paths_get_user_data_path("tactility.launcher", "favourites", path, sizeof(path)) != ERROR_NONE) { return ""; diff --git a/Tactility/Private/Tactility/app/launcher/LauncherMode.h b/Tactility/Private/Tactility/app/launcher/LauncherMode.h index 43c23381d..37abab502 100644 --- a/Tactility/Private/Tactility/app/launcher/LauncherMode.h +++ b/Tactility/Private/Tactility/app/launcher/LauncherMode.h @@ -21,6 +21,9 @@ struct LauncherMode { void (*onKey)(TileGrid& grid, const TileGridItem& item, uint32_t key); }; +/** Reads the favourites from their file into memory. File operation, see . */ +void loadFavourites(void* context); + extern const LauncherMode APPS_MODE; extern const LauncherMode SETTINGS_MODE; diff --git a/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h b/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h deleted file mode 100644 index 68f26d0e4..000000000 --- a/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once - -// Intentionally empty: LocaleSettings.cpp has no external callers (verified via repo-wide -// grep during its thread-per-app conversion), so it no longer exposes a start() wrapper. This -// header is kept as a placeholder in case that changes; nothing currently includes it. \ No newline at end of file diff --git a/Tactility/Private/Tactility/app/localesettings/TextResources.h b/Tactility/Private/Tactility/app/localesettings/TextResources.h deleted file mode 100644 index e6c4bb05f..000000000 --- a/Tactility/Private/Tactility/app/localesettings/TextResources.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include - -// WARNING: This file is auto-generated. Do not edit manually. - -namespace tt::app::localesettings::i18n { - -enum class Text { - NL_NL = 0, - NL_BE = 1, - EN_US = 2, - EN_GB = 3, - FR_FR = 4, - REGION = 5, - LANGUAGE = 6, -}; - -} diff --git a/Tactility/Private/Tactility/lvgl/FontCache.h b/Tactility/Private/Tactility/lvgl/FontCache.h index 223202bc2..3349766ea 100644 --- a/Tactility/Private/Tactility/lvgl/FontCache.h +++ b/Tactility/Private/Tactility/lvgl/FontCache.h @@ -9,8 +9,24 @@ struct BinFont; namespace tt::lvgl { -/** @return the path of a font file in the system partition */ -std::string getSystemFontPath(const char* fileName); +/** A TrueType font in a file, or in memory when data is set (the memory must stay valid forever) */ +struct TtfSource { + std::string path; + const uint8_t* data = nullptr; + size_t size = 0; +}; + +/** @return true when the firmware has any fonts, which is not the case when the build had no host compiler */ +bool hasGeneratedFonts(); + +/** + * Finds a font that is part of the firmware, which doesn't use RAM when it's opened with binfont_open_memory() without ownership. + * @param[in] fileName the cache file name, see getCachedFontFileName() + * @param[out] data the font file contents, which are valid forever + * @param[out] size the size of data in bytes + * @return true when the firmware has the font + */ +bool findGeneratedFont(const std::string& fileName, const uint8_t*& data, size_t& size); /** @return 1 for monochrome displays, otherwise 4 when PSRAM is available, otherwise 2 */ uint8_t getGeneratedFontBpp(); @@ -25,21 +41,21 @@ void deleteStaleCachedFonts(const char* prefix, const std::vector& * Opens a font from the font cache in the data path. When it's not cached, it's rasterized from * the TTF and written to the cache. Without a data path, the font is only kept in memory. * @param[in] fileName the cache file name, see getCachedFontFileName() - * @param[in] ttfPath the TrueType font to rasterize from + * @param[in] ttf the TrueType font to rasterize from * @param[in] size font size in pixels * @param[in] bpp bits per pixel * @param[in] getCodepoints provides the codepoints to rasterize, only called when the font is generated. * An empty list rasterizes every codepoint in the TTF. * @return the font, or nullptr when it couldn't be loaded or generated */ -BinFont* loadOrGenerateFont(const std::string& fileName, const std::string& ttfPath, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints); +BinFont* loadOrGenerateFont(const std::string& fileName, const TtfSource& ttf, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints); /** * Generates a font and writes it to the font cache, unless it's cached already. * Without a data path there is no cache: the font is generated and discarded, to verify that it can be. * @return false when the font couldn't be generated or written */ -bool ensureCachedFont(const std::string& fileName, const std::string& ttfPath, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints); +bool ensureCachedFont(const std::string& fileName, const TtfSource& ttf, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints); /** @return a version for a font file that changes when the file is replaced, or 0 when it doesn't exist */ uint32_t getFontFileVersion(const std::string& path); diff --git a/Tactility/Private/Tactility/lvgl/Fonts.h b/Tactility/Private/Tactility/lvgl/Fonts.h index 70fdeb5da..159459566 100644 --- a/Tactility/Private/Tactility/lvgl/Fonts.h +++ b/Tactility/Private/Tactility/lvgl/Fonts.h @@ -29,6 +29,12 @@ constexpr size_t LARGE_FONT_CHARACTER_COUNT = 1000; /** @return the number of characters in a TrueType font, or 0 when it can't be read */ size_t getTtfCharacterCount(const std::string& path); +/** + * Changing fonts generates them on the device, which needs at least 2 MB of memory in total. + * Otherwise only the fonts in the firmware are used. + */ +bool isFontChangeSupported(); + /** @return the configuration from the appearance settings, with the device defaults for unset values */ FontConfiguration loadFontConfiguration(); diff --git a/Tactility/Private/Tactility/service/webserver/WebServerService.h b/Tactility/Private/Tactility/service/webserver/WebServerService.h index dcc772b27..dc414afec 100644 --- a/Tactility/Private/Tactility/service/webserver/WebServerService.h +++ b/Tactility/Private/Tactility/service/webserver/WebServerService.h @@ -52,7 +52,6 @@ private: // Core HTML endpoints (hardcoded in firmware) static error_t handleRoot(struct HttpServerRequest* request, void* user_ctx); - static error_t handleSync(struct HttpServerRequest* request, void* user_ctx); static error_t handleReboot(struct HttpServerRequest* request, void* user_ctx); // File browser endpoints diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp index 5aeddca89..ce1c3ca78 100644 --- a/Tactility/Source/InitApps.cpp +++ b/Tactility/Source/InitApps.cpp @@ -55,7 +55,6 @@ namespace app { namespace inputdialog { extern const ::AppManifest manifest; } namespace launcher { extern const ::AppManifest manifest; } namespace ledstripsettings { extern const ::AppManifest manifest; } - namespace localesettings { extern const ::AppManifest manifest; } namespace locationsettings { extern const ::AppManifest manifest; } namespace notes { extern const ::AppManifest manifest; } namespace power { extern const ::AppManifest manifest; } @@ -126,7 +125,6 @@ static void registerInternalApps() { app_manager_add(&app::imageviewer::manifest); app_manager_add(&app::inputdialog::manifest); app_manager_add(&app::launcher::manifest); - app_manager_add(&app::localesettings::manifest); app_manager_add(&app::locationsettings::manifest); app_manager_add(&app::notes::manifest); if (device_exists_of_type(&POWER_SUPPLY_TYPE)) { @@ -215,7 +213,7 @@ static void registerInstalledAppsFromFileSystems() { char path[128]; if (file_system_get_path(fs, path, sizeof(path)) != ERROR_NONE) return true; const auto app_path = std::format("{}/tactility/app", path); - if (!app_path.starts_with(file::MOUNT_POINT_SYSTEM) && file::isDirectory(app_path)) { + if (file::isDirectory(app_path)) { LOG_I(TAG, "Registering install path %s", app_path.c_str()); app_manager_install_path_add(app_path.c_str()); } diff --git a/Tactility/Source/InitServices.cpp b/Tactility/Source/InitServices.cpp index 79fa3e8ab..74ea3d309 100644 --- a/Tactility/Source/InitServices.cpp +++ b/Tactility/Source/InitServices.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -50,6 +51,8 @@ void registerAndStartServices() { addService(service::audio::manifest); } check(module_ensure_started(&wifi_module) == ERROR_NONE); + location_configure_certificate(reinterpret_cast(asset_certificates_GTS_Root_R4_pem)); + app_hub_configure_certificate(reinterpret_cast(asset_certificates_GTS_Root_R4_pem)); check(module_ensure_started(&location_module) == ERROR_NONE); check(module_ensure_started(&app_hub_module) == ERROR_NONE); // Before the mesh service starts, as it needs the region to turn on the radio diff --git a/Tactility/Source/Main.cpp b/Tactility/Source/Main.cpp index d1f9b48da..70d3927cf 100644 --- a/Tactility/Source/Main.cpp +++ b/Tactility/Source/Main.cpp @@ -1,28 +1,17 @@ #include -#include #include -#include - #ifndef ESP_PLATFORM #include #endif -constexpr auto* TAG = "Main"; - extern "C" { void app_main() { - if (!tt::init(dts_modules, dts_devices)) { - return; - } - - LOG_I(TAG, "Main dispatcher ready"); - const auto dispatcher = tt::getMainDispatcherHandle(); - while (true) { - dispatcher_consume(dispatcher); - } + tt::init(dts_modules, dts_devices); + // The main task is only there to init. Delete it task afterwards to free up memory. + vTaskDelete(nullptr); } } // extern diff --git a/Tactility/Source/MainDispatcher.cpp b/Tactility/Source/MainDispatcher.cpp index 06e4ba04b..31602f6cc 100644 --- a/Tactility/Source/MainDispatcher.cpp +++ b/Tactility/Source/MainDispatcher.cpp @@ -1,39 +1,114 @@ +#include #include #include +#include + +#include +#include + // Implements the deprecated MainDispatcher API #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" namespace tt { -static DispatcherHandle_t mainDispatcherHandle = dispatcher_alloc(); +constexpr auto* TAG = "MainDispatcher"; -namespace { +// Internal RAM, so that the functions can access flash +constexpr size_t TASK_STACK_SIZE = 8192; -void mainDispatcherTrampoline(void* context) { - auto* function = static_cast(context); - (*function)(); - delete function; +struct MainDispatcher::Node { + Function function; + Node* next = nullptr; +}; + +MainDispatcher::MainDispatcher() { + mutex_construct(&mutex); } -} // namespace +MainDispatcher::Node* MainDispatcher::popOrStop() { + mutex_lock(&mutex); + Node* node = head; + if (node != nullptr) { + head = node->next; + if (head == nullptr) { + tail = nullptr; + } + } else { + running = false; + } + mutex_unlock(&mutex); + return node; +} -bool MainDispatcher::dispatch(Function function, TickType_t timeout) const { - auto* boxed = new Function(std::move(function)); - if (dispatcher_dispatch_timed(handle, boxed, mainDispatcherTrampoline, timeout) != ERROR_NONE) { - delete boxed; +int32_t MainDispatcher::taskMain(void* context) { + auto* dispatcher = static_cast(context); + while (Node* node = dispatcher->popOrStop()) { + node->function(); + deleteObject(node); + } + return 0; +} + +bool MainDispatcher::startTask() { + if (stale != nullptr) { + thread_join(stale, portMAX_DELAY, 1); + thread_free(stale); + stale = nullptr; + } + + ::Thread* thread = thread_alloc_full("main_dispatcher", TASK_STACK_SIZE, taskMain, this, getCpuAffinityConfiguration().mainDispatcher); + if (thread == nullptr) { return false; } + running = true; + if (thread_start(thread) != ERROR_NONE) { + running = false; + thread_free(thread); + return false; + } + stale = thread; return true; } -MainDispatcher getMainDispatcher() { - return MainDispatcher(mainDispatcherHandle); +bool MainDispatcher::dispatch(Function function) { + auto* node = newObject(); + if (node == nullptr) { + return false; + } + node->function = std::move(function); + + mutex_lock(&mutex); + if (tail != nullptr) { + tail->next = node; + } else { + head = node; + } + tail = node; + + if (!running && !startTask()) { + // The queue was empty before this node, because no task was running + head = nullptr; + tail = nullptr; + mutex_unlock(&mutex); + LOG_E(TAG, "Failed to start the task"); + deleteObject(node); + return false; + } + mutex_unlock(&mutex); + return true; } -DispatcherHandle_t getMainDispatcherHandle() { - return mainDispatcherHandle; +MainDispatcher& getMainDispatcher() { + // Allocated once and never freed + static MainDispatcher* instance = newObject(); + check(instance != nullptr); + return *instance; +} + +bool dispatchOnMainDispatcher(void (*function)(void* context), void* context) { + return getMainDispatcher().dispatch([function, context] { function(context); }); } } // namespace diff --git a/Tactility/Source/MountPoints.cpp b/Tactility/Source/MountPoints.cpp index 7653400d5..d6f90cbd4 100644 --- a/Tactility/Source/MountPoints.cpp +++ b/Tactility/Source/MountPoints.cpp @@ -18,12 +18,11 @@ std::vector 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; - // ESP32 mount paths are short names ("/system"); POSIX file systems can return a full + // ESP32 mount paths are short names ("/data"); 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, .d_type = TT_DT_DIR, diff --git a/Tactility/Source/app/alertdialog/AlertDialog.cpp b/Tactility/Source/app/alertdialog/AlertDialog.cpp index 6473d7b4b..b27f7a7c1 100644 --- a/Tactility/Source/app/alertdialog/AlertDialog.cpp +++ b/Tactility/Source/app/alertdialog/AlertDialog.cpp @@ -179,7 +179,8 @@ extern const ::AppManifest manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = { .depth = 3072, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL }, + // Requires 3072 is not enough on certain devices + .stack = { .depth = 4096, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL }, }; } diff --git a/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp b/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp index 9e19ab43a..aae7a89b3 100644 --- a/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp +++ b/Tactility/Source/app/appearancesettings/AppearanceSettings.cpp @@ -163,6 +163,10 @@ struct Context { }; lvgl::FontConfiguration toFontConfiguration(const settings::appearance::AppearanceSettings& settings) { + // The font settings aren't shown without enough memory to generate fonts, so saved values are ignored like at boot + if (!lvgl::isFontChangeSupported()) { + return { .defaultSize = static_cast(TT_FONT_DEFAULT_SIZE), .regularFontPath = "", .monoFontPath = "" }; + } return { .defaultSize = settings.fontSize != 0 ? settings.fontSize : static_cast(TT_FONT_DEFAULT_SIZE), .regularFontPath = settings.regularFontPath, @@ -286,9 +290,11 @@ void updateWidgets(Context* ctx) { const bool applying = ctx->applying; setHidden(ctx->applyButton, applying || ctx->pendingSettings == ctx->savedSettings); setHidden(ctx->spinner, !applying); - updateFontRow(ctx->regularRow, ctx->pendingSettings.regularFontPath, ctx->regularCharacterCount); - updateFontRow(ctx->monoRow, ctx->pendingSettings.monoFontPath, ctx->monoCharacterCount); - setHidden(ctx->errorLabel, !ctx->applyFailed); + if (ctx->errorLabel != nullptr) { + updateFontRow(ctx->regularRow, ctx->pendingSettings.regularFontPath, ctx->regularCharacterCount); + updateFontRow(ctx->monoRow, ctx->pendingSettings.monoFontPath, ctx->monoCharacterCount); + setHidden(ctx->errorLabel, !ctx->applyFailed); + } updateThemeWidgets(ctx); } @@ -727,43 +733,45 @@ void createWidgets(lv_obj_t* parent, void* userData) { } createSystemBarsCard(content, ctx); - auto* fonts_card = createSection(content, "Fonts"); - auto* font_size_row = createRow(fonts_card); - auto* font_size_label = lv_label_create(font_size_row); - lv_label_set_text(font_size_label, "Font size"); - lv_obj_set_flex_grow(font_size_label, 1); - // [-] [size] [+] - auto* font_size_spinbox = lv_spinbox_create(font_size_row); - lv_spinbox_set_range(font_size_spinbox, MIN_FONT_SIZE, MAX_FONT_SIZE); - lv_spinbox_set_digit_format(font_size_spinbox, 2, 0); - lv_spinbox_set_step(font_size_spinbox, 1); - // Wide enough for the digits and the room that the text field keeps for its cursor, so it never scrolls the digits out of view. - // The size is set before the value, because setting the value scrolls to the cursor using the current size. - const lv_font_t* font_size_font = lv_obj_get_style_text_font(font_size_spinbox, LV_PART_MAIN); - lv_point_t digits_size; - lv_text_get_size(&digits_size, "00", font_size_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); - const int32_t font_size_content_width = digits_size.x + lv_font_get_line_height(font_size_font); - lv_obj_set_width(font_size_spinbox, font_size_content_width + - lv_obj_get_style_pad_left(font_size_spinbox, LV_PART_MAIN) + lv_obj_get_style_pad_right(font_size_spinbox, LV_PART_MAIN) + - 2 * lv_obj_get_style_border_width(font_size_spinbox, LV_PART_MAIN)); - lv_obj_set_style_text_align(font_size_spinbox, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); - const uint16_t font_size = ctx->pendingSettings.fontSize != 0 ? ctx->pendingSettings.fontSize : static_cast(TT_FONT_DEFAULT_SIZE); - lv_spinbox_set_value(font_size_spinbox, font_size); - // The value is changed with the buttons, so the digit cursor isn't shown - lv_obj_set_style_bg_opa(font_size_spinbox, LV_OPA_TRANSP, LV_PART_CURSOR); - lv_obj_add_event_cb(font_size_spinbox, onFontSizeChanged, LV_EVENT_VALUE_CHANGED, ctx); - auto* decrement_button = createStepButton(font_size_row, LV_SYMBOL_MINUS, onFontSizeDecrementPressed, font_size_spinbox); - lv_obj_move_to_index(decrement_button, lv_obj_get_index(font_size_spinbox)); - createStepButton(font_size_row, LV_SYMBOL_PLUS, onFontSizeIncrementPressed, font_size_spinbox); + if (lvgl::isFontChangeSupported()) { + auto* fonts_card = createSection(content, "Fonts"); + auto* font_size_row = createRow(fonts_card); + auto* font_size_label = lv_label_create(font_size_row); + lv_label_set_text(font_size_label, "Font size"); + lv_obj_set_flex_grow(font_size_label, 1); + // [-] [size] [+] + auto* font_size_spinbox = lv_spinbox_create(font_size_row); + lv_spinbox_set_range(font_size_spinbox, MIN_FONT_SIZE, MAX_FONT_SIZE); + lv_spinbox_set_digit_format(font_size_spinbox, 2, 0); + lv_spinbox_set_step(font_size_spinbox, 1); + // Wide enough for the digits and the room that the text field keeps for its cursor, so it never scrolls the digits out of view. + // The size is set before the value, because setting the value scrolls to the cursor using the current size. + const lv_font_t* font_size_font = lv_obj_get_style_text_font(font_size_spinbox, LV_PART_MAIN); + lv_point_t digits_size; + lv_text_get_size(&digits_size, "00", font_size_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE); + const int32_t font_size_content_width = digits_size.x + lv_font_get_line_height(font_size_font); + lv_obj_set_width(font_size_spinbox, font_size_content_width + + lv_obj_get_style_pad_left(font_size_spinbox, LV_PART_MAIN) + lv_obj_get_style_pad_right(font_size_spinbox, LV_PART_MAIN) + + 2 * lv_obj_get_style_border_width(font_size_spinbox, LV_PART_MAIN)); + lv_obj_set_style_text_align(font_size_spinbox, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + const uint16_t font_size = ctx->pendingSettings.fontSize != 0 ? ctx->pendingSettings.fontSize : static_cast(TT_FONT_DEFAULT_SIZE); + lv_spinbox_set_value(font_size_spinbox, font_size); + // The value is changed with the buttons, so the digit cursor isn't shown + lv_obj_set_style_bg_opa(font_size_spinbox, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_add_event_cb(font_size_spinbox, onFontSizeChanged, LV_EVENT_VALUE_CHANGED, ctx); + auto* decrement_button = createStepButton(font_size_row, LV_SYMBOL_MINUS, onFontSizeDecrementPressed, font_size_spinbox); + lv_obj_move_to_index(decrement_button, lv_obj_get_index(font_size_spinbox)); + createStepButton(font_size_row, LV_SYMBOL_PLUS, onFontSizeIncrementPressed, font_size_spinbox); - ctx->regularRow = createFontRow(fonts_card, "Regular font", onSelectRegularPressed, onDefaultRegularPressed, ctx); - ctx->monoRow = createFontRow(fonts_card, "Monospace font", onSelectMonoPressed, onDefaultMonoPressed, ctx); + ctx->regularRow = createFontRow(fonts_card, "Regular font", onSelectRegularPressed, onDefaultRegularPressed, ctx); + ctx->monoRow = createFontRow(fonts_card, "Monospace font", onSelectMonoPressed, onDefaultMonoPressed, ctx); - ctx->errorLabel = lv_label_create(fonts_card); - lv_obj_set_width(ctx->errorLabel, LV_PCT(100)); - lv_label_set_long_mode(ctx->errorLabel, LV_LABEL_LONG_MODE_WRAP); - lv_label_set_text(ctx->errorLabel, "Failed to create the fonts. Check that the selected files are TrueType fonts."); - lv_obj_set_style_text_color(ctx->errorLabel, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT); + ctx->errorLabel = lv_label_create(fonts_card); + lv_obj_set_width(ctx->errorLabel, LV_PCT(100)); + lv_label_set_long_mode(ctx->errorLabel, LV_LABEL_LONG_MODE_WRAP); + lv_label_set_text(ctx->errorLabel, "Failed to create the fonts. Check that the selected files are TrueType fonts."); + lv_obj_set_style_text_color(ctx->errorLabel, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT); + } updateWidgets(ctx); diff --git a/Tactility/Source/app/boot/BootInit.cpp b/Tactility/Source/app/boot/BootInit.cpp index 0b01b0e97..fe92e4cee 100644 --- a/Tactility/Source/app/boot/BootInit.cpp +++ b/Tactility/Source/app/boot/BootInit.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -29,7 +30,6 @@ #include #include -#include #ifdef ESP_PLATFORM #include @@ -46,36 +46,32 @@ constexpr auto* DEFAULT_LAUNCHER_ID = "tactility.launcher"; namespace { -#ifdef ESP_PLATFORM -constexpr auto PARTITION_PREFIX = std::string("/"); -#else -constexpr auto PARTITION_PREFIX = std::string(""); -#endif - -// Equivalent of AppPaths::getAssetsPath() for the internal "Boot" app id, without needing a -// live AppContext (which this app no longer has under the new app-module model). -std::string getBootAssetsPath(const std::string& childPath) { - return std::format("{}{}/app/Boot/assets/{}", PARTITION_PREFIX, file::SYSTEM_PARTITION_NAME, childPath); -} - // TODO: Replace with automatic asset buckets like on Android -std::string getSizedAssetPath(const BootScreen& screen, const char* small, const char* medium, const char* large) { +BootLogo getSizedLogo(const BootScreen& screen, const BootLogo& small, const BootLogo& medium, const BootLogo& large) { const int smallest_dimension = screen.getSmallestDimension(); if (smallest_dimension < 150) { // e.g. Cardputer - return getBootAssetsPath(small); + return small; } else if (smallest_dimension >= 320) { - return getBootAssetsPath(large); + return large; } else { - return getBootAssetsPath(medium); + return medium; } } -std::string getLogoPath(const BootScreen& screen) { - return getSizedAssetPath(screen, "logo_small.png", "logo.png", "logo_large.png"); +BootLogo getLogo(const BootScreen& screen) { + return getSizedLogo(screen, + { asset_app_Boot_assets_logo_small_png, asset_app_Boot_assets_logo_small_png_SIZE }, + { asset_app_Boot_assets_logo_png, asset_app_Boot_assets_logo_png_SIZE }, + { asset_app_Boot_assets_logo_large_png, asset_app_Boot_assets_logo_large_png_SIZE } + ); } -std::string getUsbLogoPath(const BootScreen& screen) { - return getSizedAssetPath(screen, "logo_usb_small.png", "logo_usb.png", "logo_usb_large.png"); +BootLogo getUsbLogo(const BootScreen& screen) { + return getSizedLogo(screen, + { asset_app_Boot_assets_logo_usb_small_png, asset_app_Boot_assets_logo_usb_small_png_SIZE }, + { asset_app_Boot_assets_logo_usb_png, asset_app_Boot_assets_logo_usb_png_SIZE }, + { asset_app_Boot_assets_logo_usb_large_png, asset_app_Boot_assets_logo_usb_large_png_SIZE } + ); } void setupDisplay() { @@ -207,7 +203,7 @@ bool bootInit(TickType_t startTime) { BootScreen screen; if (screen.begin()) { - screen.show(is_usb_boot ? getUsbLogoPath(screen) : getLogoPath(screen), {}); + screen.show(is_usb_boot ? getUsbLogo(screen) : getLogo(screen), {}); } LOG_I(TAG, "Init display"); @@ -249,14 +245,14 @@ bool bootInit(TickType_t startTime) { break; case UsbBootResult::Started: #ifdef ESP_PLATFORM - screen.show(getUsbLogoPath(screen), { getInputPrompt("return to OS") }); + screen.show(getUsbLogo(screen), { getInputPrompt("return to OS") }); waitForInput(); hal::usb::stop(); esp_restart(); #endif return false; case UsbBootResult::Failed: - screen.show("", { "Failed to start USB mass storage:", hal::usb::getLastError(), getInputPrompt("reboot") }); + screen.show({}, { "Failed to start USB mass storage:", hal::usb::getLastError(), getInputPrompt("reboot") }); waitForInputAndReboot(); return false; } @@ -264,7 +260,7 @@ bool bootInit(TickType_t startTime) { registerApps(); if (sd_card_missing) { - screen.show("", { "SD card not found.", "Please insert one and reboot.", getInputPrompt("reboot") }); + screen.show({}, { "SD card not found.", "Please insert one and reboot.", getInputPrompt("reboot") }); waitForInputAndReboot(); return false; } diff --git a/Tactility/Source/app/boot/BootScreen.cpp b/Tactility/Source/app/boot/BootScreen.cpp index a008ecaaa..4e09c4dee 100644 --- a/Tactility/Source/app/boot/BootScreen.cpp +++ b/Tactility/Source/app/boot/BootScreen.cpp @@ -137,16 +137,16 @@ int BootScreen::getSmallestDimension() const { return std::min(panelWidth, panelHeight); } -void BootScreen::show(const std::string& logoPath, const std::vector& lines) { +void BootScreen::show(const BootLogo& bootLogo, const std::vector& lines) { if (target == nullptr) { return; } Logo logo; - if (!logoPath.empty()) { - const unsigned error = lodepng_decode32_file(&logo.rgba, &logo.width, &logo.height, logoPath.c_str()); + if (bootLogo.data != nullptr) { + const unsigned error = lodepng_decode32(&logo.rgba, &logo.width, &logo.height, bootLogo.data, bootLogo.size); if (error != 0) { - LOG_E(TAG, "Failed to load %s: %s", logoPath.c_str(), lodepng_error_text(error)); + LOG_E(TAG, "Failed to decode logo: %s", lodepng_error_text(error)); lodepng_free(logo.rgba); logo.rgba = nullptr; logo.width = 0; diff --git a/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp b/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp index 17ffff167..b926f372a 100644 --- a/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp +++ b/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp @@ -122,7 +122,7 @@ void showCrashScreen(boot::BootScreen& screen) { lines.push_back(std::string("Only the cause is known: ") + crashCauseToString(crash_data.cause)); } lines.push_back(prompt); - screen.show("", lines); + screen.show({}, lines); waitForInputOrForever(has_input); return; } @@ -154,7 +154,7 @@ void showCrashScreen(boot::BootScreen& screen) { return qrcode_getModule(&qr_code, static_cast(x), static_cast(y)); }, lines); } else { - screen.show("", lines); + screen.show({}, lines); } waitForInputOrForever(has_input); diff --git a/Tactility/Source/app/imageviewer/ImageViewer.cpp b/Tactility/Source/app/imageviewer/ImageViewer.cpp index 5d8bda911..4c59fa868 100644 --- a/Tactility/Source/app/imageviewer/ImageViewer.cpp +++ b/Tactility/Source/app/imageviewer/ImageViewer.cpp @@ -15,6 +15,9 @@ #include #include +#include + +#include #include namespace tt::app::imageviewer { @@ -27,9 +30,35 @@ namespace { struct Context { uint32_t appInstanceId; - std::string filePath; + ::std::string filePath; + // File contents are read in the app task, never on the LVGL task + tt::std::vector fileData; + lv_image_dsc_t imageDsc {}; }; +void loadFile(Context& ctx) { + FILE* file = fopen(ctx.filePath.c_str(), "rb"); + if (file == nullptr) return; + + fseek(file, 0, SEEK_END); + long size = ftell(file); + fseek(file, 0, SEEK_SET); + if (size > 0) { + ctx.fileData.resize(static_cast(size)); + if (fread(ctx.fileData.data(), 1, ctx.fileData.size(), file) != ctx.fileData.size()) { + ctx.fileData.clear(); + } + } + fclose(file); + + if (!ctx.fileData.empty()) { + ctx.imageDsc.header.magic = LV_IMAGE_HEADER_MAGIC; + ctx.imageDsc.header.cf = LV_COLOR_FORMAT_RAW; + ctx.imageDsc.data_size = ctx.fileData.size(); + ctx.imageDsc.data = ctx.fileData.data(); + } +} + void onBackPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); @@ -68,10 +97,9 @@ void createWidgets(lv_obj_t* parent, void* userData) { auto* file_label = lv_label_create(wrapper); lv_obj_align_to(file_label, wrapper, LV_ALIGN_BOTTOM_LEFT, 0, 0); - if (!ctx->filePath.empty()) { - std::string prefixed_path = lvgl::PATH_PREFIX + ctx->filePath; - LOG_I(TAG, "Opening %s", prefixed_path.c_str()); - lv_img_set_src(image, prefixed_path.c_str()); + if (!ctx->fileData.empty()) { + LOG_I(TAG, "Opening %s", ctx->filePath.c_str()); + lv_image_set_src(image, &ctx->imageDsc); auto path = string::getLastPathSegment(ctx->filePath); lv_label_set_text(file_label, path.c_str()); } else { @@ -86,6 +114,7 @@ int32_t appMain(int argc, char* argv[]) { Context ctx {}; ctx.appInstanceId = appInstanceId; ctx.filePath = argv[0]; + loadFile(ctx); TaskEventGroup event_group {}; task_event_group_construct(&event_group); @@ -121,7 +150,7 @@ int32_t appMain(int argc, char* argv[]) { } // namespace -void start(const std::string& file) { +void start(const ::std::string& file) { const char* argv[] = { file.c_str() }; uint32_t instanceId = 0; AppStartContext context = app_start_context_for_manifest(&manifest); diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index a4d35bbb2..bf6e3fe5c 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -6,10 +6,12 @@ #include #include +#include #include #include #include +#include #include namespace tt::app::launcher { @@ -20,6 +22,19 @@ constexpr auto* TAG = "Launcher"; const Favourites favourites; +struct SaveFavouritesJob { + std::vector ids; +}; + +// File operation: runs on the file operations task, which frees the job +void saveFavourites(void* context) { + auto* job = static_cast(context); + if (!favourites.save(job->ids)) { + LOG_E(TAG, "Failed to save favourites"); + } + delete job; +} + struct IconEntry { const char* id; const char* icon; @@ -77,14 +92,14 @@ void collectIncompatibleAppIds(const AppPackage* package, void* context) { } std::vector collectItems() { - const std::vector favouriteIds = favourites.load(); + const std::vector favouriteIds = favourites.ids(); std::vector<::AppManifest> collected; app_manager_for_each_manifest(collectManifest, &collected); std::vector incompatibleIds; app_manager_for_each_package(collectIncompatibleAppIds, &incompatibleIds); - std::erase_if(collected, [&](const ::AppManifest& manifest) { - return !isShown(manifest) || std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end(); + ::std::erase_if(collected, [&](const ::AppManifest& manifest) { + return !isShown(manifest) || ::std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end(); }); std::vector items; @@ -92,7 +107,7 @@ std::vector collectItems() { for (const auto& manifest : collected) { items.push_back({ manifest.id, manifest.name, appIcon(&manifest), Favourites::contains(favouriteIds, manifest.id) }); } - std::ranges::sort(items, [](const TileGridItem& a, const TileGridItem& b) { + ::std::ranges::sort(items, [](const TileGridItem& a, const TileGridItem& b) { if (a.highlighted != b.highlighted) { return a.highlighted; } @@ -102,8 +117,20 @@ std::vector collectItems() { } void toggleFavourite(TileGrid& grid, const char* appId, bool keepSelection) { - if (!favourites.toggle(appId)) { - LOG_E(TAG, "Failed to save favourites"); + auto* job = new (::std::nothrow) SaveFavouritesJob(); + if (job == nullptr) { + LOG_E(TAG, "Failed to queue saving favourites"); + return; + } + if (!favourites.toggle(appId, job->ids)) { + // The favourites are still being loaded, so toggling would overwrite the file with an incomplete list + LOG_W(TAG, "Favourites aren't loaded yet"); + delete job; + return; + } + if (file_ops_queue(saveFavourites, job, nullptr) != ERROR_NONE) { + LOG_E(TAG, "Failed to queue saving favourites"); + delete job; } grid.requestRepopulate(keepSelection); } @@ -120,6 +147,10 @@ void onKey(TileGrid& grid, const TileGridItem& item, uint32_t key) { } // namespace +void loadFavourites(void*) { + favourites.load(); +} + const LauncherMode APPS_MODE = { .buttonIcon = LVGL_ICON_SHARED_APPS, .iconColor = TileGrid::IconColor::Primary, diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index ea4bee389..cf4f4bd4d 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -5,13 +5,17 @@ #include #include +#include +#include #include +#include #include #include #include +#include #include #include @@ -35,7 +39,7 @@ void onAppKey(const TileGridItem& item, uint32_t key, void* userData); enum class Mode { Apps, - Settings + Settings, }; const LauncherMode& getLauncherMode(Mode mode) { @@ -57,7 +61,7 @@ struct Context { .onKey = onAppKey, .resolveIcon = nullptr, .onPageChanged = nullptr, - .userData = this + .userData = this, } }; }; @@ -65,16 +69,27 @@ std::vector collectItems(void* userData) { return getLauncherMode(static_cast(userData)->mode).collect(); } -void startApp(const char* appId) { +struct StartAppJob { + std::string appId; +}; + +// Starting an app can load it from a file, so it runs on the file operations task, which frees the job +void startApp(void* context) { + auto* job = static_cast(context); uint32_t instance_id = 0; - AppStartContext context; - if (app_start_context_from_id(appId, &context) == ERROR_NONE) { - app_start_with_context(&context, &instance_id); + AppStartContext start_context; + if (app_start_context_from_id(job->appId.c_str(), &start_context) == ERROR_NONE) { + app_start_with_context(&start_context, &instance_id); } + delete job; } void onAppClicked(const TileGridItem& item, void*) { - startApp(item.id.c_str()); + auto* job = new (::std::nothrow) StartAppJob { item.id }; + if (job == nullptr || file_ops_queue(startApp, job, nullptr) != ERROR_NONE) { + LOG_E(TAG, "Failed to queue starting %s", item.id.c_str()); + delete job; + } } void onAppLongPressed(const TileGridItem& item, void* userData) { @@ -135,7 +150,8 @@ void destroyWidgets(void* userData) { ctx->grid.destroyWidgets(); } -void runAutoStart() { +// Reads settings files, so it runs on the file operations task +void runAutoStart(void*) { settings::BootSettings boot_properties; AppStartContext context; if ( @@ -172,16 +188,28 @@ int32_t appMain(int argc, char* argv[]) { AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + FileOpsSubscription file_ops_sub {}; + check(file_ops_subscribe(&file_ops_sub, &event_group) == ERROR_NONE); + Context ctx; WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); - runAutoStart(); + // The file operations run in order: the favourites are available when auto-start runs + check(file_ops_queue(loadFavourites, nullptr, &file_ops_sub) == ERROR_NONE); + check(file_ops_queue(runAutoStart, nullptr, nullptr) == ERROR_NONE); // The launcher is meant to stay resident (it's the home screen) - it only gives up its // thread when app-module's scheduler asks it to (e.g. another new-model app is started). while (true) { task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); + if (file_ops_poll(&file_ops_sub) == ERROR_NONE) { + // The favourites were loaded + lvgl_lock(); + ctx.grid.requestRepopulate(false); + lvgl_unlock(); + } + bool shouldClose = false; AppEvent event {}; while (app_event_poll(&sub, &event) == ERROR_NONE) { @@ -194,6 +222,7 @@ int32_t appMain(int argc, char* argv[]) { } window_manager_remove(window); + file_ops_unsubscribe(&file_ops_sub); check(app_event_unsubscribe(&sub) == ERROR_NONE); task_event_group_destruct(&event_group); return 0; @@ -207,7 +236,8 @@ extern const ::AppManifest manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = { .depth = 5120, .desired_memory_capability = 0 }, + // All file operations run on the file operations task, so the stack can be in external RAM + .stack = { .depth = 3072, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL }, }; } // namespace diff --git a/Tactility/Source/app/launcher/SettingsMode.cpp b/Tactility/Source/app/launcher/SettingsMode.cpp index 6174845a4..35cfdc0be 100644 --- a/Tactility/Source/app/launcher/SettingsMode.cpp +++ b/Tactility/Source/app/launcher/SettingsMode.cpp @@ -26,7 +26,6 @@ constexpr IconEntry ICONS[] = { {"tactility.grovesettings", LVGL_ICON_SHARED_CABLE}, {"tactility.keyboardsettings", LVGL_ICON_SHARED_KEYBOARD_ALT}, {"tactility.ledstripsettings", LVGL_ICON_SHARED_LIGHTSTRIP}, - {"tactility.localesettings", LVGL_ICON_SHARED_LANGUAGE}, {"tactility.locationsettings", LVGL_ICON_SHARED_LOCATION_ON}, {"tactility.meshsettings", LVGL_ICON_SHARED_HUB}, {"tactility.power", LVGL_ICON_SHARED_POWER_SETTINGS_NEW}, diff --git a/Tactility/Source/app/localesettings/LocaleSettings.cpp b/Tactility/Source/app/localesettings/LocaleSettings.cpp deleted file mode 100644 index 217b08d6c..000000000 --- a/Tactility/Source/app/localesettings/LocaleSettings.cpp +++ /dev/null @@ -1,256 +0,0 @@ -#include - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include - -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace tt::app::localesettings { - -constexpr auto* TAG = "LocaleSettings"; - -#ifdef ESP_PLATFORM -constexpr auto* TEXT_RESOURCE_PATH = "/system/app/LocaleSettings/i18n"; -#else -constexpr auto* TEXT_RESOURCE_PATH = "system/app/LocaleSettings/i18n"; -#endif - -extern const ::AppManifest manifest; - -namespace { - -struct Context { - uint32_t appInstanceId; - tt::i18n::TextResources textResources = tt::i18n::TextResources(TEXT_RESOURCE_PATH); - RecursiveMutex mutex; - TaskEventGroup* eventGroup = nullptr; - uint32_t selectLanguageBit = 0; - uint32_t languageSelectLaunchId = 0; - // Valid while the window is shown - lv_obj_t* languageLabel = nullptr; - lv_obj_t* languageValueLabel = nullptr; - bool settingsUpdated = false; - - std::map languageMap; -}; - - -std::vector getLanguageNames(Context* ctx) { - std::vector items; - for (int i = 0; i < static_cast(settings::Language::count); i++) { - switch (static_cast(i)) { - case settings::Language::en_GB: - items.push_back(ctx->textResources[i18n::Text::EN_GB]); - break; - case settings::Language::en_US: - items.push_back(ctx->textResources[i18n::Text::EN_US]); - break; - case settings::Language::fr_FR: - items.push_back(ctx->textResources[i18n::Text::FR_FR]); - break; - case settings::Language::nl_BE: - items.push_back(ctx->textResources[i18n::Text::NL_BE]); - break; - case settings::Language::nl_NL: - items.push_back(ctx->textResources[i18n::Text::NL_NL]); - break; - case settings::Language::count: - break; - } - } - return items; -} - -/** Requires the LVGL lock */ -void updateViews(Context* ctx) { - if (ctx->languageLabel == nullptr) { - return; - } - lv_label_set_text(ctx->languageLabel, ctx->textResources[i18n::Text::LANGUAGE].c_str()); - const auto names = getLanguageNames(ctx); - const auto index = static_cast(settings::getLanguage()); - lv_label_set_text(ctx->languageValueLabel, index < names.size() ? names[index].c_str() : ""); -} - -void onSelectLanguagePressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - task_event_group_signal(ctx->eventGroup, ctx->selectLanguageBit); -} - -void startLanguageSelection(Context* ctx) { - if (ctx->languageSelectLaunchId != 0) { - return; - } - ctx->languageSelectLaunchId = selectiondialog::start(ctx->appInstanceId, ctx->textResources[i18n::Text::LANGUAGE], getLanguageNames(ctx)); -} - -void onLanguageSelectionResult(Context* ctx, const AppEvent& event) { - ctx->languageSelectLaunchId = 0; - // Other results mean that the dialog was dismissed - const int32_t selected = event.result.result; - if (selected >= 0 && selected < static_cast(settings::Language::count)) { - settings::setLanguage(static_cast(selected)); - ctx->textResources.load(); - lvgl_lock(); - updateViews(ctx); - lvgl_unlock(); - } - app_manager_stop(event.result.launch_id); -} - -// Preserved from the pre-conversion code as-is: declared but never wired to any widget there -// either, so this has always been dead code (kept verbatim rather than dropped, since removing -// it would be a functional judgment call outside the scope of this lifecycle-only conversion). -[[maybe_unused]] void onRegionChanged(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - ctx->settingsUpdated = true; -} - -void onBackPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - app_event_emit_close(ctx->appInstanceId); -} - -void createWidgets(lv_obj_t* parent, void* userData) { - auto* ctx = static_cast(userData); - ctx->textResources.load(); - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - auto* toolbar = lvgl_toolbar_create(parent, "Locale"); - // The global toolbar nav callback only knows how to stop old-model apps. - lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); - - // "Language English [Select]" - auto* card = lvgl_card_create(main_wrapper); - lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); - - // Transparent, so the card provides the background - auto* language_row = lv_obj_create(card); - lv_obj_set_size(language_row, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(language_row, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(language_row, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_set_flex_flow(language_row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(language_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_border_width(language_row, 0, LV_STATE_DEFAULT); - lv_obj_set_scrollable(language_row, false); - - // The title with the language below it, which take the width that the button leaves. Texts that don't fit scroll. - auto* texts = lv_obj_create(language_row); - lv_obj_set_height(texts, LV_SIZE_CONTENT); - lv_obj_set_flex_grow(texts, 1); - lv_obj_set_flex_flow(texts, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(texts, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_row(texts, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(texts, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(texts, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_set_scrollable(texts, false); - - ctx->languageLabel = lv_label_create(texts); - lv_obj_set_width(ctx->languageLabel, LV_PCT(100)); - lv_label_set_long_mode(ctx->languageLabel, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR); - - ctx->languageValueLabel = lv_label_create(texts); - lv_obj_set_width(ctx->languageValueLabel, LV_PCT(100)); - lv_label_set_long_mode(ctx->languageValueLabel, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR); - lv_obj_set_style_text_font(ctx->languageValueLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); - - auto* select_button = lv_button_create(language_row); - auto* select_icon = lv_label_create(select_button); - lv_obj_set_style_text_font(select_icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); - lv_label_set_text(select_icon, LVGL_ICON_SHARED_LIST_ALT); - lv_obj_add_event_cb(select_button, onSelectLanguagePressed, LV_EVENT_SHORT_CLICKED, ctx); - - updateViews(ctx); -} - -void destroyWidgets(void* userData) { - auto* ctx = static_cast(userData); - ctx->languageLabel = nullptr; - ctx->languageValueLabel = nullptr; -} - -int32_t appMain(int argc, char* argv[]) { - uint32_t appInstanceId = app_scheduler_current_app_id(); - Context ctx; - ctx.appInstanceId = appInstanceId; - - TaskEventGroup event_group {}; - task_event_group_construct(&event_group); - ctx.eventGroup = &event_group; - check(task_event_group_claim_bit(&event_group, &ctx.selectLanguageBit) == ERROR_NONE); - - AppEventSubscription sub {}; - check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - - WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); - - bool shouldClose = false; - while (!shouldClose) { - uint32_t flags = 0; - task_event_group_wait_any(&event_group, &flags, portMAX_DELAY); - - if (flags & ctx.selectLanguageBit) { - startLanguageSelection(&ctx); - } - - AppEvent event {}; - while (app_event_poll(&sub, &event) == ERROR_NONE) { - if (event.type == APP_EVENT_CLOSE) { - shouldClose = true; - break; - } else if (event.type == APP_EVENT_RESULT && event.result.launch_id == ctx.languageSelectLaunchId) { - onLanguageSelectionResult(&ctx, event); - } - } - } - - window_manager_remove(window); - check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&event_group); - - return 0; -} - -} // namespace - -extern const ::AppManifest manifest = { - .id = "tactility.localesettings", - .name = "Locale", - .category = APP_CATEGORY_SETTINGS, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, - .flags = 0, - .stack = {} -}; - -} // namespace tt::app::localesettings diff --git a/Tactility/Source/app/meshsettings/MeshSettings.cpp b/Tactility/Source/app/meshsettings/MeshSettings.cpp index 575c6945b..c82fef5b3 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -82,7 +82,7 @@ struct Context { // Valid while the window is shown lv_obj_t* serviceLabel = nullptr; lv_obj_t* nameLabel = nullptr; - lv_obj_t* radioRow = nullptr; + lv_obj_t* radioButton = nullptr; lv_obj_t* radioLabel = nullptr; lv_obj_t* regionLabel = nullptr; lv_obj_t* presetRow = nullptr; @@ -121,7 +121,12 @@ void updateLabels(Context* ctx) { } lv_label_set_text(ctx->serviceLabel, ctx->serviceText.c_str()); lv_label_set_text(ctx->nameLabel, ctx->nameText.c_str()); - lv_obj_set_hidden(ctx->radioRow, !ctx->radioChoice); + // The radio is always shown, but it can only be changed when there are several + if (ctx->radioChoice) { + lv_obj_remove_state(ctx->radioButton, LV_STATE_DISABLED); + } else { + lv_obj_add_state(ctx->radioButton, LV_STATE_DISABLED); + } lv_label_set_text(ctx->radioLabel, ctx->radioText.c_str()); lv_label_set_text(ctx->regionLabel, ctx->regionText.c_str()); lv_obj_set_hidden(ctx->presetRow, !ctx->hasPresets); @@ -175,7 +180,7 @@ void loadState(Context* ctx) { char name[MESH_NAME_MAX_LENGTH + 1]; mesh_get_node_name(name, sizeof(name)); ctx->nameText = name; - // A choice only matters when there is more than one radio + // A choice is only possible when there is more than one radio char radio[MESH_NAME_MAX_LENGTH + 1] = "-"; mesh_get_radio(radio, sizeof(radio)); std::vector radioNames; @@ -415,12 +420,13 @@ lv_obj_t* createTitleAndValue(lv_obj_t* row, const char* title) { return valueLabel; } -void createChangeButton(lv_obj_t* row, lv_event_cb_t callback, Context* ctx) { +lv_obj_t* createChangeButton(lv_obj_t* row, lv_event_cb_t callback, Context* ctx) { auto* button = lv_button_create(row); auto* icon = lv_label_create(button); lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); lv_label_set_text(icon, LVGL_ICON_SHARED_LIST_ALT); lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, ctx); + return button; } void createWidgets(lv_obj_t* parent, void* userData) { @@ -450,9 +456,9 @@ void createWidgets(lv_obj_t* parent, void* userData) { ctx->nameLabel = createTitleAndValue(nameRow, "Name"); createChangeButton(nameRow, onChangeNamePressed, ctx); - ctx->radioRow = createRow(card); - ctx->radioLabel = createTitleAndValue(ctx->radioRow, "Radio"); - createChangeButton(ctx->radioRow, onSelectRadioPressed, ctx); + auto* radioRow = createRow(card); + ctx->radioLabel = createTitleAndValue(radioRow, "Radio"); + ctx->radioButton = createChangeButton(radioRow, onSelectRadioPressed, ctx); auto* regionRow = createRow(card); ctx->regionLabel = createTitleAndValue(regionRow, "Region"); @@ -478,7 +484,7 @@ void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); ctx->serviceLabel = nullptr; ctx->nameLabel = nullptr; - ctx->radioRow = nullptr; + ctx->radioButton = nullptr; ctx->radioLabel = nullptr; ctx->regionLabel = nullptr; ctx->presetRow = nullptr; diff --git a/Tactility/Source/app/notes/Notes.cpp b/Tactility/Source/app/notes/Notes.cpp index 2e2a16759..14905f356 100644 --- a/Tactility/Source/app/notes/Notes.cpp +++ b/Tactility/Source/app/notes/Notes.cpp @@ -24,8 +24,11 @@ #include #include #include +#include #include +#include + namespace tt::app::notes { constexpr auto* TAG = "Notes"; @@ -85,6 +88,22 @@ bool saveFile(Context* ctx, const std::string& path) { return result; } +struct SaveJob { + std::string path; + std::string content; +}; + +/** File operation: it runs on the file operations task, which frees the job */ +void saveInBackground(void* context) { + auto* job = static_cast(context); + if (file::writeString(job->path, job->content.c_str())) { + LOG_I(TAG, "Saved to %s", job->path.c_str()); + } else { + LOG_E(TAG, "Failed to save to %s", job->path.c_str()); + } + delete job; +} + void hideOverlay(Context* ctx) { if (lv_obj_is_hidden(ctx->uiOverlay)) { return; @@ -108,8 +127,12 @@ void onNewPressed(lv_event_t* e) { void onSavePressed(lv_event_t* e) { auto* ctx = static_cast(lv_event_get_user_data(e)); hideOverlay(ctx); - ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText); - saveFile(ctx, ctx->filePath); + // Runs on the LVGL task, which must not do file operations + auto* job = new (::std::nothrow) SaveJob { ctx->filePath, lv_textarea_get_text(ctx->uiNoteText) }; + if (job == nullptr || file_ops_queue(saveInBackground, job, nullptr) != ERROR_NONE) { + LOG_E(TAG, "Failed to queue saving %s", ctx->filePath.c_str()); + delete job; + } } void onSaveAsPressed(lv_event_t* e) { diff --git a/Tactility/Source/app/systeminfo/SystemInfo.cpp b/Tactility/Source/app/systeminfo/SystemInfo.cpp index 8604a0952..0c3419e42 100644 --- a/Tactility/Source/app/systeminfo/SystemInfo.cpp +++ b/Tactility/Source/app/systeminfo/SystemInfo.cpp @@ -294,7 +294,6 @@ struct Context { MemoryBarWidgets externalMemBar; MemoryBarWidgets dataStorageBar; MemoryBarWidgets sdcardStorageBar; - MemoryBarWidgets systemStorageBar; lv_obj_t* tasksContainer = nullptr; /** The task name of each row in tasksContainer */ @@ -303,7 +302,6 @@ struct Context { bool hasExternalMem = false; bool hasDataStorage = false; - bool hasSystemStorage = false; }; @@ -331,11 +329,6 @@ void updateStorage(Context* ctx) { updateMemoryBar(ctx->sdcardStorageBar, storage_free, storage_total); } - if (ctx->hasSystemStorage) { - if (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK) { - updateMemoryBar(ctx->systemStorageBar, storage_free, storage_total); - } - } #endif } @@ -428,13 +421,6 @@ void createWidgets(lv_obj_t* parent, void* userData) { if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) { ctx->sdcardStorageBar = createMemoryBar(storage_tab, sdcard_path.c_str()); } - - if (config::SHOW_SYSTEM_PARTITION) { - ctx->hasSystemStorage = (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK); - if (ctx->hasSystemStorage) { - ctx->systemStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_SYSTEM); - } - } #endif #if configUSE_TRACE_FACILITY diff --git a/Tactility/Source/app/timezone/TimeZoneView.cpp b/Tactility/Source/app/timezone/TimeZoneView.cpp index f38acc61f..d57835bc1 100644 --- a/Tactility/Source/app/timezone/TimeZoneView.cpp +++ b/Tactility/Source/app/timezone/TimeZoneView.cpp @@ -1,6 +1,5 @@ #include -#include #include #include @@ -15,7 +14,9 @@ #include #include -#include +#include + +#include namespace tt::app::timezone { @@ -27,10 +28,6 @@ namespace { constexpr size_t MAX_ENTRIES = 50; constexpr TickType_t FILTER_DELAY = pdMS_TO_TICKS(500); -std::string getTimeZonesPath() { - return std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv"; -} - /** Parses a line like "Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3" */ bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) { if (input.size() < 3) { @@ -48,20 +45,18 @@ bool parseEntry(const std::string& input, std::string& outName, std::string& out /** Calls @a visitor for each time zone until it returns false */ void forEachTimeZone(const std::function& visitor) { - auto* file = fopen(getTimeZonesPath().c_str(), "rb"); - if (file == nullptr) { - LOG_E(TAG, "Failed to open %s", getTimeZonesPath().c_str()); - return; - } - char line[96]; + const std::string_view csv(reinterpret_cast(asset_timezones_csv), asset_timezones_csv_size); std::string name; std::string code; - while (fgets(line, sizeof(line), file)) { - if (parseEntry(line, name, code) && !visitor(name, code)) { + size_t start = 0; + while (start < csv.size()) { + size_t end = csv.find('\n', start); + end = end == std::string_view::npos ? csv.size() : end + 1; + if (parseEntry(std::string(csv.substr(start, end - start)), name, code) && !visitor(name, code)) { break; } + start = end; } - fclose(file); } } // namespace diff --git a/Tactility/Source/app/trackball/TrackballSettings.cpp b/Tactility/Source/app/trackball/TrackballSettings.cpp index b3404bde7..56192e90b 100644 --- a/Tactility/Source/app/trackball/TrackballSettings.cpp +++ b/Tactility/Source/app/trackball/TrackballSettings.cpp @@ -12,7 +12,7 @@ #include #include -#include +#include #include #include @@ -94,7 +94,7 @@ void applyLive(Context* ctx) { lvgl_lock(); lvgl_trackball_set_settings(ctx->trackballIndev, &ctx->tbSettings); if (ctx->tbSettings.mode == LVGL_TRACKBALL_MODE_POINTER) { - lvgl_trackball_set_cursor_image(ctx->trackballIndev, TT_ASSETS_UI_CURSOR); + lvgl_trackball_set_cursor_image(ctx->trackballIndev, lvgl_get_cursor_image()); } lvgl_unlock(); } diff --git a/Tactility/Source/bluetooth/BluetoothHidHost.cpp b/Tactility/Source/bluetooth/BluetoothHidHost.cpp index cd659606a..5a33991f7 100644 --- a/Tactility/Source/bluetooth/BluetoothHidHost.cpp +++ b/Tactility/Source/bluetooth/BluetoothHidHost.cpp @@ -8,7 +8,7 @@ #include #include -#include +#include #include #include @@ -354,7 +354,7 @@ static void hidHostHandleMouseReport(const uint8_t* data, uint16_t len) { auto* cur = lv_image_create(lv_layer_sys()); lv_obj_set_clickable(cur, false); lv_obj_set_hidden(cur, true); - lv_image_set_src(cur, TT_ASSETS_UI_CURSOR); + lv_image_set_src(cur, lvgl_get_cursor_image()); lv_indev_set_cursor(ms, cur); hid_host_ctx->mouseIndev = ms; hid_host_ctx->mouseCursor = cur; diff --git a/Tactility/Source/lvgl/FontCache.cpp b/Tactility/Source/lvgl/FontCache.cpp index 543ce6898..9752d94d7 100644 --- a/Tactility/Source/lvgl/FontCache.cpp +++ b/Tactility/Source/lvgl/FontCache.cpp @@ -1,8 +1,9 @@ #include -#include #include +#include + #include #include #include @@ -39,8 +40,19 @@ static const std::string& getCacheDirectory() { return directory; } -std::string getSystemFontPath(const char* fileName) { - return std::format("{}/fonts/{}", file::MOUNT_POINT_SYSTEM, fileName); +bool hasGeneratedFonts() { + return generated_fonts_count > 0; +} + +bool findGeneratedFont(const std::string& fileName, const uint8_t*& data, size_t& size) { + for (size_t i = 0; i < generated_fonts_count; i++) { + if (fileName == generated_fonts[i].file_name) { + data = generated_fonts[i].data; + size = generated_fonts[i].size; + return true; + } + } + return false; } uint8_t getGeneratedFontBpp() { @@ -92,10 +104,12 @@ static bool writeCacheFile(const std::string& path, const uint8_t* data, size_t } /** Rasterizes a font, see binfont_generate() */ -static bool generateFont(const std::string& fileName, const std::string& ttfPath, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints, uint8_t*& data, size_t& dataSize) { +static bool generateFont(const std::string& fileName, const TtfSource& ttf, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints, uint8_t*& data, size_t& dataSize) { const auto codepoints = getCodepoints(); const BinFontGeneratorConfig config = { - .ttf_path = ttfPath.c_str(), + .ttf_path = ttf.path.c_str(), + .ttf_data = ttf.data, + .ttf_size = ttf.size, .size = size, .bpp = bpp, .codepoints = codepoints.empty() ? nullptr : codepoints.data(), @@ -105,14 +119,14 @@ static bool generateFont(const std::string& fileName, const std::string& ttfPath const auto start_time = get_millis(); const error_t error = binfont_generate(&config, &data, &dataSize); if (error != ERROR_NONE) { - LOG_E(TAG, "Failed to generate %s from %s (%s)", fileName.c_str(), ttfPath.c_str(), error_to_string(error)); + LOG_E(TAG, "Failed to generate %s from %s (%s)", fileName.c_str(), ttf.data != nullptr ? "memory" : ttf.path.c_str(), error_to_string(error)); return false; } LOG_I(TAG, "Generated %s (%zu bytes) in %zu ms", fileName.c_str(), dataSize, get_millis() - start_time); return true; } -BinFont* loadOrGenerateFont(const std::string& fileName, const std::string& ttfPath, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints) { +BinFont* loadOrGenerateFont(const std::string& fileName, const TtfSource& ttf, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints) { const auto& directory = getCacheDirectory(); const auto cache_path = directory.empty() ? std::string() : file::getChildPath(directory, fileName); @@ -126,7 +140,7 @@ BinFont* loadOrGenerateFont(const std::string& fileName, const std::string& ttfP uint8_t* data = nullptr; size_t data_size = 0; - if (!generateFont(fileName, ttfPath, size, bpp, getCodepoints, data, data_size)) { + if (!generateFont(fileName, ttf, size, bpp, getCodepoints, data, data_size)) { return nullptr; } @@ -141,7 +155,7 @@ BinFont* loadOrGenerateFont(const std::string& fileName, const std::string& ttfP return font; } -bool ensureCachedFont(const std::string& fileName, const std::string& ttfPath, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints) { +bool ensureCachedFont(const std::string& fileName, const TtfSource& ttf, uint16_t size, uint8_t bpp, const std::function()>& getCodepoints) { const auto& directory = getCacheDirectory(); const auto cache_path = directory.empty() ? std::string() : file::getChildPath(directory, fileName); if (!cache_path.empty() && file::isFile(cache_path)) { @@ -150,7 +164,7 @@ bool ensureCachedFont(const std::string& fileName, const std::string& ttfPath, u uint8_t* data = nullptr; size_t data_size = 0; - if (!generateFont(fileName, ttfPath, size, bpp, getCodepoints, data, data_size)) { + if (!generateFont(fileName, ttf, size, bpp, getCodepoints, data, data_size)) { return false; } // Without a cache, the font is only generated to verify that it can be diff --git a/Tactility/Source/lvgl/Fonts.cpp b/Tactility/Source/lvgl/Fonts.cpp index ec05cda49..aa5291504 100644 --- a/Tactility/Source/lvgl/Fonts.cpp +++ b/Tactility/Source/lvgl/Fonts.cpp @@ -1,11 +1,12 @@ #include -#include #include #include #include #include +#include + #include #include #include @@ -33,7 +34,7 @@ using CodepointProvider = std::function()>; /** A TTF and the codepoints to rasterize from it */ struct FontSource { - std::string ttfPath; + TtfSource ttf; uint32_t version; /** An empty list rasterizes all codepoints of the TTF */ CodepointProvider getCodepoints; @@ -43,7 +44,7 @@ struct FontSource { /** A font in the font cache */ struct FontRequest { std::string fileName; - std::string ttfPath; + TtfSource ttf; uint16_t size; uint8_t bpp; CodepointProvider getCodepoints; @@ -79,10 +80,10 @@ struct LoadedFont { lv_font_t* lvFont; }; -/** ESP32 devices without PSRAM skip the large fonts to save memory */ +/** ESP32 devices without PSRAM skip the large fonts to save memory, unless the fonts are part of the firmware, which doesn't use RAM */ static bool hasLimitedMemory() { #ifdef ESP_PLATFORM - return memory_external_total() == 0; + return memory_external_total() == 0 && !hasGeneratedFonts(); #else return false; #endif @@ -120,11 +121,11 @@ size_t getTtfCharacterCount(const std::string& path) { return count; } -static FontSource getSystemSource(const char* systemFileName, uint32_t systemVersion) { - return { getSystemFontPath(systemFileName), systemVersion, [] { return std::vector(); }, false }; +static FontSource getSystemSource(const uint8_t* systemData, size_t systemSize, uint32_t systemVersion) { + return { { "", systemData, systemSize }, systemVersion, [] { return std::vector(); }, false }; } -static FontSource getSource(const std::string& customPath, const char* systemFileName, uint32_t systemVersion) { +static FontSource getSource(const std::string& customPath, const uint8_t* systemData, size_t systemSize, uint32_t systemVersion) { if (!customPath.empty()) { const uint32_t version = getFontFileVersion(customPath); if (version != 0) { @@ -135,39 +136,46 @@ static FontSource getSource(const std::string& customPath, const char* systemFil } return std::vector(); }; - return { customPath, version, get_codepoints, true }; + return { { customPath }, version, get_codepoints, true }; } LOG_W(TAG, "%s not found, using the system font", customPath.c_str()); } - return getSystemSource(systemFileName, systemVersion); + return getSystemSource(systemData, systemSize, systemVersion); } static FontSource getTextSource(const FontConfiguration& configuration) { - return getSource(configuration.regularFontPath, "AdwaitaSans.ttf", TT_TEXT_FONT_VERSION); + return getSource(configuration.regularFontPath, asset_fonts_AdwaitaSans_ttf, asset_fonts_AdwaitaSans_ttf_size, TT_TEXT_FONT_VERSION); } static FontSource getMonoSource(const FontConfiguration& configuration) { - return getSource(configuration.monoFontPath, "AdwaitaMono.ttf", TT_MONO_FONT_VERSION); + return getSource(configuration.monoFontPath, asset_fonts_AdwaitaMono_ttf, asset_fonts_AdwaitaMono_ttf_size, TT_MONO_FONT_VERSION); } static FontRequest getTextRequest(const FontConfiguration& configuration, const FontSource& source, const TextFontDefinition& definition, uint8_t bpp) { const uint16_t size = getTextFontSize(configuration.defaultSize, definition.id); - return { getCachedFontFileName(TEXT_CACHE_PREFIX, definition.name, size, bpp, source.version), source.ttfPath, size, bpp, source.getCodepoints }; + return { getCachedFontFileName(TEXT_CACHE_PREFIX, definition.name, size, bpp, source.version), source.ttf, size, bpp, source.getCodepoints }; } static FontRequest getMonoRequest(const FontConfiguration& configuration, const FontSource& source, uint8_t bpp) { - return { getCachedFontFileName(MONO_CACHE_PREFIX, "mono", configuration.defaultSize, bpp, source.version), source.ttfPath, configuration.defaultSize, bpp, source.getCodepoints }; + return { getCachedFontFileName(MONO_CACHE_PREFIX, "mono", configuration.defaultSize, bpp, source.version), source.ttf, configuration.defaultSize, bpp, source.getCodepoints }; } /** Parses a .codepoints file: one " " pair per line */ static std::unordered_map loadIconCodepoints() { std::unordered_map codepoints; - file::readLines(getSystemFontPath("MaterialSymbolsRounded.codepoints"), true, [&codepoints](const char* line) { - const char* separator = strchr(line, ' '); - if (separator != nullptr) { - codepoints[std::string(line, separator - line)] = static_cast(strtoul(separator + 1, nullptr, 16)); + const char* line = reinterpret_cast(asset_fonts_MaterialSymbolsRounded_codepoints); + const char* end = line + asset_fonts_MaterialSymbolsRounded_codepoints_size; + while (line < end) { + const char* line_end = static_cast(memchr(line, '\n', end - line)); + if (line_end == nullptr) { + line_end = end; } - }); + const char* separator = static_cast(memchr(line, ' ', line_end - line)); + if (separator != nullptr) { + codepoints[std::string(line, separator - line)] = static_cast(strtoul(std::string(separator + 1, line_end).c_str(), nullptr, 16)); + } + line = line_end + 1; + } return codepoints; } @@ -189,19 +197,42 @@ static FontRequest getIconRequest(const FontConfiguration& configuration, const }; return { getCachedFontFileName(ICON_CACHE_PREFIX, definition.name, size, bpp, LVGL_ICON_FONT_VERSION), - getSystemFontPath("MaterialSymbolsRounded.ttf"), + { "", asset_fonts_MaterialSymbolsRounded_ttf, asset_fonts_MaterialSymbolsRounded_ttf_size }, size, bpp, get_codepoints }; } +/** Fonts that aren't part of the firmware are loaded into RAM without external memory */ +static void warnWhenInRam(const FontRequest& request) { + if (memory_external_total() == 0) { + LOG_W(TAG, "%s is not part of the firmware, so it is or will be in RAM", request.fileName.c_str()); + } +} + static BinFont* load(const FontRequest& request) { - return loadOrGenerateFont(request.fileName, request.ttfPath, request.size, request.bpp, request.getCodepoints); + const uint8_t* data = nullptr; + size_t size = 0; + if (findGeneratedFont(request.fileName, data, size)) { + BinFont* font = nullptr; + if (binfont_open_memory(data, size, false, &font) == ERROR_NONE) { + return font; + } + LOG_E(TAG, "Failed to open %s from the firmware", request.fileName.c_str()); + } + warnWhenInRam(request); + return loadOrGenerateFont(request.fileName, request.ttf, request.size, request.bpp, request.getCodepoints); } static bool ensureCached(const FontRequest& request) { - return ensureCachedFont(request.fileName, request.ttfPath, request.size, request.bpp, request.getCodepoints); + const uint8_t* data = nullptr; + size_t size = 0; + if (findGeneratedFont(request.fileName, data, size)) { + return true; + } + warnWhenInRam(request); + return ensureCachedFont(request.fileName, request.ttf, request.size, request.bpp, request.getCodepoints); } static lv_font_t* createLvglFont(BinFont* font) { @@ -229,7 +260,15 @@ static void unloadFonts() { loadedFonts.clear(); } +bool isFontChangeSupported() { + constexpr size_t MINIMUM_TOTAL_MEMORY = 2 * 1024 * 1024; + return memory_heap_total() + memory_external_total() >= MINIMUM_TOTAL_MEMORY; +} + FontConfiguration loadFontConfiguration() { + if (!isFontChangeSupported()) { + return { .defaultSize = static_cast(TT_FONT_DEFAULT_SIZE), .regularFontPath = "", .monoFontPath = "" }; + } const auto settings = settings::appearance::loadOrGetDefault(); return { .defaultSize = settings.fontSize != 0 ? settings.fontSize : static_cast(TT_FONT_DEFAULT_SIZE), @@ -277,7 +316,7 @@ void loadFonts(const FontConfiguration& configuration) { BinFont* font = load(request); if (font == nullptr && text_source.custom) { LOG_W(TAG, "Falling back to the system font for %s text", definition.name); - request = getTextRequest(configuration, getSystemSource("AdwaitaSans.ttf", TT_TEXT_FONT_VERSION), definition, bpp); + request = getTextRequest(configuration, getSystemSource(asset_fonts_AdwaitaSans_ttf, asset_fonts_AdwaitaSans_ttf_size, TT_TEXT_FONT_VERSION), definition, bpp); font = load(request); } if (font == nullptr) { @@ -334,7 +373,7 @@ BinFont* loadMonoFont() { BinFont* font = load(getMonoRequest(configuration, source, bpp)); if (font == nullptr && source.custom) { LOG_W(TAG, "Falling back to the system monospace font"); - font = load(getMonoRequest(configuration, getSystemSource("AdwaitaMono.ttf", TT_MONO_FONT_VERSION), bpp)); + font = load(getMonoRequest(configuration, getSystemSource(asset_fonts_AdwaitaMono_ttf, asset_fonts_AdwaitaMono_ttf_size, TT_MONO_FONT_VERSION), bpp)); } return font; } diff --git a/Tactility/Source/lvgl/Lvgl.cpp b/Tactility/Source/lvgl/Lvgl.cpp index c0cb1a1d5..e8a456c3d 100644 --- a/Tactility/Source/lvgl/Lvgl.cpp +++ b/Tactility/Source/lvgl/Lvgl.cpp @@ -7,6 +7,7 @@ #endif #include +#include #include #include #include @@ -27,6 +28,7 @@ #include #include +#include #include #include #include @@ -233,9 +235,26 @@ bool isStarted() { return module_is_started(&lvgl_module); } +#define UI_ASSET_IMAGE(name) \ + lv_image_dsc_t { \ + .header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_RAW }, \ + .data_size = asset_ui_##name##_png_SIZE, \ + .data = asset_ui_##name##_png \ + } + +// Ordered by size, see LVGL_UI_SPINNER_COUNT and LVGL_UI_CURSOR_COUNT +static const lv_image_dsc_t spinnerImages[] = { + UI_ASSET_IMAGE(spinner_12), UI_ASSET_IMAGE(spinner_16), UI_ASSET_IMAGE(spinner_20), UI_ASSET_IMAGE(spinner_24), UI_ASSET_IMAGE(spinner_32), UI_ASSET_IMAGE(spinner_36), UI_ASSET_IMAGE(spinner_40) +}; +static const lv_image_dsc_t cursorImages[] = { + UI_ASSET_IMAGE(cursor_12), UI_ASSET_IMAGE(cursor_16), UI_ASSET_IMAGE(cursor_20), UI_ASSET_IMAGE(cursor_24), UI_ASSET_IMAGE(cursor_32), UI_ASSET_IMAGE(cursor_40) +}; + void start() { static bool configured = false; if (!configured) { + const LvglUiAssets uiAssets = { spinnerImages, cursorImages }; + lvgl_ui_assets_configure(&uiAssets); lvgl_module_configure((LvglModuleConfig) { .on_start = onLvglStarted, .on_stop = onLvglStopped, diff --git a/Tactility/Source/lvgl/SystemMenu.cpp b/Tactility/Source/lvgl/SystemMenu.cpp index bac24a907..b09d93b20 100644 --- a/Tactility/Source/lvgl/SystemMenu.cpp +++ b/Tactility/Source/lvgl/SystemMenu.cpp @@ -172,7 +172,7 @@ static void onToggleChanged(lv_event_t* event) { const auto* option = static_cast(lv_event_get_user_data(event)); const bool enabled = lv_obj_has_state(toggle, LV_STATE_CHECKED); lv_label_set_text(lv_obj_get_child(toggle, 0), getToggleIcon(option, enabled)); - dispatcher_dispatch_timed(getMainDispatcherHandle(), toContext(enabled), option->setEnabled, portMAX_DELAY); + tt::dispatchOnMainDispatcher(option->setEnabled, toContext(enabled)); } static void onCloseClicked(lv_event_t* /*event*/) { diff --git a/Tactility/Source/lvgl/TrackballInit.cpp b/Tactility/Source/lvgl/TrackballInit.cpp index 09789773a..767ccb81f 100644 --- a/Tactility/Source/lvgl/TrackballInit.cpp +++ b/Tactility/Source/lvgl/TrackballInit.cpp @@ -4,7 +4,7 @@ #include #include -#include +#include #include namespace tt::lvgl { @@ -29,7 +29,7 @@ static void initTrackball(lv_indev_t* indev, LvglTrackballSettings& settings) { lvgl_trackball_set_settings(indev, &settings); if (settings.mode == LVGL_TRACKBALL_MODE_POINTER) { - lvgl_trackball_set_cursor_image(indev, TT_ASSETS_UI_CURSOR); + lvgl_trackball_set_cursor_image(indev, lvgl_get_cursor_image()); } } diff --git a/Tactility/Source/lvgl/UsbHidInput.cpp b/Tactility/Source/lvgl/UsbHidInput.cpp index 88145c5dc..89a8e73bf 100644 --- a/Tactility/Source/lvgl/UsbHidInput.cpp +++ b/Tactility/Source/lvgl/UsbHidInput.cpp @@ -2,7 +2,7 @@ #ifdef ESP_PLATFORM -#include +#include #include #include @@ -441,7 +441,7 @@ void startUsbHidInput() { if (sys_layer != nullptr) { ctx->mouse_cursor = lv_image_create(sys_layer); lv_obj_set_clickable(ctx->mouse_cursor, false); - lv_image_set_src(ctx->mouse_cursor, TT_ASSETS_UI_CURSOR); + lv_image_set_src(ctx->mouse_cursor, lvgl_get_cursor_image()); lv_obj_set_hidden(ctx->mouse_cursor, true); } lvgl_unlock(); diff --git a/Tactility/Source/service/ServiceRegistration.cpp b/Tactility/Source/service/ServiceRegistration.cpp index 030669e82..3a01ecd81 100644 --- a/Tactility/Source/service/ServiceRegistration.cpp +++ b/Tactility/Source/service/ServiceRegistration.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -44,8 +45,9 @@ std::unordered_map& allocatedManifests() { // function-pointer types they're assigned to (see e.g. gpio_controller.cpp). extern "C" { +// Releases what ServiceManifest::createService returned, which must be allocated with tt::newObject() static void cppDestroyServiceTrampoline(const ::ServiceManifest* /*manifest*/, void* data) { - delete static_cast*>(data); + deleteObject(static_cast*>(data)); } static error_t cppOnStartTrampoline(::ServiceInstance* instance, void* data) { diff --git a/Tactility/Source/service/webserver/AssetVersion.cpp b/Tactility/Source/service/webserver/AssetVersion.cpp deleted file mode 100644 index 553008268..000000000 --- a/Tactility/Source/service/webserver/AssetVersion.cpp +++ /dev/null @@ -1,386 +0,0 @@ -#ifdef ESP_PLATFORM - -#include - -#include - -#include -#include -#include -#include -#include -#include - -#include - -namespace tt::service::webserver { - -constexpr auto* TAG = "AssetVersion"; -constexpr auto* DATA_VERSION_FILE = "/system/app/WebServer/version.json"; -constexpr auto* SD_VERSION_FILE = "/sdcard/tactility/webserver/version.json"; -constexpr auto* DATA_ASSETS_DIR = "/system/app/WebServer"; -constexpr auto* SD_ASSETS_DIR = "/sdcard/tactility/webserver"; - -static bool loadVersionFromFile(const char* path, AssetVersion& version) { - if (!file::isFile(path)) { - LOG_W(TAG, "Version file not found: %s", path); - return false; - } - - // Read file content - std::string content; - FILE* fp = fopen(path, "r"); - if (!fp) { - LOG_E(TAG, "Failed to open version file: %s", path); - return false; - } - - char buffer[256]; - size_t bytesRead = fread(buffer, 1, sizeof(buffer) - 1, fp); - bool readError = ferror(fp) != 0; - fclose(fp); - - if (readError) { - LOG_E(TAG, "Error reading version file: %s", path); - return false; - } - if (bytesRead == 0) { - LOG_E(TAG, "Version file is empty: %s", path); - return false; - } - buffer[bytesRead] = '\0'; - content = buffer; - - // Parse JSON - cJSON* json = cJSON_Parse(content.c_str()); - if (json == nullptr) { - LOG_E(TAG, "Failed to parse version JSON: %s", path); - return false; - } - - cJSON* versionItem = cJSON_GetObjectItem(json, "version"); - if (versionItem == nullptr || !cJSON_IsNumber(versionItem)) { - LOG_E(TAG, "Invalid version JSON format: %s", path); - cJSON_Delete(json); - return false; - } - - double versionValue = versionItem->valuedouble; - if (versionValue < 0 || versionValue > UINT32_MAX) { - LOG_E(TAG, "Version out of valid range [0, %u]: %s", (unsigned)UINT32_MAX, path); - cJSON_Delete(json); - return false; - } - version.version = static_cast(versionValue); - cJSON_Delete(json); - - LOG_I(TAG, "Loaded version %u from %s", (unsigned)version.version, path); - return true; -} - -static bool saveVersionToFile(const char* path, const AssetVersion& version) { - // Create directory if it doesn't exist - std::string dirPath(path); - size_t lastSlash = dirPath.find_last_of('/'); - if (lastSlash != std::string::npos) { - dirPath = dirPath.substr(0, lastSlash); - if (!file::isDirectory(dirPath.c_str())) { - if (!file::findOrCreateDirectory(dirPath.c_str(), 0755)) { - LOG_E(TAG, "Failed to create directory: %s", dirPath.c_str()); - return false; - } - } - } - - // Create JSON - cJSON* json = cJSON_CreateObject(); - if (json == nullptr) { - LOG_E(TAG, "Failed to create JSON object for version"); - return false; - } - cJSON_AddNumberToObject(json, "version", version.version); - - char* jsonString = cJSON_Print(json); - if (jsonString == nullptr) { - LOG_E(TAG, "Failed to serialize version JSON"); - cJSON_Delete(json); - return false; - } - - // Write to file - bool success = false; - FILE* fp = fopen(path, "w"); - if (fp) { - size_t len = strlen(jsonString); - size_t written = fwrite(jsonString, 1, len, fp); - success = (written == len); - if (success) { - if (fflush(fp) != 0) { - LOG_E(TAG, "Failed to flush version file: %s", path); - success = false; - } else { - int fd = fileno(fp); - if (fd >= 0 && fsync(fd) != 0) { - LOG_E(TAG, "Failed to fsync version file: %s", path); - success = false; - } - } - } - fclose(fp); - } - - cJSON_free(jsonString); - cJSON_Delete(json); - - if (success) { - LOG_I(TAG, "Saved version %u to %s", (unsigned)version.version, path); - } else { - LOG_E(TAG, "Failed to write version file: %s", path); - } - - return success; -} - -bool loadDataVersion(AssetVersion& version) { - return loadVersionFromFile(DATA_VERSION_FILE, version); -} - -bool loadSdVersion(AssetVersion& version) { - return loadVersionFromFile(SD_VERSION_FILE, version); -} - -bool saveDataVersion(const AssetVersion& version) { - return saveVersionToFile(DATA_VERSION_FILE, version); -} - -bool saveSdVersion(const AssetVersion& version) { - return saveVersionToFile(SD_VERSION_FILE, version); -} - -bool hasDataAssets() { - return file::isDirectory(DATA_ASSETS_DIR); -} - -bool hasSdAssets() { - return file::isDirectory(SD_ASSETS_DIR); -} - -static bool copyDirectory(const char* src, const char* dst, int depth = 0) { - constexpr int MAX_DEPTH = 16; - if (depth >= MAX_DEPTH) { - LOG_E(TAG, "Max directory depth exceeded: %s", src); - return false; - } - LOG_I(TAG, "Copying directory: %s -> %s", src, dst); - - // Create destination directory - if (!file::isDirectory(dst)) { - if (!file::findOrCreateDirectory(dst, 0755)) { - LOG_E(TAG, "Failed to create destination directory: %s", dst); - return false; - } - } - - // List source directory and copy each entry - bool copySuccess = true; - bool listSuccess = file::listDirectory(src, [&](const dirent& entry) { - // Skip "." and ".." entries (though listDirectory should already filter these) - if (strcmp(entry.d_name, ".") == 0 || strcmp(entry.d_name, "..") == 0) { - return; - } - - std::string srcPath = file::getChildPath(src, entry.d_name); - std::string dstPath = file::getChildPath(dst, entry.d_name); - - // Determine entry type - use stat() directly for unknown/unexpected d_type values - // (FAT/SD card filesystems often return non-standard d_type values) - // Note: We use stat() directly here instead of file::isDirectory/isFile to avoid - // deadlock, since listDirectory already holds a lock on the parent directory. - bool isDir = (entry.d_type == file::TT_DT_DIR); - bool isReg = (entry.d_type == file::TT_DT_REG); - if (!isDir && !isReg) { - struct stat st; - if (stat(srcPath.c_str(), &st) == 0) { - isDir = S_ISDIR(st.st_mode); - isReg = S_ISREG(st.st_mode); - } else { - LOG_W(TAG, "Failed to stat entry, skipping: %s", srcPath.c_str()); - return; - } - } - - if (isDir) { - // Recursively copy subdirectory - if (!copyDirectory(srcPath.c_str(), dstPath.c_str(), depth + 1)) { - copySuccess = false; - } - } else if (isReg) { - // Copy file - no additional locking needed since listDirectory already holds a lock - // and we're the only accessor during sync - std::string tempPath = std::format("{}.tmp.{}", dstPath, esp_random()); - - FILE* srcFile = fopen(srcPath.c_str(), "rb"); - if (!srcFile) { - LOG_E(TAG, "Failed to open source file: %s", srcPath.c_str()); - copySuccess = false; - return; - } - - FILE* tempFile = fopen(tempPath.c_str(), "wb"); - if (!tempFile) { - LOG_E(TAG, "Failed to create temp file: %s", tempPath.c_str()); - fclose(srcFile); - copySuccess = false; - return; - } - - // Copy in chunks (heap-allocated buffer to avoid stack overflow) - constexpr size_t COPY_BUF_SIZE = 4096; - auto buffer = std::make_unique(COPY_BUF_SIZE); - size_t bytesRead; - bool fileCopySuccess = true; - while ((bytesRead = fread(buffer.get(), 1, COPY_BUF_SIZE, srcFile)) > 0) { - size_t bytesWritten = fwrite(buffer.get(), 1, bytesRead, tempFile); - if (bytesWritten != bytesRead) { - LOG_E(TAG, "Failed to write to temp file: %s", tempPath.c_str()); - fileCopySuccess = false; - copySuccess = false; - break; - } - } - - if (fileCopySuccess && ferror(srcFile)) { - LOG_E(TAG, "Error reading source file: %s", srcPath.c_str()); - fileCopySuccess = false; - copySuccess = false; - } - - fclose(srcFile); - - // Flush and sync temp file before closing - if (fileCopySuccess) { - if (fflush(tempFile) != 0) { - LOG_E(TAG, "Failed to flush temp file: %s", tempPath.c_str()); - fileCopySuccess = false; - copySuccess = false; - } else { - int fd = fileno(tempFile); - if (fd >= 0 && fsync(fd) != 0) { - LOG_E(TAG, "Failed to fsync temp file: %s", tempPath.c_str()); - fileCopySuccess = false; - copySuccess = false; - } - } - } - - fclose(tempFile); - - if (fileCopySuccess) { - // Remove destination if it exists (rename may not overwrite on some filesystems) - remove(dstPath.c_str()); - // Rename temp file to destination - if (rename(tempPath.c_str(), dstPath.c_str()) != 0) { - LOG_E(TAG, "Failed to rename temp file %s to %s", tempPath.c_str(), dstPath.c_str()); - remove(tempPath.c_str()); - fileCopySuccess = false; - copySuccess = false; - } - } else { - // Clean up temp file on failure - remove(tempPath.c_str()); - } - - if (fileCopySuccess) { - LOG_I(TAG, "Copied file: %s", entry.d_name); - } - } - }); - - if (!listSuccess) { - LOG_E(TAG, "Failed to list source directory: %s", src); - return false; - } - - return copySuccess; -} - -bool syncAssets() { - LOG_I(TAG, "Starting asset synchronization..."); - - // Check if Data partition and SD card exist - bool dataExists = hasDataAssets(); - bool sdExists = hasSdAssets(); - - // FIRST BOOT SCENARIO: Data has version 0, SD card is missing - if (dataExists && !sdExists) { - LOG_I(TAG, "First boot - Data exists but SD card backup missing"); - LOG_W(TAG, "Skipping SD backup during boot - will be created on first settings save"); - LOG_W(TAG, "This avoids watchdog timeout if SD card is slow or corrupted"); - return true; // Don't block boot - defer copy to runtime - } - - // NO SD CARD: Just ensure Data has default structure - if (!sdExists) { - LOG_W(TAG, "No SD card available - creating default Data structure if needed"); - if (!dataExists) { - if (!file::findOrCreateDirectory(DATA_ASSETS_DIR, 0755)) { - LOG_E(TAG, "Failed to create Data assets directory"); - return false; - } - AssetVersion defaultVersion(0); // Start at version 0 - SD card updates will be version 1+ - if (!saveDataVersion(defaultVersion)) { - LOG_E(TAG, "Failed to save default Data version"); - return false; - } - LOG_I(TAG, "Created default Data assets structure (version 0)"); - } - return true; - } - - // NORMAL OPERATION: Both exist - compare versions - AssetVersion dataVersion, sdVersion; - bool hasDataVer = loadDataVersion(dataVersion); - bool hasSdVer = loadSdVersion(sdVersion); - - if (!hasDataVer) { - LOG_W(TAG, "No Data version.json - assuming version 0"); - dataVersion.version = 0; - if (!saveDataVersion(dataVersion)) { - LOG_W(TAG, "Failed to save default Data version (non-fatal)"); - } - } - - if (!hasSdVer) { - LOG_W(TAG, "No SD version.json - assuming version 0"); - sdVersion.version = 0; - // DON'T save to SD during boot - defer to runtime - LOG_W(TAG, "Skipping SD version.json creation during boot - will be created on first settings save"); - } - - LOG_I(TAG, "Version comparison - Data: %u, SD: %u", (unsigned)dataVersion.version, (unsigned)sdVersion.version); - - if (sdVersion.version > dataVersion.version) { - // Firmware update - copy SD -> Data - LOG_I(TAG, "SD card newer (v%u > v%u) - copying assets SD -> Data (firmware update)", - (unsigned)sdVersion.version, (unsigned)dataVersion.version); - if (!copyDirectory(SD_ASSETS_DIR, DATA_ASSETS_DIR)) { - LOG_E(TAG, "Failed to copy assets from SD to Data"); - return false; - } - LOG_I(TAG, "Firmware update complete - assets updated from SD card"); - } else if (dataVersion.version > sdVersion.version) { - // User customization - backup Data -> SD - LOG_W(TAG, "Data newer (v%u > v%u) - deferring SD backup to avoid boot watchdog", - (unsigned)dataVersion.version, (unsigned)sdVersion.version); - LOG_W(TAG, "SD backup will occur on first WebServer settings save"); - return true; // Don't block boot - defer copy to runtime - } else { - LOG_I(TAG, "Versions match (v%u) - no sync needed", (unsigned)dataVersion.version); - } - - return true; -} - -} // namespace - -#endif diff --git a/Tactility/Source/service/webserver/README.md b/Tactility/Source/service/webserver/README.md index a4839b3ad..aabfa9fb7 100644 --- a/Tactility/Source/service/webserver/README.md +++ b/Tactility/Source/service/webserver/README.md @@ -9,7 +9,7 @@ The WebServer service provides a built-in HTTP server for remote device manageme - **App Management**: List installed apps, run apps remotely, install/uninstall external apps - **WiFi Status**: View current WiFi connection details - **Screenshot Capture**: Capture the current display as a PNG -- **System Controls**: Sync assets, reboot device +- **System Controls**: Reboot device ## Enabling the WebServer @@ -286,12 +286,6 @@ Returns a tree structure of all mount points and their immediate contents. ### Admin Operations -#### POST /admin/sync - -Synchronizes web assets from the Data partition. - -**Response:** `Assets synchronized successfully` - #### POST /admin/reboot Reboots the device after a 1-second delay. @@ -300,133 +294,9 @@ Reboots the device after a 1-second delay. ## Static Assets -The WebServer serves static files from: +`dashboard.html`, `version.json` and `favicon.ico` are embedded in the firmware from `Data/system/app/WebServer/`. -1. **Primary**: `/data/webserver/` (internal flash) -2. **Fallback**: `/sdcard/tactility/webserver/` (SD card) - -The dashboard HTML file is served from these locations. If `dashboard.html` doesn't exist, `default.html` is served as a fallback. - -## Asset Synchronization - -The WebServer includes an asset synchronization system that keeps web assets in sync between the Data partition and SD card. This enables recovery after firmware updates and backup of user customizations. - -### Storage Locations - -| Location | Path | Purpose | -|----------|------|---------| -| Data Partition | `/data/webserver/` | Primary storage, served by WebServer | -| SD Card | `/sdcard/tactility/webserver/` | Backup storage for recovery | - -### Version Tracking - -Each storage location maintains a `version.json` file: - -```json -{ - "version": 1 -} -``` - -The version is an integer that increments when assets are updated. This allows the sync system to determine which location has newer assets. - -### Sync Scenarios - -The `syncAssets()` function handles several scenarios: - -#### First Boot (No SD Card Backup) -- **Condition**: Data partition has assets, SD card backup doesn't exist -- **Action**: Skip backup during boot to avoid watchdog timeout -- **Note**: SD backup is deferred to first settings save - -#### No SD Card Available -- **Condition**: SD card not mounted or unavailable -- **Action**: Create default Data structure with version 0 if needed -- **Note**: System operates normally without SD backup - -#### Post-Flash Recovery -- **Condition**: Data partition empty, SD card has backup -- **Action**: Copy entire SD backup to Data partition -- **Use Case**: Restoring assets after flashing new firmware that erased Data - -#### Firmware Update (SD Newer) -- **Condition**: SD version > Data version -- **Action**: Copy SD assets to Data partition -- **Use Case**: SD card contains newer assets from a firmware update package - -#### User Customization (Data Newer) -- **Condition**: Data version > SD version -- **Action**: Defer backup to avoid boot watchdog timeout -- **Note**: Backup occurs on next settings save or manual sync - -#### Versions Match -- **Condition**: Data version == SD version -- **Action**: No synchronization needed - -### Boot Watchdog Considerations - -Some sync operations are intentionally deferred during boot to avoid triggering the ESP32 watchdog timer: - -- **Deferred**: Copying from Data to SD (user customization backup) -- **Deferred**: Creating SD version.json -- **Immediate**: Copying from SD to Data (recovery and firmware update) - -This ensures the device boots reliably even with slow or corrupted SD cards. - -### Manual Synchronization - -#### Settings App -Navigate to **Settings > Web Server** and tap **"Sync Assets Now"** to manually trigger synchronization. - -#### API Endpoint -Send a POST request to `/admin/sync`: - -```bash -curl -X POST http:///admin/sync -``` - -**Response:** `Assets synchronized successfully` - -### Programmatic Access - -```cpp -#include - -// Check asset status -bool hasData = tt::service::webserver::hasDataAssets(); -bool hasSd = tt::service::webserver::hasSdAssets(); - -// Load versions -tt::service::webserver::AssetVersion dataVer, sdVer; -tt::service::webserver::loadDataVersion(dataVer); -tt::service::webserver::loadSdVersion(sdVer); - -// Trigger sync -bool success = tt::service::webserver::syncAssets(); -``` - -### Directory Structure - -```text -/data/webserver/ -├── version.json # Version tracking -├── dashboard.html # Main dashboard UI -└── ... # Other web assets - -/sdcard/tactility/webserver/ -├── version.json # Version tracking (backup) -├── dashboard.html # Dashboard backup -└── ... # Other web assets (backup) -``` - -### Updating Assets - -To update web assets with a new version: - -1. Place new assets in `/sdcard/tactility/webserver/` -2. Update `/sdcard/tactility/webserver/version.json` with a higher version number -3. Reboot the device or trigger manual sync -4. The sync system will detect the newer SD version and copy to Data +Other files are served from `/sdcard/tactility/webserver/` (SD card). ## Security Considerations @@ -489,10 +359,6 @@ The WebServer publishes events: The ESP-IDF HTTP server has a limit on URI handlers. The WebServer configures this dynamically based on the number of handlers needed, but if you see this error, check `CONFIG_HTTPD_MAX_URI_HANDLERS` in sdkconfig. -### 404 for dashboard.html - -Ensure the `dashboard.html` file exists in `/data/webserver/`. Run the asset sync operation or copy files manually. - ### Screenshot fails - Verify `TT_FEATURE_SCREENSHOT_ENABLED` is defined @@ -505,11 +371,3 @@ Ensure the `dashboard.html` file exists in `/data/webserver/`. Run the asset syn - Check file size is under 10MB limit - Verify the destination path is writable - Ensure the parent directory exists - -### Asset sync fails - -- Check SD card is properly mounted and writable -- Verify sufficient space on destination (Data or SD card) -- Check logs for specific file copy errors -- Maximum directory depth is 16 levels -- If sync hangs during boot, the SD card may be slow or corrupted diff --git a/Tactility/Source/service/webserver/WebServerService.cpp b/Tactility/Source/service/webserver/WebServerService.cpp index 48d1d6308..897289336 100644 --- a/Tactility/Source/service/webserver/WebServerService.cpp +++ b/Tactility/Source/service/webserver/WebServerService.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -618,7 +619,7 @@ static bool isAllowedBasePath(const std::string& path, bool allowRoot = false) { return false; } if (allowRoot && path == "/") return true; - return path.starts_with("/data") || path.starts_with("/system/app/WebServer") || path.starts_with("/sdcard"); + return path.starts_with("/data") || path.starts_with("/sdcard"); } // Normalize client-supplied path: URL-decode, trim quotes/control chars, ensure leading slash, collapse duplicate slashes @@ -777,7 +778,7 @@ error_t WebServerService::handleFsList(HttpServerRequest* request, void*) { file_system_for_each(&fs_iter_context, [] (auto* fs, void* context) { auto* fs_iter_context = static_cast(context); char path[128]; - if (file_system_is_mounted(fs) && file_system_get_path(fs, path, sizeof(path)) == ERROR_NONE && strcmp(path, "/system") != 0) { + if (file_system_is_mounted(fs) && file_system_get_path(fs, path, sizeof(path)) == ERROR_NONE) { fs_iter_context->count++; if (fs_iter_context->count != 1) fs_iter_context->json << ","; // add separator between json array entries fs_iter_context->json << "{\"name\":\"" << path << "\",\"type\":\"dir\",\"size\":0}"; @@ -1180,7 +1181,6 @@ error_t WebServerService::handleApiSysinfo(HttpServerRequest* request, void*) { file_system_for_each(&fs_iter_context, [] (auto* fs, void* context) { char mount_path[128] = ""; if (file_system_get_path(fs, mount_path, sizeof(mount_path)) != ERROR_NONE) return true; - if (strcmp(mount_path, "/system") == 0) return true; // Hide system partition bool mounted = file_system_is_mounted(fs); auto* fs_iter_context = static_cast(context); @@ -1746,6 +1746,15 @@ error_t WebServerService::handleReboot(HttpServerRequest* request, void*) { return ERROR_NONE; // Unreachable on ESP_PLATFORM, but satisfies function signature } +static error_t sendEmbeddedAsset(HttpServerRequest* request, const char* uri, const char* contentType, const uint8_t* data, size_t size) { + http_server_request_set_content_type(request, contentType); + const error_t error = http_server_request_send(request, data, size); + if (error == ERROR_NONE) { + LOG_I(TAG, "[200] %s (embedded)", uri); + } + return error; +} + error_t WebServerService::handleAssets(HttpServerRequest* request, void*) { // Auth check for UI access control bool authPassed = false; @@ -1758,76 +1767,22 @@ error_t WebServerService::handleAssets(HttpServerRequest* request, void*) { http_server_request_get_uri(request, uri, sizeof(uri)); LOG_I(TAG, "GET %s", uri); - // Special case: serve favicon from system assets - if (strcmp(uri, "/favicon.ico") == 0) { - std::string faviconPathStr = std::string(file::MOUNT_POINT_SYSTEM) + "/spinner.png"; - const char* faviconPath = faviconPathStr.c_str(); - if (file::isFile(faviconPath)) { - http_server_request_set_content_type(request, "image/png"); - http_server_request_set_header(request, "Cache-Control", "public, max-age=86400"); - - FILE* fp = fopen(faviconPath, "rb"); - if (fp) { - if (http_server_request_send_chunk_start(request) != ERROR_NONE) { - fclose(fp); - return ERROR_UNDEFINED; - } - char buffer[512]; - size_t bytesRead; - while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) { - if (http_server_request_send_chunk(request, buffer, bytesRead) != ERROR_NONE) { - fclose(fp); - return ERROR_UNDEFINED; - } - } - fclose(fp); - http_server_request_send_chunk_end(request); - LOG_I(TAG, "[200] %s (favicon)", uri); - return ERROR_NONE; - } - } - // If favicon not found, return 404 silently (browsers handle this gracefully) - http_server_request_send_error(request, 404, "Not found"); - return ERROR_UNDEFINED; - } - - // Special case: if requesting dashboard.html but it doesn't exist, serve default.html std::string requestedPath = normalizePath(uri); if (requestedPath == "/.." || requestedPath.ends_with("/..") || requestedPath.find("/../") != std::string::npos) { http_server_request_send_error(request, 400, "invalid path"); return ERROR_UNDEFINED; } - std::string dataPath = std::string(file::MOUNT_POINT_SYSTEM) + "/app/WebServer" + requestedPath; - - if (requestedPath == "/dashboard.html" && !file::isFile(dataPath.c_str())) { - LOG_I(TAG, "dashboard.html not found, serving default.html"); + // Embedded assets take priority over the SD card + if (requestedPath == "/favicon.ico") { + http_server_request_set_header(request, "Cache-Control", "public, max-age=86400"); + return sendEmbeddedAsset(request, uri, "image/png", asset_ui_spinner_32_png, asset_ui_spinner_32_png_size); } - - // Try to serve from Data partition first - if (file::isFile(dataPath.c_str())) { - http_server_request_set_content_type(request, getContentType(dataPath)); - - FILE* fp = fopen(dataPath.c_str(), "rb"); - if (fp) { - if (http_server_request_send_chunk_start(request) != ERROR_NONE) { - fclose(fp); - return ERROR_UNDEFINED; - } - char buffer[512]; - size_t bytesRead; - while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) { - if (http_server_request_send_chunk(request, buffer, bytesRead) != ERROR_NONE) { - fclose(fp); - return ERROR_UNDEFINED; - } - } - fclose(fp); - - http_server_request_send_chunk_end(request); // End of chunks - LOG_I(TAG, "[200] %s (from Data)", uri); - return ERROR_NONE; - } + if (requestedPath == "/dashboard.html") { + return sendEmbeddedAsset(request, uri, "text/html", asset_app_WebServer_dashboard_html, asset_app_WebServer_dashboard_html_size); + } + if (requestedPath == "/version.json") { + return sendEmbeddedAsset(request, uri, "application/json", asset_app_WebServer_version_json, asset_app_WebServer_version_json_size); } // Fallback to SD card diff --git a/Tactility/Tests/CMakeLists.txt b/Tactility/Tests/CMakeLists.txt index f9b11ccba..4ba660223 100644 --- a/Tactility/Tests/CMakeLists.txt +++ b/Tactility/Tests/CMakeLists.txt @@ -11,7 +11,27 @@ file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/Source/*. file(GLOB_RECURSE TACTILITY_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/../Source/*.c*) list(FILTER TACTILITY_SOURCES EXCLUDE REGEX ".*/Main\\.cpp$") -add_executable(TactilityTests EXCLUDE_FROM_ALL ${TEST_SOURCES} ${TACTILITY_SOURCES}) +# Tactility's embedded assets, see Tactility/CMakeLists.txt +set(GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/Generated") +file(MAKE_DIRECTORY "${GENERATED_DIR}") +add_custom_target(TestsGeneratedAssets + COMMAND python "${CMAKE_SOURCE_DIR}/Buildscripts/generate-assets.py" + "${CMAKE_SOURCE_DIR}/Data/system" "${GENERATED_DIR}" + BYPRODUCTS "${GENERATED_DIR}/generated_assets.c" "${GENERATED_DIR}/generated_assets.h" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" +) +add_custom_target(TestsGeneratedFonts + COMMAND python "${CMAKE_SOURCE_DIR}/Buildscripts/generate-fonts.py" + "${CMAKE_SOURCE_DIR}/Devices/simulator" 1 "${GENERATED_DIR}" + BYPRODUCTS "${GENERATED_DIR}/generated_fonts.c" "${GENERATED_DIR}/generated_fonts.h" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" +) +set_source_files_properties("${GENERATED_DIR}/generated_assets.c" PROPERTIES GENERATED TRUE) +set_source_files_properties("${GENERATED_DIR}/generated_fonts.c" PROPERTIES GENERATED TRUE) + +add_executable(TactilityTests EXCLUDE_FROM_ALL ${TEST_SOURCES} ${TACTILITY_SOURCES} "${GENERATED_DIR}/generated_assets.c" "${GENERATED_DIR}/generated_fonts.c") +add_dependencies(TactilityTests TestsGeneratedAssets TestsGeneratedFonts) +target_include_directories(TactilityTests PRIVATE "${GENERATED_DIR}") target_include_directories(TactilityTests PRIVATE ${DOCTESTINC}) target_include_directories(TactilityTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../Include) diff --git a/Tactility/Tests/Source/FontsTest.cpp b/Tactility/Tests/Source/FontsTest.cpp index e515d0de3..e8b7fd25a 100644 --- a/Tactility/Tests/Source/FontsTest.cpp +++ b/Tactility/Tests/Source/FontsTest.cpp @@ -1,10 +1,15 @@ #include "doctest.h" +#include +#include #include #include +#include #include +#include +#include #include using namespace tt; @@ -91,3 +96,35 @@ TEST_CASE("updateFontCache fails for a file that is not a TrueType font") { }; CHECK_FALSE(lvgl::updateFontCache(configuration)); } + +TEST_CASE("the firmware has the fonts of the default size, generated like the firmware does") { + // The simulator has size 14 and 2 bits per pixel + const auto file_name = lvgl::getCachedFontFileName("text_", "default", 14, 2, TT_TEXT_FONT_VERSION); + const uint8_t* embedded = nullptr; + size_t embedded_size = 0; + REQUIRE(lvgl::findGeneratedFont(file_name, embedded, embedded_size)); + CHECK(lvgl::findGeneratedFont(lvgl::getCachedFontFileName("mono_", "mono", 14, 2, TT_MONO_FONT_VERSION), embedded, embedded_size)); + CHECK_FALSE(lvgl::findGeneratedFont(lvgl::getCachedFontFileName("text_", "default", 15, 2, TT_TEXT_FONT_VERSION), embedded, embedded_size)); + + DataDirectory data_directory; + const BinFontGeneratorConfig config = { + .ttf_path = "system/fonts/AdwaitaSans.ttf", + .ttf_data = nullptr, + .ttf_size = 0, + .size = 14, + .bpp = 2, + .codepoints = nullptr, + .codepoint_count = 0, + }; + uint8_t* generated = nullptr; + size_t generated_size = 0; + REQUIRE_EQ(binfont_generate(&config, &generated, &generated_size), ERROR_NONE); + REQUIRE(lvgl::findGeneratedFont(file_name, embedded, embedded_size)); + CHECK_EQ(generated_size, embedded_size); + CHECK_EQ(memcmp(generated, embedded, embedded_size), 0); + memory_free(generated); + + BinFont* font = nullptr; + REQUIRE_EQ(binfont_open_memory(embedded, embedded_size, false, &font), ERROR_NONE); + binfont_close(font); +} diff --git a/TactilityKernel/include/tactility/file_ops.h b/TactilityKernel/include/tactility/file_ops.h new file mode 100644 index 000000000..8e5811436 --- /dev/null +++ b/TactilityKernel/include/tactility/file_ops.h @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * A function that runs on the file operations task. + * It may block on file and flash access, which tasks and callbacks with a stack in external RAM or the LVGL task must not do. + * Results are passed back through the context, which must stay valid until the function has completed. + */ +typedef void (*file_ops_work_fn_t)(void* context); + +/** + * Caller-owned subscription that reports when queued work has completed. + * Same subscribe/await/poll shape as http_download and app events: the subscription claims a bit of a + * TaskEventGroup that is signalled whenever work queued with this subscription completes. + * @warning Fields other than `bit` are for internal use only. + */ +struct FileOpsSubscription { + /** Set by file_ops_subscribe(). Read-only for the caller: OR it into a task_event_group_wait() mask + * to wait for completion together with other event sources. */ + uint32_t bit; + + struct { + /** Caller-owned, borrowed. Set by file_ops_subscribe(). */ + struct TaskEventGroup* event_group; + struct FileOpsLink* link; + } internal; +}; + +/** + * Registers a subscription. + * @warning Does not work in ISR context. + * @param[in,out] sub the subscription, which the caller owns and must keep alive and stationary until file_ops_unsubscribe() + * @param[in] event_group caller-owned group that must outlive @a sub + * @retval ERROR_NONE on success + * @retval ERROR_RESOURCE @a event_group has no free bits left to claim + * @retval ERROR_OUT_OF_MEMORY the subscription's internal state could not be allocated + */ +error_t file_ops_subscribe(struct FileOpsSubscription* sub, struct TaskEventGroup* event_group); + +/** + * Removes a subscription. Safe to call at any time, including while work queued with it hasn't run yet: the + * work still runs, but the subscription and its event group are never touched again once this returns. + * @warning Does not work in ISR context. + * @retval ERROR_NONE on success + * @retval ERROR_NOT_FOUND @a sub is not subscribed + */ +error_t file_ops_unsubscribe(struct FileOpsSubscription* sub); + +/** + * Queues work to run on the file operations task, in the order that it was queued. + * The task is created when work is queued and no task is running, and it ends when the queue is empty. + * @param[in] work the function to run + * @param[in] context passed to @a work, must stay valid until the work has completed + * @param[in] sub a subscription that is notified when the work completed, or NULL + * @retval ERROR_NONE the work was queued + * @retval ERROR_INVALID_ARGUMENT @a sub is not subscribed + * @retval ERROR_OUT_OF_MEMORY the queue entry could not be allocated + * @retval ERROR_RESOURCE the task could not be started + */ +error_t file_ops_queue(file_ops_work_fn_t work, void* context, struct FileOpsSubscription* sub); + +/** + * Non-blocking: consumes one completion of work that was queued with @a sub. + * @retval ERROR_NONE a completion was consumed + * @retval ERROR_TIMEOUT no queued work has completed + */ +error_t file_ops_poll(struct FileOpsSubscription* sub); + +/** + * Waits until work that was queued with @a sub has completed, and consumes one completion. + * Returns immediately when a completion is already available. + * @param[in] timeout the maximum time to wait + * @retval ERROR_NONE a completion was consumed + * @retval ERROR_TIMEOUT no queued work completed within @a timeout + */ +error_t file_ops_await(struct FileOpsSubscription* sub, TickType_t timeout); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/source/file_ops.cpp b/TactilityKernel/source/file_ops.cpp new file mode 100644 index 000000000..a71dbb979 --- /dev/null +++ b/TactilityKernel/source/file_ops.cpp @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include +#include +#include + +#include + +#define TAG "file_ops" + +// Refcounted: one share for the subscriber, one for each queued item that references it. +// Lets file_ops_unsubscribe() sever the subscription at any time, also while work is still queued. +struct FileOpsLink { + Mutex mutex; + uint32_t owners = 1; + bool subscribed = true; + uint32_t completed = 0; + + FileOpsLink() { mutex_construct(&mutex); } + ~FileOpsLink() { mutex_destruct(&mutex); } +}; + +namespace { + +// Large enough for the file and settings helpers and for loading apps. It must be in internal RAM to access flash +constexpr size_t WORKER_STACK_SIZE = 8192; + +struct Item { + file_ops_work_fn_t work; + void* context; + FileOpsSubscription* subscription; + FileOpsLink* link; + Item* next; +}; + +// Small enough to stay in internal RAM, which is also where its mutex must be +struct State { + Mutex mutex; + Item* head = nullptr; + Item* tail = nullptr; + bool running = false; + // The worker that has ended or is ending, as it can't free itself + Thread* stale = nullptr; + + State() { mutex_construct(&mutex); } +}; + +State& state() { + static State instance; + return instance; +} + +const MemoryPolicy ITEM_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + +void release_link(FileOpsLink* link) { + mutex_lock(&link->mutex); + const bool last = (--link->owners == 0); + mutex_unlock(&link->mutex); + if (last) { + delete link; + } +} + +void complete(Item* item) { + FileOpsLink* link = item->link; + if (link == nullptr) { + return; + } + mutex_lock(&link->mutex); + if (link->subscribed) { + link->completed++; + task_event_group_signal(item->subscription->internal.event_group, item->subscription->bit); + } + const bool last = (--link->owners == 0); + mutex_unlock(&link->mutex); + if (last) { + delete link; + } +} + +Item* pop_or_stop(State& s) { + mutex_lock(&s.mutex); + Item* item = s.head; + if (item != nullptr) { + s.head = item->next; + if (s.head == nullptr) { + s.tail = nullptr; + } + } else { + s.running = false; + } + mutex_unlock(&s.mutex); + return item; +} + +int32_t worker_main(void* context) { + auto& s = *static_cast(context); + while (Item* item = pop_or_stop(s)) { + item->work(item->context); + complete(item); + memory_free(item); + } + return 0; +} + +// Must be called with the state mutex held and no worker running +bool start_worker(State& s) { + if (s.stale != nullptr) { + thread_join(s.stale, portMAX_DELAY, 1); + thread_free(s.stale); + s.stale = nullptr; + } + + Thread* thread = thread_alloc_full("file_ops", WORKER_STACK_SIZE, worker_main, &s, -1); + if (thread == nullptr) { + return false; + } + s.running = true; + if (thread_start(thread) != ERROR_NONE) { + s.running = false; + thread_free(thread); + return false; + } + s.stale = thread; + return true; +} + +} + +extern "C" { + +error_t file_ops_subscribe(FileOpsSubscription* sub, TaskEventGroup* event_group) { + uint32_t bit; + const error_t claim_result = task_event_group_claim_bit(event_group, &bit); + if (claim_result != ERROR_NONE) { + return claim_result; + } + + auto* link = new (std::nothrow) FileOpsLink(); + if (link == nullptr) { + task_event_group_release_bit(event_group, bit); + return ERROR_OUT_OF_MEMORY; + } + + sub->bit = bit; + sub->internal.event_group = event_group; + sub->internal.link = link; + return ERROR_NONE; +} + +error_t file_ops_unsubscribe(FileOpsSubscription* sub) { + FileOpsLink* link = sub->internal.link; + if (link == nullptr) { + return ERROR_NOT_FOUND; + } + // Caller-exclusive: the worker never reads this field + sub->internal.link = nullptr; + + mutex_lock(&link->mutex); + // The worker reads these under the same lock while subscribed is true + TaskEventGroup* event_group = sub->internal.event_group; + const uint32_t bit = sub->bit; + sub->internal.event_group = nullptr; + link->subscribed = false; + const bool last = (--link->owners == 0); + mutex_unlock(&link->mutex); + + // Nothing signals the bit anymore, as decided in the critical section above + task_event_group_release_bit(event_group, bit); + + if (last) { + delete link; + } + return ERROR_NONE; +} + +error_t file_ops_queue(file_ops_work_fn_t work, void* context, FileOpsSubscription* sub) { + FileOpsLink* link = nullptr; + if (sub != nullptr) { + link = sub->internal.link; + if (link == nullptr) { + return ERROR_INVALID_ARGUMENT; + } + } + + auto* item = static_cast(memory_alloc_with_policy(sizeof(Item), &ITEM_POLICY)); + if (item == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + *item = { work, context, sub, link, nullptr }; + + if (link != nullptr) { + mutex_lock(&link->mutex); + link->owners++; + mutex_unlock(&link->mutex); + } + + auto& s = state(); + mutex_lock(&s.mutex); + if (s.tail != nullptr) { + s.tail->next = item; + } else { + s.head = item; + } + s.tail = item; + + if (!s.running && !start_worker(s)) { + // The queue was empty before this item, because no worker was running + s.head = nullptr; + s.tail = nullptr; + mutex_unlock(&s.mutex); + LOG_E(TAG, "Failed to start the file operations task"); + if (link != nullptr) { + release_link(link); + } + memory_free(item); + return ERROR_RESOURCE; + } + mutex_unlock(&s.mutex); + return ERROR_NONE; +} + +error_t file_ops_poll(FileOpsSubscription* sub) { + FileOpsLink* link = sub->internal.link; + if (link == nullptr) { + return ERROR_TIMEOUT; + } + + mutex_lock(&link->mutex); + const bool available = link->completed > 0; + if (available) { + link->completed--; + } + mutex_unlock(&link->mutex); + return available ? ERROR_NONE : ERROR_TIMEOUT; +} + +error_t file_ops_await(FileOpsSubscription* sub, TickType_t timeout) { + if (file_ops_poll(sub) == ERROR_NONE) { + return ERROR_NONE; + } + const TickType_t start = get_ticks(); + while (true) { + const TickType_t elapsed = get_ticks() - start; + if (elapsed >= timeout) { + return ERROR_TIMEOUT; + } + if (task_event_group_wait(sub->internal.event_group, sub->bit, false, nullptr, timeout - elapsed) != ERROR_NONE) { + return ERROR_TIMEOUT; + } + if (file_ops_poll(sub) == ERROR_NONE) { + return ERROR_NONE; + } + } +} + +} diff --git a/TactilityKernel/source/symbols.c b/TactilityKernel/source/symbols.c index f26bea837..7a78089bb 100644 --- a/TactilityKernel/source/symbols.c +++ b/TactilityKernel/source/symbols.c @@ -48,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -518,6 +519,12 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(usb_midi_device_send), DEFINE_MODULE_SYMBOL(usb_midi_device_is_connected), DEFINE_MODULE_SYMBOL(USB_MIDI_DEVICE_TYPE), + // file_ops + DEFINE_MODULE_SYMBOL(file_ops_subscribe), + DEFINE_MODULE_SYMBOL(file_ops_unsubscribe), + DEFINE_MODULE_SYMBOL(file_ops_queue), + DEFINE_MODULE_SYMBOL(file_ops_poll), + DEFINE_MODULE_SYMBOL(file_ops_await), // concurrent/dispatcher DEFINE_MODULE_SYMBOL(dispatcher_alloc), DEFINE_MODULE_SYMBOL(dispatcher_free), diff --git a/TactilityKernel/tests/source/file_ops_test.cpp b/TactilityKernel/tests/source/file_ops_test.cpp new file mode 100644 index 000000000..361995883 --- /dev/null +++ b/TactilityKernel/tests/source/file_ops_test.cpp @@ -0,0 +1,153 @@ +#include "doctest.h" + +#include +#include + +#include + +namespace { + +struct Recorder { + std::atomic order[8]; + std::atomic count { 0 }; + /** Set after the last write of the work, so a test can wait for the work to finish with the recorder */ + std::atomic done { 0 }; +}; + +struct Job { + Recorder* recorder; + int id; +}; + +void record(void* context) { + auto* job = static_cast(context); + const int index = job->recorder->count.fetch_add(1); + job->recorder->order[index] = job->id; + job->recorder->done.fetch_add(1); +} + +void block_briefly(void* context) { + delay_ticks(5); + record(context); +} + +} + +TEST_CASE("file_ops_await returns when the queued work has completed") { + TaskEventGroup group {}; + task_event_group_construct(&group); + FileOpsSubscription sub {}; + REQUIRE_EQ(file_ops_subscribe(&sub, &group), ERROR_NONE); + + Recorder recorder; + Job job { &recorder, 1 }; + CHECK_EQ(file_ops_queue(record, &job, &sub), ERROR_NONE); + CHECK_EQ(file_ops_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); + CHECK_EQ(recorder.count.load(), 1); + CHECK_EQ(file_ops_poll(&sub), ERROR_TIMEOUT); + + file_ops_unsubscribe(&sub); + task_event_group_destruct(&group); +} + +TEST_CASE("file_ops runs work in the order it was queued, with one completion for each") { + TaskEventGroup group {}; + task_event_group_construct(&group); + FileOpsSubscription sub {}; + REQUIRE_EQ(file_ops_subscribe(&sub, &group), ERROR_NONE); + + Recorder recorder; + Job jobs[3] = { { &recorder, 1 }, { &recorder, 2 }, { &recorder, 3 } }; + for (auto& job : jobs) { + CHECK_EQ(file_ops_queue(block_briefly, &job, &sub), ERROR_NONE); + } + for (int i = 0; i < 3; i++) { + CHECK_EQ(file_ops_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); + } + CHECK_EQ(recorder.order[0].load(), 1); + CHECK_EQ(recorder.order[1].load(), 2); + CHECK_EQ(recorder.order[2].load(), 3); + CHECK_EQ(file_ops_poll(&sub), ERROR_TIMEOUT); + + file_ops_unsubscribe(&sub); + task_event_group_destruct(&group); +} + +TEST_CASE("file_ops starts a new task after the queue was emptied") { + TaskEventGroup group {}; + task_event_group_construct(&group); + FileOpsSubscription sub {}; + REQUIRE_EQ(file_ops_subscribe(&sub, &group), ERROR_NONE); + + Recorder recorder; + for (int i = 0; i < 3; i++) { + Job job { &recorder, i }; + CHECK_EQ(file_ops_queue(record, &job, &sub), ERROR_NONE); + CHECK_EQ(file_ops_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); + delay_ticks(5); + } + CHECK_EQ(recorder.count.load(), 3); + + file_ops_unsubscribe(&sub); + task_event_group_destruct(&group); +} + +TEST_CASE("file_ops runs work without a subscription") { + TaskEventGroup group {}; + task_event_group_construct(&group); + FileOpsSubscription sub {}; + REQUIRE_EQ(file_ops_subscribe(&sub, &group), ERROR_NONE); + + Recorder recorder; + Job first { &recorder, 1 }; + Job last { &recorder, 2 }; + CHECK_EQ(file_ops_queue(block_briefly, &first, nullptr), ERROR_NONE); + CHECK_EQ(file_ops_queue(record, &last, &sub), ERROR_NONE); + CHECK_EQ(file_ops_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); + CHECK_EQ(recorder.count.load(), 2); + + file_ops_unsubscribe(&sub); + task_event_group_destruct(&group); +} + +TEST_CASE("file_ops_unsubscribe is safe while work is still queued") { + TaskEventGroup group {}; + task_event_group_construct(&group); + FileOpsSubscription sub {}; + REQUIRE_EQ(file_ops_subscribe(&sub, &group), ERROR_NONE); + + Recorder recorder; + Job job { &recorder, 1 }; + CHECK_EQ(file_ops_queue(block_briefly, &job, &sub), ERROR_NONE); + CHECK_EQ(file_ops_unsubscribe(&sub), ERROR_NONE); + CHECK_EQ(file_ops_unsubscribe(&sub), ERROR_NOT_FOUND); + + // The bit is free again immediately, and the work still runs + uint32_t bit; + CHECK_EQ(task_event_group_claim_bit(&group, &bit), ERROR_NONE); + task_event_group_release_bit(&group, bit); + while (recorder.done.load() == 0) { + delay_ticks(1); + } + + task_event_group_destruct(&group); +} + +TEST_CASE("file_ops_queue rejects a subscription that isn't subscribed") { + FileOpsSubscription sub {}; + Recorder recorder; + Job job { &recorder, 1 }; + CHECK_EQ(file_ops_queue(record, &job, &sub), ERROR_INVALID_ARGUMENT); +} + +TEST_CASE("file_ops_await times out when nothing was queued") { + TaskEventGroup group {}; + task_event_group_construct(&group); + FileOpsSubscription sub {}; + REQUIRE_EQ(file_ops_subscribe(&sub, &group), ERROR_NONE); + + CHECK_EQ(file_ops_await(&sub, pdMS_TO_TICKS(20)), ERROR_TIMEOUT); + + file_ops_unsubscribe(&sub); + task_event_group_destruct(&group); +} diff --git a/TactilityKernelCpp/Include/TactilityCpp/std/unordered_map.h b/TactilityKernelCpp/Include/TactilityCpp/std/unordered_map.h new file mode 100644 index 000000000..905bfbaca --- /dev/null +++ b/TactilityKernelCpp/Include/TactilityCpp/std/unordered_map.h @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include + +namespace tt::std { + +/** std::unordered_map that prefers external memory (PSRAM) and falls back to internal RAM */ +template, typename KeyEqual = ::std::equal_to> +using unordered_map = ::std::unordered_map>>; + +} // namespace tt::std diff --git a/Translations/generate-all.py b/Translations/generate-all.py index 477cc00e4..655800900 100644 --- a/Translations/generate-all.py +++ b/Translations/generate-all.py @@ -28,10 +28,3 @@ if __name__ == "__main__": "tt::app::launcher::i18n", "Data/system/app/Launcher/i18n" ) - # LocaleSettings app - generate( - "LocaleSettings.csv", - "Tactility/Private/Tactility/app/localesettings/TextResources.h", - "tt::app::localesettings::i18n", - "Data/system/app/LocaleSettings/i18n" - ) diff --git a/partitions-16mb-no-sd-dev.csv b/partitions-16mb-no-sd-dev.csv index d6fe4a7df..b3516bbbd 100644 --- a/partitions-16mb-no-sd-dev.csv +++ b/partitions-16mb-no-sd-dev.csv @@ -3,5 +3,4 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 6M, -system, data, fat, , 192k, data, data, fat, , 1000k, diff --git a/partitions-16mb-no-sd.csv b/partitions-16mb-no-sd.csv index 28ec04007..b8260bcfc 100644 --- a/partitions-16mb-no-sd.csv +++ b/partitions-16mb-no-sd.csv @@ -3,5 +3,4 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 6M, -system, data, fat, , 192k, data, data, fat, , 9800k, diff --git a/partitions-16mb-with-sd.csv b/partitions-16mb-with-sd.csv index d4f56f9da..2ef1a3e71 100644 --- a/partitions-16mb-with-sd.csv +++ b/partitions-16mb-with-sd.csv @@ -3,4 +3,3 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 6M, -system, data, fat, , 192k, diff --git a/partitions-32mb-no-sd-dev.csv b/partitions-32mb-no-sd-dev.csv index d6fe4a7df..b3516bbbd 100644 --- a/partitions-32mb-no-sd-dev.csv +++ b/partitions-32mb-no-sd-dev.csv @@ -3,5 +3,4 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 6M, -system, data, fat, , 192k, data, data, fat, , 1000k, diff --git a/partitions-32mb-no-sd.csv b/partitions-32mb-no-sd.csv index b204a7f68..16669cb61 100644 --- a/partitions-32mb-no-sd.csv +++ b/partitions-32mb-no-sd.csv @@ -3,5 +3,4 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 6M, -system, data, fat, , 192k, data, data, fat, , 25M, diff --git a/partitions-4mb-with-sd.csv b/partitions-4mb-with-sd.csv index 475936d70..cd0f4d3a8 100644 --- a/partitions-4mb-with-sd.csv +++ b/partitions-4mb-with-sd.csv @@ -2,5 +2,4 @@ # Offsets are left blank so the build auto-places partitions relative to CONFIG_PARTITION_TABLE_OFFSET (see device.py), including on targets where that offset is bumped to fit a larger bootloader. nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, -factory, app, factory, , 3800k, -system, data, fat, , 192k, +factory, app, factory, , 4032k, diff --git a/partitions-8mb-no-sd-dev.csv b/partitions-8mb-no-sd-dev.csv index 37bf2e4a7..7dbd688d6 100644 --- a/partitions-8mb-no-sd-dev.csv +++ b/partitions-8mb-no-sd-dev.csv @@ -3,5 +3,4 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 4M, -system, data, fat, , 192k, data, data, fat, , 1000k, diff --git a/partitions-8mb-no-sd.csv b/partitions-8mb-no-sd.csv index 01b559389..b085085a8 100644 --- a/partitions-8mb-no-sd.csv +++ b/partitions-8mb-no-sd.csv @@ -3,5 +3,4 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 4M, -system, data, fat, , 192k, data, data, fat, , 3736k, diff --git a/partitions-8mb-with-sd.csv b/partitions-8mb-with-sd.csv index 722a96baf..352e9f0b6 100644 --- a/partitions-8mb-with-sd.csv +++ b/partitions-8mb-with-sd.csv @@ -3,4 +3,3 @@ nvs, data, nvs, , 0x6000, phy_init, data, phy, , 0x1000, factory, app, factory, , 4M, -system, data, fat, , 192k,