Compare commits

...

2 Commits

Author SHA1 Message Date
Ken Van Hoeylandt
21b100b789
Refactor SDL/FreeRTOS implementation to support macOS simulator properly (#653)
- Run SDL from main() and place FreeRTOS in separate thread. This fixes macOS support.
- Updated GitHub Actions to publish macOS simulator build for testing, updated amd64 to x86_64 for consistent naming.
2026-09-20 23:12:42 +02:00
Ken Van Hoeylandt
89f1804184
Async keyboard API and device probing support (#654)
- Implemented async keyboard API. Made it backwards compatible.
- `KeyboardApi::read_key` and `keyboard_read_key()` automatically forward to subscribing pollers.
- Implement `Device::probe` for device hot-plug support.
- Implement probing for Tab5 keyboard
- Updated coding style guide
2026-09-20 17:31:07 +02:00
45 changed files with 1616 additions and 706 deletions

View File

@ -2,8 +2,8 @@
Two conventions coexist; which one to use depends on the project layer:
- **C code** (TactilityKernel, drivers): `lower_snake_case` for files, functions, variables. `UpperCamelCase` for types. Files in `source/`, `include/`, `private/` directories.
- **C++ code** (Tactility, apps, services): `UpperCamelCase` for files and types. `lowerCamelCase` for functions. Files in `Source/`, `Include/`, `Private/` directories.
- **C code**: `lower_snake_case` for files, functions, variables. `UpperCamelCase` for types. Files in `source/`, `include/`, `private/` directories.
- **C++ code**: `UpperCamelCase` for files and types. `lowerCamelCase` for functions. Files in `Source/`, `Include/`, `Private/` directories.
For projects that emit C headers and have a C++ implementation file: the internal C++ function naming should be snake_case.
@ -11,24 +11,10 @@ Formatting is enforced by `.clang-format` (LLVM-based, 4-space indent, no column
Never throw exceptions — use return types for error handling. Use `enum class` over plain `enum` when writing C++ code.
Do not add redundant null checks for parameters with an explicit non-null precondition.
Code Comments:
Code Comments (important!):
- Should be as short as possible, leaving only important context.
- Should avoid explaining what the code does, unless the code complexity is high enough to warrant an explanation.
- Must avoid explaining how the code was before, or how it was changed.
- Should explain why code is implemented.
- Should be as brief as possible without losing critical information.
- Should be minimal, but must contain critical information.
- Must not explain how code was before, or how it was changed.
- Should avoid explaining what was not implemented.
- Should avoid referring to designs of other subsystems.
- Must avoid interjections: avoid hyphens or braces to interject. If interjections provide crucial info, use Doxygen entity/anchor references like:
/**
* A dedicated completion \signal for one app instance's task.
* Whichever \side finishes with it last is the one that deletes `semaphore` and frees this struct.
*
* \signal Not the task's shared default FreeRTOS notification, which app_event.cpp's AppEventSubscription also uses.
* An unrelated event delivered to the same task could otherwise unblock a waiter early.
* \side The exiting task or a concurrent app_scheduler_stop() that found the entry in time and is waiting on `semaphore`.
*/
```
- Must avoid referring to designs of other subsystems.
- Must avoid interjections: avoid hyphens or braces to interject.

View File

@ -4,11 +4,8 @@ inputs:
os_name:
description: A descriptive name for the operating system (e.g. linux, windows)
required: true
platform_name:
description: A descriptive name for the target platform (e.g. amd64, aarch64, etc.)
required: true
publish:
description: A boolean that enables publishing of artifacts
architecture:
description: A descriptive name for the target architecture (e.g. x86_64, aarch64, etc.)
required: true
runs:
@ -55,11 +52,10 @@ runs:
run: cmake --build buildsim --target Tactility
- name: 'Release'
shell: bash
run: Buildscripts/release-simulator.sh buildsim release/Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
run: Buildscripts/release-simulator.sh buildsim release/Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
if: ${{ inputs.publish == 'true' }}
with:
name: Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
path: release/Simulator-${{ inputs.os_name }}-${{ inputs.platform_name }}
name: Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
path: release/Simulator-${{ inputs.os_name }}-${{ inputs.architecture }}
retention-days: 30

View File

@ -17,8 +17,7 @@ jobs:
uses: ./.github/actions/build-simulator
with:
os_name: linux
platform_name: amd64
publish: true
architecture: x86_64
macOS:
runs-on: macos-latest
steps:
@ -29,6 +28,4 @@ jobs:
uses: ./.github/actions/build-simulator
with:
os_name: macos
platform_name: aarch64
# macOS simulator currently fails due to main thread requirement for rendering
publish: false
architecture: aarch64

View File

@ -62,9 +62,13 @@ if (DEFINED ENV{ESP_IDF_VERSION})
# to every target; PanicHandler.cpp branches internally per architecture.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_panic_handler" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=read" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=write" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=close" APPEND)
# Wraps newlib's reentrant syscall stubs, not the plain read()/write()/close() newlib itself
# implements as thin wrappers around them. newlib's own stdio (fflush()'s buffer-flush path in particular)
# calls these _r stubs directly, bypassing the plain names entirely.
# See Modules/app-module/source/stdio_wrap.cpp's own comment for the exact call chain.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_read_r" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_write_r" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_close_r" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_button_create" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_dropdown_create" APPEND)

View File

@ -9,7 +9,7 @@ constexpr auto* TAG = "Tab5";
// Mirrors device_construct_add_start(), but with a device_set_parent() call inserted between
// construct and add - device_set_parent() asserts on device->internal (only valid after
// construct), and device_construct_add_start() doesn't expose a hook to call it before add().
bool construct_add_start(Device* device, Device* parent, const char* compatible) {
bool construct_add(Device* device, Device* parent, const char* compatible) {
error_t error = device_construct(device);
if (error != ERROR_NONE) {
LOG_E(TAG, "display_detect: failed to construct %s: %s", device->name, error_to_string(error));
@ -33,7 +33,15 @@ bool construct_add_start(Device* device, Device* parent, const char* compatible)
return false;
}
error = device_start(device);
return true;
}
bool construct_add_start(Device* device, Device* parent, const char* compatible) {
if (!construct_add(device, parent, compatible)) {
return false;
}
error_t error = device_start(device);
if (error != ERROR_NONE) {
LOG_E(TAG, "display_detect: failed to start %s: %s", device->name, error_to_string(error));
device_remove(device);

View File

@ -10,6 +10,10 @@
// device_construct_add_start(), but with a device_set_parent() call inserted between construct and add
bool construct_add_start(Device* device, Device* parent, const char* compatible);
// Same as construct_add_start(), but doesn't start the device - for a device registered with
// device_hotplug_register() instead, whose driver has a Driver::probe deciding if/when it starts.
bool construct_add(Device* device, Device* parent, const char* compatible);
// Unpacks a vendor-typed init-cmd array (ili9881c_lcd_init_cmd_t / st7123_lcd_init_cmd_t - same
// field layout) into the flattened [cmd, data_len, delay_ms, data_len bytes...] byte encoding that
// ili9881c-module/st7123-module's init-sequence config field expects (the same encoding a

View File

@ -16,6 +16,7 @@
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <cstdlib>
#include <cstring>
@ -262,9 +263,9 @@ static bool write_reg_fast(Device* device, uint8_t reg, uint8_t value) {
return i2c_controller_write_register(parent, I2C_ADDRESS, reg, &value, 1, pdMS_TO_TICKS(2)) == ERROR_NONE;
}
bool tab5_keyboard_is_attached(Device* device) {
static error_t probe(Device* device) {
auto* parent = device_get_parent(device);
return i2c_controller_has_device_at_address(parent, I2C_ADDRESS, pdMS_TO_TICKS(5)) == ERROR_NONE;
return i2c_controller_has_device_at_address(parent, I2C_ADDRESS, pdMS_TO_TICKS(5));
}
// ---------------------------------------------------------------------------
@ -462,7 +463,7 @@ static void drain_events(Device* device, Tab5KeyboardInternal* internal) {
// mode and interrupt configuration are volatile and reset to power-on defaults when the keyboard
// is unplugged and reconnected.
// ---------------------------------------------------------------------------
void tab5_keyboard_reinit(Device* device) {
static void tab5_keyboard_reinit(Device* device) {
auto* internal = static_cast<Tab5KeyboardInternal*>(device_get_driver_data(device));
write_reg_fast(device, REG_KEYBOARD_MODE, 0x00); // Normal mode
write_reg_fast(device, REG_EVENT_NUM, 0x00); // flush event queue
@ -476,7 +477,7 @@ void tab5_keyboard_reinit(Device* device) {
}
}
void tab5_keyboard_reset_state(Device* device) {
static void tab5_keyboard_reset_state(Device* device) {
auto* internal = static_cast<Tab5KeyboardInternal*>(device_get_driver_data(device));
for (uint8_t idx = 0; idx < 70U; idx++) {
@ -594,28 +595,26 @@ static error_t start(Device* device) {
// device_get_driver_data().
device_set_driver_data(device, internal);
// This device is constructed speculatively at boot so it can be hot-plug-detected later - if
// the keyboard isn't physically attached yet, skip reinit here (tab5_keyboard_attach_detect.cpp
// calls it again once attach is confirmed) rather than issuing register writes that are certain
// to fail: unlike tab5_keyboard_is_attached()'s plain probe, write_register() logs at error
// level on failure (see esp32_i2c_master.cpp), which would be misleading noise for what's just
// "not plugged in yet".
if (tab5_keyboard_is_attached(device)) {
tab5_keyboard_reinit(device);
}
// Driver::probe (tab5_keyboard_is_attached) already confirmed presence before driver_bind()
// called this.
tab5_keyboard_reinit(device);
return ERROR_NONE;
}
// How long to give LVGL's own indev timer to drain the release events tab5_keyboard_reset_state()
// pushes below before the queue carrying them is deleted - longer than LVGL's default indev poll
// period so a key held across an unplug doesn't read as stuck forever.
static constexpr uint32_t STOP_DRAIN_GRACE_MS = 50;
static error_t stop(Device* device) {
auto* internal = static_cast<Tab5KeyboardInternal*>(device_get_driver_data(device));
remove_irq_pin(internal);
write_reg(device, REG_INT_CFG, 0x00); // disable all interrupts
internal->sym_active = false;
internal->aa_sticky = false;
internal->aa_held = false;
update_leds(device, internal); // turn LEDs off
tab5_keyboard_reset_state(device);
vTaskDelay(pdMS_TO_TICKS(STOP_DRAIN_GRACE_MS));
vQueueDelete(internal->queue);
free(internal);
@ -659,7 +658,6 @@ static error_t tab5_keyboard_read_key(Device* device, KeyboardKeyData* data) {
static const KeyboardApi tab5_keyboard_api = {
.read_key = tab5_keyboard_read_key,
.get_backlight = nullptr,
.is_present = tab5_keyboard_is_attached,
};
// Defined in module.cpp - this driver is registered directly by m5stack-tab5's own module,
@ -671,6 +669,7 @@ Driver tab5_keyboard_driver = {
.compatible = (const char*[]) { "m5stack,tab5-keyboard", nullptr },
.start_device = start,
.stop_device = stop,
.probe = probe,
.api = &tab5_keyboard_api,
.device_type = &KEYBOARD_TYPE,
.owner = &m5stack_tab5_module,
@ -684,10 +683,10 @@ static Device tab5_keyboard_device {};
// The keyboard accessory is a kernel driver device (m5stack,tab5-keyboard, defined directly in
// this project). Unlike the display/touch, it isn't gated on the display-variant detection at
// all (it lives on i2c2, a separate bus) - lvgl-module binds its indev unconditionally at boot
// regardless of physical attach state. Hot-plug attach/detach handling (register reinit, LVGL
// rotation) lives in tab5_keyboard_attach_detect.cpp, not this driver - see module.cpp for where
// that gets started.
// all (it lives on i2c2, a separate bus). Registered with the kernel's hotplug poller instead of
// started here - Driver::probe (tab5_keyboard_is_attached) decides when it starts/stops. LVGL
// rotation-on-attach lives in tab5_keyboard_attach_detect.cpp, not this driver - see module.cpp
// for where that gets started.
void tab5_create_keyboard(Device* i2c2) {
tab5_keyboard_device = Device {
.address = 0,
@ -715,7 +714,9 @@ void tab5_create_keyboard(Device* i2c2) {
// Parented to i2c2 itself (not root, unlike the display): the keyboard driver's start() uses
// device_get_parent() as its I2C bus controller.
construct_add_start(&tab5_keyboard_device, i2c2, "m5stack,tab5-keyboard");
if (construct_add(&tab5_keyboard_device, i2c2, "m5stack,tab5-keyboard")) {
device_hotplug_register(&tab5_keyboard_device);
}
}
// endregion

View File

@ -11,38 +11,23 @@ extern "C" {
#include <tactility/drivers/gpio.h>
struct Tab5KeyboardConfig {
// Fixed 0x6D on this keyboard accessory - kept as a field for parity/documentation with
// other dynamically-constructed devices in this project, not used to probe the bus.
/** Fixed 0x6D - not used to probe the bus, kept only for parity with other dynamic devices. */
uint8_t address;
// Native SoC GPIO (e.g. gpio0 pin 50) wired directly to the keyboard's INT line - not routed
// through an IO expander, since it needs a real hardware ISR for responsive key events.
// GPIO_PIN_SPEC_NONE falls back to polling REG_INT_STAT instead.
/**
* Wired directly to a native SoC GPIO (not an IO expander) for a real ISR.
* GPIO_PIN_SPEC_NONE falls back to polling REG_INT_STAT.
*/
struct GpioPinSpec pin_interrupt;
};
extern struct Driver tab5_keyboard_driver;
// Constructs and starts the keyboard accessory device on i2c2. Called from display_detect.cpp's
// on_display_detect_event() once i2c2 is up.
/**
* @brief Constructs (but doesn't start) the keyboard device on i2c2 and registers it with the
* kernel's hotplug poller. Called once i2c2 is up (see display_detect.cpp).
*/
void tab5_create_keyboard(struct Device* i2c2);
// Returns true if the keyboard accessory currently ACKs on the I2C bus. Cheap bus probe, no
// debouncing - callers wanting hot-plug-stable state (e.g. tab5_keyboard_attach_detect.cpp)
// should debounce across their own polling interval.
bool tab5_keyboard_is_attached(struct Device* device);
// (Re)applies the device's register configuration - RGB mode, brightness, interrupt config, LED
// state. Volatile on this chip: reset to power-on defaults whenever the keyboard is unplugged and
// reconnected, so callers must call this again after confirming a reattach (see
// tab5_keyboard_attach_detect.cpp).
void tab5_keyboard_reinit(struct Device* device);
// Emits a release for every currently-held key (a hardware release can't arrive once the keyboard
// is unplugged), then clears held-key/software-repeat/modifier state. Callers must call this on
// confirmed detach (see tab5_keyboard_attach_detect.cpp) so a key held across an unplug doesn't
// leave consumers with a stuck key or spurious repeats/modifiers after reattach.
void tab5_keyboard_reset_state(struct Device* device);
#ifdef __cplusplus
}
#endif

View File

@ -1,173 +0,0 @@
#include "tab5_keyboard_attach_detect.h"
#include "tab5_keyboard.h"
#include <tactility/device.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl.h>
#include <freertos/FreeRTOS.h>
#include <freertos/timers.h>
constexpr auto* TAG = "Tab5";
// Hot-plug attach-state check interval. I2C probes can false-positive on a floating/half-connected
// bus (e.g. mid-unplug), so a state change is only acted on once it's seen on two consecutive
// checks in a row.
constexpr auto ATTACH_CHECK_INTERVAL_MS = 1000;
static TimerHandle_t attach_detect_timer = nullptr;
static bool was_attached = false;
static bool pending_attach_state = false;
static uint8_t pending_attach_confirm_count = 0;
// Tracks LVGL's own readiness so a restart (lvgl_is_running() going from false back to true -
// e.g. an app that took over the display for direct rendering, stopping and letting LVGL rebind)
// can be told apart from the keyboard itself attaching/detaching. See apply_state()'s comment for
// why that distinction matters.
static bool was_lvgl_ready = false;
static lv_display_rotation_t saved_rotation = LV_DISPLAY_ROTATION_0;
static bool rotation_override_active = false;
// Applies the current attach state to LVGL (landscape rotation while attached, restoring the
// prior rotation on detach unless the user changed it manually since attaching) and to the
// keyboard device's own register state (reinit on attach - RGB mode/interrupt config are volatile
// across an unplug/replug on this chip). Ported as-is from the deprecated HAL's
// Tab5Keyboard::applyAutoRotation() / the pre-refactor tab5_keyboard.cpp driver logic.
// @return true once handled; false to be retried on the next tick (e.g. LVGL lock busy).
static bool apply_state(Device* keyboard_device, bool attached) {
if (!lvgl_try_lock(pdMS_TO_TICKS(100))) {
return false; // retry next tick
}
// Resolved inside the lock, not before: the default display can start/stop between an
// unlocked probe and actually taking the lock, and lv_display_get_default() itself isn't
// safe to call without holding it (unlike lvgl_is_running(), used for the readiness check in
// attach_detect_callback()).
auto* display = lv_display_get_default();
if (display == nullptr) {
lvgl_unlock();
return false; // LVGL not ready yet - retry next tick
}
if (attached) {
tab5_keyboard_reinit(keyboard_device);
if (lv_display_get_rotation(display) != LV_DISPLAY_ROTATION_90) {
saved_rotation = lv_display_get_rotation(display);
rotation_override_active = true;
lv_display_set_rotation(display, LV_DISPLAY_ROTATION_90);
}
} else {
// A key held at the moment of unplug can never get its hardware release - reset tracked
// state so it doesn't read as stuck or feed stale modifiers into whatever's pressed next
// after reattach.
tab5_keyboard_reset_state(keyboard_device);
// Only restore if rotation is still what we set it to - if the user manually changed it
// since attaching, respect their choice instead.
if (rotation_override_active && lv_display_get_rotation(display) == LV_DISPLAY_ROTATION_90) {
lv_display_set_rotation(display, saved_rotation);
}
rotation_override_active = false;
}
lvgl_unlock();
return true;
}
static void attach_detect_callback(TimerHandle_t /*timer*/) {
Device* keyboard_device = nullptr;
if (device_get_by_name("keyboard0", &keyboard_device) != ERROR_NONE) {
return; // Not constructed yet - will retry on next tick
}
// LVGL restarting is a distinct event from the keyboard physically attaching/detaching: the
// accessory may never have moved, but whatever apply_state() last set (rotation) may have
// been reset in the meantime by the restart. Forcing was_attached false makes the block below
// see a fresh "attached" transition, still going through the normal two-check debounce, so
// apply_state() re-announces the current state instead of staying silent forever, waiting for
// an edge that will never come because the keyboard was never actually unplugged.
// lvgl_is_running() is safe to call unlocked (unlike lv_display_get_default(), resolved inside
// the lock in apply_state() instead).
const bool lvgl_ready = lvgl_is_running();
if (lvgl_ready && !was_lvgl_ready) {
// If the keyboard is (and, per was_attached, already was) physically detached, forcing
// was_attached false below means the detach transition below will never fire again for
// this unplug - it already happened before this restart. Reset software state here
// instead, since apply_state()'s own detach path may never have run: it could have bailed
// out early (LVGL lock busy, or display not ready yet) before reaching its
// tab5_keyboard_reset_state() call, and now never will, because that transition is about
// to be erased. This reset doesn't touch LVGL/display state, so it doesn't need
// apply_state()'s lock/display gating.
if (was_attached && !tab5_keyboard_is_attached(keyboard_device)) {
tab5_keyboard_reset_state(keyboard_device);
}
was_attached = false;
pending_attach_confirm_count = 0;
}
was_lvgl_ready = lvgl_ready;
const bool attached = tab5_keyboard_is_attached(keyboard_device);
if (attached != was_attached) {
// Require the new state to be confirmed on a second consecutive check before acting: a
// single probe on a floating/half-connected bus (e.g. mid-unplug) can false-positive.
if (attached != pending_attach_state || pending_attach_confirm_count == 0) {
pending_attach_state = attached;
pending_attach_confirm_count = 1;
} else {
pending_attach_confirm_count = 0;
if (apply_state(keyboard_device, attached)) {
was_attached = attached;
}
// else: not handled yet (e.g. LVGL lock busy); retry on the next confirmed check
}
} else {
pending_attach_confirm_count = 0;
}
device_put(keyboard_device);
}
void tab5_keyboard_attach_detect_start() {
if (attach_detect_timer != nullptr) {
LOG_W(TAG, "keyboard attach-detect timer already running");
return;
}
was_attached = false;
pending_attach_confirm_count = 0;
was_lvgl_ready = false;
rotation_override_active = false;
attach_detect_timer = xTimerCreate("kb_attach_detect", pdMS_TO_TICKS(ATTACH_CHECK_INTERVAL_MS), pdTRUE, nullptr, attach_detect_callback);
if (!attach_detect_timer) {
LOG_E(TAG, "Failed to create keyboard attach-detect timer");
return;
}
if (xTimerStart(attach_detect_timer, pdMS_TO_TICKS(100)) != pdPASS) {
LOG_E(TAG, "Failed to start keyboard attach-detect timer");
xTimerDelete(attach_detect_timer, pdMS_TO_TICKS(100));
attach_detect_timer = nullptr;
}
}
void tab5_keyboard_attach_detect_stop() {
if (attach_detect_timer == nullptr) {
return;
}
if (xTimerStop(attach_detect_timer, pdMS_TO_TICKS(100)) != pdPASS) {
LOG_W(TAG, "Failed to stop keyboard attach-detect timer");
}
if (xTimerDelete(attach_detect_timer, pdMS_TO_TICKS(100)) != pdPASS) {
LOG_E(TAG, "Failed to delete keyboard attach-detect timer");
}
// Always clear the handle - stale non-null handle is worse than a resource leak, as it would
// cause tab5_keyboard_attach_detect_start() to silently skip re-creating the timer.
attach_detect_timer = nullptr;
}

View File

@ -1,12 +0,0 @@
#pragma once
// Starts/stops the periodic keyboard-accessory hot-plug poll: reapplies register configuration on
// reattach (see tab5_keyboard_reinit()) and switches LVGL to landscape while attached, restoring
// the prior rotation on detach - unless the user changed it manually since attaching, in which
// case their choice is respected. Also re-announces the current attach state after LVGL itself
// restarts (e.g. an app that took over the display for direct rendering, stopping and letting
// LVGL rebind), since that's a distinct event from the keyboard physically attaching/detaching -
// the accessory may never have moved, but the rotation override it applied may have been reset in
// the meantime. Called from module.cpp's start()/stop().
void tab5_keyboard_attach_detect_start();
void tab5_keyboard_attach_detect_stop();

View File

@ -0,0 +1,58 @@
#include "tab5_keyboard_lvgl_rotation.h"
#include <tactility/device.h>
#include <tactility/device_listener.h>
#include <lvgl/lvgl.h>
#include <lvgl.h>
static lv_display_rotation_t saved_rotation = LV_DISPLAY_ROTATION_0;
static bool rotation_override_active = false;
static void apply_rotation(bool attached) {
lvgl_lock();
auto* display = lv_display_get_default();
if (display == nullptr) {
lvgl_unlock();
return;
}
if (attached) {
if (lv_display_get_rotation(display) != LV_DISPLAY_ROTATION_90) {
saved_rotation = lv_display_get_rotation(display);
rotation_override_active = true;
lv_display_set_rotation(display, LV_DISPLAY_ROTATION_90);
}
} else if (rotation_override_active) {
// Ownership of the rotation ends here regardless - only restore it if the display is
// still at what we set it to (the user may have changed it manually while attached).
if (lv_display_get_rotation(display) == LV_DISPLAY_ROTATION_90) {
lv_display_set_rotation(display, saved_rotation);
}
rotation_override_active = false;
}
lvgl_unlock();
}
static void on_device_event(Device* device, DeviceEvent event, void*) {
if (!device_is_compatible(device, "m5stack,tab5-keyboard")) {
return;
}
if (event == DEVICE_EVENT_STARTED) {
apply_rotation(true);
} else if (event == DEVICE_EVENT_STOPPED) {
apply_rotation(false);
}
}
void tab5_keyboard_lvgl_rotation_start() {
rotation_override_active = false;
device_listener_add(on_device_event, nullptr);
}
void tab5_keyboard_lvgl_rotation_stop() {
apply_rotation(false);
device_listener_remove(on_device_event);
}

View File

@ -0,0 +1,8 @@
#pragma once
// Applies landscape rotation while the keyboard accessory ("keyboard0") is started, restoring the
// prior rotation on stop - unless the user changed it manually since attaching, in which case
// their choice is respected. Start/stop are now driven by the kernel's hotplug poller (see
// tab5_keyboard.cpp's Driver::probe), not polled here. Called from module.cpp's start()/stop().
void tab5_keyboard_lvgl_rotation_start();
void tab5_keyboard_lvgl_rotation_stop();

View File

@ -13,7 +13,7 @@
#include "devices/detect.h"
#include "devices/tab5_headphone_detect.h"
#include "devices/tab5_keyboard.h"
#include "devices/tab5_keyboard_attach_detect.h"
#include "devices/tab5_keyboard_lvgl_rotation.h"
#include "devices/tab5_power_control.h"
#include "devices/tab_5_camera.h"
@ -70,12 +70,12 @@ static error_t start() {
tab5_camera_init();
device_listener_add(on_io_expander0_started, nullptr);
tab5_headphone_detect_start();
tab5_keyboard_attach_detect_start();
tab5_keyboard_lvgl_rotation_start();
return ERROR_NONE;
}
static error_t stop() {
tab5_keyboard_attach_detect_stop();
tab5_keyboard_lvgl_rotation_stop();
tab5_headphone_detect_stop();
device_listener_remove(on_io_expander0_started);
tab5_detect_stop();

View File

@ -10,13 +10,13 @@ constexpr auto* TAG = "FreeRTOS";
namespace simulator {
MainFunction mainFunction = nullptr;
static MainFunction mainFunction = nullptr;
void setMain(MainFunction newMainFunction) {
mainFunction = newMainFunction;
}
static void freertosMainTask(void* parameter) {
static void freertosMainTask(void*) {
LOG_I(TAG, "starting app_main()");
assert(simulator::mainFunction);
mainFunction();

View File

@ -1,6 +1,11 @@
#pragma once
#include "Main.h"
#include "drivers/sdl_bridge.h"
#include <csignal>
#include <pthread.h>
#include <thread>
namespace simulator {
/** Set the function pointer of the real app_main() */
@ -14,8 +19,23 @@ void app_main(); // ESP-IDF's main function, implemented in the application
}
int main() {
// Actual main function that passes on app_main() (to be executed in a FreeRTOS task) and bootstraps FreeRTOS
// 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)
// A signal-generated SIGALRM can land on any thread in the process that doesn't block it.
// This thread stays a plain OS thread (running the SDL loop below, never a FreeRTOS task),
// so without this it's eligible to catch a tick SIGALRM and freeze inside the scheduler's handler.
// Block the same set here, before anything else, so it never can.
sigset_t all_signals_except_sigint;
sigfillset(&all_signals_except_sigint);
sigdelset(&all_signals_except_sigint, SIGINT);
pthread_sigmask(SIG_SETMASK, &all_signals_except_sigint, nullptr);
// FreeRTOS and app_main() run on a separate thread: macOS requires SDL/Cocoa window creation,
// event pumping and rendering to happen on the real OS main thread, which sdl_bridge_run_main_loop()
// below takes over. freertosMain() never returns, so this thread is detached rather than joined.
simulator::setMain(app_main);
simulator::freertosMain();
std::thread(simulator::freertosMain).detach();
sdl_bridge_run_main_loop();
return 0;
}

View File

@ -0,0 +1,64 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_bridge.h"
#include "sdl_input.h"
#include <tactility/error.h>
#include <chrono>
#include <condition_variable>
#include <mutex>
namespace {
struct PresentJob {
Device* device;
void* internal;
int32_t x_start;
int32_t y_start;
int32_t x_end;
int32_t y_end;
const void* color_data;
error_t result;
};
std::mutex job_mutex;
std::condition_variable job_ready_cv;
std::condition_variable job_done_cv;
bool job_pending = false;
bool job_done = false;
PresentJob pending_job;
}
error_t sdl_bridge_present(Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
std::unique_lock<std::mutex> lock(job_mutex);
pending_job = { device, internal, x_start, y_start, x_end, y_end, color_data, ERROR_NONE };
job_pending = true;
job_done = false;
job_ready_cv.notify_one();
job_done_cv.wait(lock, [] { return job_done; });
return pending_job.result;
}
void sdl_bridge_run_main_loop() {
while (true) {
sdl_input_pump();
std::unique_lock<std::mutex> lock(job_mutex);
if (job_ready_cv.wait_for(lock, std::chrono::milliseconds(1), [] { return job_pending; })) {
PresentJob job = pending_job;
lock.unlock();
job.result = sdl_display_execute_draw_bitmap(job.device, job.internal, job.x_start, job.y_start, job.x_end, job.y_end, job.color_data);
lock.lock();
pending_job.result = job.result;
job_pending = false;
job_done = true;
lock.unlock();
job_done_cv.notify_one();
}
}
}

View File

@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/error.h>
#include <stdint.h>
struct Device;
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Runs forever, pumping SDL input and executing display present jobs submitted via
* sdl_bridge_present(). Must be called exactly once, from the real OS main thread: macOS requires
* SDL/Cocoa window creation, event pumping and rendering to happen there, but FreeRTOS tasks
* (including the lvgl task that owns display flush and indev polling) run on separate pthreads
* spawned by the FreeRTOS POSIX port, not on that thread.
*/
void sdl_bridge_run_main_loop(void);
/**
* @brief Hands a display flush off to the main thread and blocks until it has finished copying
* the pixel data out (see sdl_display_execute_draw_bitmap() in sdl_display.cpp). Called from the
* lvgl task. Must block: the caller's pixel buffer is single-buffered and gets reused as soon as
* this returns.
*/
error_t sdl_bridge_present(struct Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
/**
* @brief Implemented in sdl_display.cpp: the actual SDL work behind a display flush (lazy window
* init on first call, SDL_UpdateTexture, present). Only ever called from sdl_bridge_run_main_loop()
* on the main thread.
*/
error_t sdl_display_execute_draw_bitmap(struct Device* device, void* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
#ifdef __cplusplus
}
#endif

View File

@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
#include "sdl_display.h"
#include "sdl_bridge.h"
#include <tactility/device.h>
#include <tactility/driver.h>
@ -141,8 +142,10 @@ static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal)
return true;
}
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
// Only ever called from sdl_bridge_run_main_loop() on the real main thread - required for
// SDL/Cocoa window creation and rendering on macOS.
error_t sdl_display_execute_draw_bitmap(Device* device, void* internal_ptr, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(internal_ptr);
if (internal->init_failed) {
return ERROR_RESOURCE;
@ -166,6 +169,16 @@ static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t
return ERROR_NONE;
}
static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<SdlDisplayInternal*>(device_get_driver_data(device));
if (internal->init_failed) {
return ERROR_RESOURCE;
}
return sdl_bridge_present(device, internal, x_start, y_start, x_end, y_end, color_data);
}
static enum DisplayColorFormat sdl_display_get_color_format(Device*) {
return DISPLAY_COLOR_FORMAT_RGB565;
}

View File

@ -7,11 +7,16 @@
#include <SDL2/SDL.h>
#include <cstdlib>
#include <mutex>
namespace {
constexpr size_t KEY_QUEUE_CAPACITY = 32;
// Written by sdl_input_pump() on the real main thread, read by sdl_input_get_pointer_state()/
// sdl_input_pop_key()/sdl_input_has_queued_key() on the lvgl task.
std::mutex state_mutex;
SdlPointerState pointer_state = { 0, 0, false };
uint32_t key_queue[KEY_QUEUE_CAPACITY];
@ -73,62 +78,78 @@ uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) {
}
void sdl_input_pump() {
if (!text_input_started) {
SDL_StartTextInput();
text_input_started = true;
// exit() must run with state_mutex unlocked: it never returns, so a lock_guard held across it
// would never release the mutex, hanging any other thread that later calls into this file's
// other functions (all of which lock state_mutex) while exit() tears the process down.
bool quit_requested = false;
{
std::lock_guard<std::mutex> lock(state_mutex);
if (!text_input_started) {
SDL_StartTextInput();
text_input_started = true;
}
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_MOUSEMOTION:
set_pointer_position(event.motion.x, event.motion.y);
break;
case SDL_MOUSEBUTTONDOWN:
if (event.button.button == SDL_BUTTON_LEFT) {
// event.button.x/y can be stale immediately after a window resize (an
// SDL/X11 event-queue quirk - confirmed by comparing against a live
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
// OS for the current pointer position directly, sidestepping that entirely.
int live_x, live_y;
SDL_GetMouseState(&live_x, &live_y);
set_pointer_position(live_x, live_y);
pointer_state.pressed = true;
}
break;
case SDL_MOUSEBUTTONUP:
if (event.button.button == SDL_BUTTON_LEFT) {
pointer_state.pressed = false;
}
break;
case SDL_KEYDOWN:
push_key(keycode_to_key(event.key.keysym.sym, (event.key.keysym.mod & KMOD_SHIFT) != 0));
break;
case SDL_TEXTINPUT:
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
push_key(static_cast<uint8_t>(event.text.text[0]));
break;
case SDL_WINDOWEVENT:
// Resizing doesn't change what LVGL last rendered, only how large it should
// appear - re-present the existing frame at the new scale immediately, rather
// than leaving stale-looking content on screen until the next LVGL-driven flush.
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
sdl_display_present_now();
}
break;
case SDL_QUIT:
quit_requested = true;
break;
default:
break;
}
}
}
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_MOUSEMOTION:
set_pointer_position(event.motion.x, event.motion.y);
break;
case SDL_MOUSEBUTTONDOWN:
if (event.button.button == SDL_BUTTON_LEFT) {
// event.button.x/y can be stale immediately after a window resize (an
// SDL/X11 event-queue quirk - confirmed by comparing against a live
// SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the
// OS for the current pointer position directly, sidestepping that entirely.
int live_x, live_y;
SDL_GetMouseState(&live_x, &live_y);
set_pointer_position(live_x, live_y);
pointer_state.pressed = true;
}
break;
case SDL_MOUSEBUTTONUP:
if (event.button.button == SDL_BUTTON_LEFT) {
pointer_state.pressed = false;
}
break;
case SDL_KEYDOWN:
push_key(keycode_to_key(event.key.keysym.sym, (event.key.keysym.mod & KMOD_SHIFT) != 0));
break;
case SDL_TEXTINPUT:
// ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard.
push_key(static_cast<uint8_t>(event.text.text[0]));
break;
case SDL_WINDOWEVENT:
// Resizing doesn't change what LVGL last rendered, only how large it should
// appear - re-present the existing frame at the new scale immediately, rather
// than leaving stale-looking content on screen until the next LVGL-driven flush.
if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
sdl_display_present_now();
}
break;
case SDL_QUIT:
exit(0);
default:
break;
}
if (quit_requested) {
exit(0);
}
}
void sdl_input_get_pointer_state(SdlPointerState* out_state) {
std::lock_guard<std::mutex> lock(state_mutex);
*out_state = pointer_state;
}
bool sdl_input_pop_key(uint32_t* out_key) {
std::lock_guard<std::mutex> lock(state_mutex);
if (key_queue_count == 0) {
return false;
}
@ -139,5 +160,6 @@ bool sdl_input_pop_key(uint32_t* out_key) {
}
bool sdl_input_has_queued_key() {
std::lock_guard<std::mutex> lock(state_mutex);
return key_queue_count > 0;
}

View File

@ -19,9 +19,9 @@ struct SdlPointerState {
/**
* @brief Drains all pending SDL events exactly once, updating the pointer state and key queue
* below. Safe to call from both the sdl-pointer and sdl-keyboard drivers' polling functions:
* SDL_PollEvent() drains a single global queue, so whichever driver is polled first on a given
* LVGL indev tick pumps events for both.
* below. Must be called only from the real OS main thread (sdl_bridge_run_main_loop()): SDL
* requires event pumping to happen there on macOS. The getters below are safe to call from a
* different thread (the lvgl task, via sdl-pointer/sdl-keyboard's polling functions).
*/
void sdl_input_pump(void);

View File

@ -16,8 +16,6 @@ static error_t stop(Device*) { return ERROR_NONE; }
// region KeyboardApi
static error_t sdl_keyboard_read_key(Device*, KeyboardKeyData* data) {
sdl_input_pump();
uint32_t key = 0;
if (sdl_input_pop_key(&key)) {
data->key = key;

View File

@ -16,7 +16,6 @@ static error_t stop(Device*) { return ERROR_NONE; }
// region PointerApi
static error_t sdl_pointer_read_data(Device*, TickType_t) {
sdl_input_pump();
return ERROR_NONE;
}

View File

@ -10,15 +10,29 @@ tactility_add_module(app-module
INCLUDE_DIRS include/
REQUIRES TactilityKernel service-module minitar
PRIV_REQUIRES TactilityKernelCpp
WHOLE_ARCHIVE
)
# Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close()
# rather than calling ::read/::write/::close() directly, on every platform whose build wraps
# those symbols (ESP-IDF always; POSIX except macOS, whose linker doesn't support --wrap) -
# see Tactility/CMakeLists.txt and the top-level CMakeLists.txt for where that's applied.
if (NOT APPLE)
# Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close() rather
# than calling ::read/::write/::close() directly, on every platform where those are wrapped: via
# -Wl,--wrap= on ESP-IDF (see the top-level CMakeLists.txt) and non-Apple POSIX (below), or via
# source/stdio_wrap.cpp's dyld interpose on Apple (whose linker lacks --wrap).
tactility_get_module_name(app-module MODULE_NAME)
target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO)
# Routes every read()/write()/close() and printf-family call to source/stdio_wrap.cpp's __wrap_*
# functions; see that file for what they do and why. ESP-IDF wraps the same symbols itself (see
# the top-level CMakeLists.txt); Apple gets the same routing via dyld interpose instead, self-
# registered in that file. PUBLIC so every consumer of this module (Tactility, and any test target
# that links app-module directly) gets the flags too.
if (NOT ESP_PLATFORM AND NOT APPLE)
tactility_get_module_name(app-module MODULE_NAME)
target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO)
target_link_options(${MODULE_NAME} PUBLIC
"-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
# install.cpp resolves an installed binary's fixed bin/<platform>/<binary>.so path itself, so it

View File

@ -8,13 +8,42 @@
#include <cerrno>
#if defined(TT_APP_IO_WRAPS_STDIO)
#ifdef ESP_PLATFORM
// Newlib's stdio (fflush()'s buffer-flush path, in particular) calls the reentrant _read_r/
// _write_r/_close_r syscall stubs directly. The plain read()/write()/close() newlib provides are
// just thin wrappers around them (esp-idf's components/newlib/src/syscalls.c: `write(fd, dst,
// size) { return _write_r(__getreent(), fd, dst, size); }`). Wrapping the _r stubs catches both;
// wrapping the plain names would only catch direct write()-style callers.
#include <reent.h>
extern "C" {
ssize_t __real__read_r(struct _reent* r, int fd, void* buffer, size_t size);
ssize_t __real__write_r(struct _reent* r, int fd, const void* buffer, size_t size);
int __real__close_r(struct _reent* r, int fd);
}
namespace {
ssize_t real_read(int fd, void* buffer, size_t size) { return __real__read_r(__getreent(), fd, buffer, size); }
ssize_t real_write(int fd, const void* buffer, size_t size) { return __real__write_r(__getreent(), fd, buffer, size); }
int real_close(int fd) { return __real__close_r(__getreent(), fd); }
} // namespace
#else
extern "C" {
ssize_t __real_read(int fd, void* buffer, size_t size);
ssize_t __real_write(int fd, const void* buffer, size_t size);
int __real_close(int fd);
}
namespace {
ssize_t real_read(int fd, void* buffer, size_t size) { return __real_read(fd, buffer, size); }
ssize_t real_write(int fd, const void* buffer, size_t size) { return __real_write(fd, buffer, size); }
int real_close(int fd) { return __real_close(fd); }
} // namespace
#endif
#else
#include <unistd.h>
namespace {
ssize_t real_read(int fd, void* buffer, size_t size) { return ::read(fd, buffer, size); }
ssize_t real_write(int fd, const void* buffer, size_t size) { return ::write(fd, buffer, size); }
int real_close(int fd) { return ::close(fd); }
} // namespace
#endif
namespace {
@ -56,11 +85,7 @@ ssize_t app_io_read(int fd, void* buffer, size_t size) {
errno = EBADF;
return -1;
}
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_read(fd, buffer, size);
#else
return ::read(fd, buffer, size);
#endif
return real_read(fd, buffer, size);
}
ssize_t app_io_write(int fd, const void* buffer, size_t size) {
@ -78,22 +103,14 @@ ssize_t app_io_write(int fd, const void* buffer, size_t size) {
// output is currently being intercepted. Without this, a log line emitted while any app
// instance has its stdout captured would vanish from the console entirely instead of
// just also being visible to the capturing parent.
#if defined(TT_APP_IO_WRAPS_STDIO)
__real_write(fd, buffer, size);
#else
::write(fd, buffer, size);
#endif
real_write(fd, buffer, size);
return result;
}
if (table != nullptr && app_fd_table_is_app_owned(table, fd)) {
errno = EBADF;
return -1;
}
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_write(fd, buffer, size);
#else
return ::write(fd, buffer, size);
#endif
return real_write(fd, buffer, size);
}
int app_io_close(int fd) {
@ -108,11 +125,7 @@ int app_io_close(int fd) {
return -1;
}
}
#if defined(TT_APP_IO_WRAPS_STDIO)
return __real_close(fd);
#else
return ::close(fd);
#endif
return real_close(fd);
}
} // extern "C"

View File

@ -0,0 +1,327 @@
// SPDX-License-Identifier: Apache-2.0
// Paired with -Wl,--wrap= on POSIX (this module's own CMakeLists.txt) and ESP32 (top-level
// CMakeLists.txt); self-registered via dyld interpose below on Apple, whose linker doesn't
// support --wrap.
#include <app/io.h>
#include <sys/types.h>
#ifdef ESP_PLATFORM
// Newlib's own stdio (fflush()'s buffer-flush path, in particular) calls the reentrant
// _read_r/_write_r/_close_r syscall stubs directly, not the plain read()/write()/close() newlib
// itself provides as thin wrappers around them (see e.g. esp-idf's components/newlib/src/
// syscalls.c: `write(fd, dst, size) { return _write_r(__getreent(), fd, dst, size); }`)
// Wrapping the plain names only catches direct write()-style callers, not newlib's own internal
// stdio calls, so the _r stubs are wrapped here instead.
#include <reent.h>
extern "C" {
ssize_t __wrap__read_r(struct _reent* r, int fd, void* buffer, size_t size) {
(void)r;
return app_io_read(fd, buffer, size);
}
ssize_t __wrap__write_r(struct _reent* r, int fd, const void* buffer, size_t size) {
(void)r;
return app_io_write(fd, buffer, size);
}
int __wrap__close_r(struct _reent* r, int fd) {
(void)r;
return app_io_close(fd);
}
}
#else
extern "C" {
ssize_t __wrap_read(int fd, void* buffer, size_t size) {
return app_io_read(fd, buffer, size);
}
ssize_t __wrap_write(int fd, const void* buffer, size_t size) {
return app_io_write(fd, buffer, size);
}
int __wrap_close(int fd) {
return app_io_close(fd);
}
}
#endif // ESP_PLATFORM
#ifdef __APPLE__
// --wrap also synthesizes __real_read/write/close automatically; dyld interpose doesn't, so
// io.cpp's TT_APP_IO_WRAPS_STDIO fallback needs them defined here. dlsym(RTLD_NEXT, ...) is the
// standard way to reach the true libSystem implementation despite the interpose below: a direct
// call to read/write/close from this file would just recurse into __wrap_read/write/close, since
// interpose rewrites every reference to those symbols in the process, this file included.
#include <dlfcn.h>
#include <unistd.h>
extern "C" {
ssize_t __real_read(int fd, void* buffer, size_t size) {
static auto real = reinterpret_cast<ssize_t (*)(int, void*, size_t)>(dlsym(RTLD_NEXT, "read"));
return real(fd, buffer, size);
}
ssize_t __real_write(int fd, const void* buffer, size_t size) {
static auto real = reinterpret_cast<ssize_t (*)(int, const void*, size_t)>(dlsym(RTLD_NEXT, "write"));
return real(fd, buffer, size);
}
int __real_close(int fd) {
static auto real = reinterpret_cast<int (*)(int)>(dlsym(RTLD_NEXT, "close"));
return real(fd);
}
}
// <mach-o/dyld-interposing.h> isn't a public SDK header (it ships with dyld's own source, not
// Xcode/Command Line Tools), so this reimplements its DYLD_INTERPOSE macro locally; reused below
// for the printf-family interposes too.
#define TT_DYLD_INTERPOSE(replacement, replacee) \
__attribute__((used)) static struct { const void* replacement; const void* replacee; } \
tt_interpose_##replacee __attribute__((section("__DATA,__interpose"))) = { \
(const void*)(unsigned long)&(replacement), (const void*)(unsigned long)&(replacee) \
};
TT_DYLD_INTERPOSE(__wrap_read, read)
TT_DYLD_INTERPOSE(__wrap_write, write)
TT_DYLD_INTERPOSE(__wrap_close, close)
#endif // __APPLE__
// region glibc stdio wraps
//
// libc's printf/fprintf/etc are compiled into the C library and call an internal, non-exported
// write() alias, which --wrap=write/the read/write/close interpose above can't reach: only calls
// WE make to the public symbol. These wraps instead redirect calls WE make to printf/fprintf/etc,
// the same trick as read/write/close above. Newlib (ESP-IDF) doesn't have this gap: its stdio does
// call the wrappable syscall stubs, so this block is POSIX-only.
//
// Scoped to the printf/getc families only: fread/fwrite take an arbitrary FILE* and are already
// used sitewide for real file I/O (e.g. File.cpp's readBinaryInternal), so wrapping them would
// route every such call through this file's stdin/stdout check, a correctness risk for unrelated
// code that isn't worth taking here. putc/getc are excluded too since libc defines them as
// macros, not real calls, so wrapping those symbols wouldn't reliably intercept them.
#if !defined(ESP_PLATFORM)
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <memory>
#include <unistd.h>
extern "C" {
int __real_vfprintf(FILE* stream, const char* format, va_list args);
int __real_fputs(const char* s, FILE* stream);
int __real_fputc(int c, FILE* stream);
int __real_fgetc(FILE* stream);
char* __real_fgets(char* buffer, int size, FILE* stream);
}
#ifdef __APPLE__
// --wrap synthesizes these automatically elsewhere; on Apple they're defined here via
// dlsym(RTLD_NEXT, ...) instead. See the read/write/close __real_* block above for why.
#include <dlfcn.h>
extern "C" {
int __real_vfprintf(FILE* stream, const char* format, va_list args) {
static auto real = reinterpret_cast<int (*)(FILE*, const char*, va_list)>(dlsym(RTLD_NEXT, "vfprintf"));
return real(stream, format, args);
}
int __real_fputs(const char* s, FILE* stream) {
static auto real = reinterpret_cast<int (*)(const char*, FILE*)>(dlsym(RTLD_NEXT, "fputs"));
return real(s, stream);
}
int __real_fputc(int c, FILE* stream) {
static auto real = reinterpret_cast<int (*)(int, FILE*)>(dlsym(RTLD_NEXT, "fputc"));
return real(c, stream);
}
int __real_fgetc(FILE* stream) {
static auto real = reinterpret_cast<int (*)(FILE*)>(dlsym(RTLD_NEXT, "fgetc"));
return real(stream);
}
char* __real_fgets(char* buffer, int size, FILE* stream) {
static auto real = reinterpret_cast<char* (*)(char*, int, FILE*)>(dlsym(RTLD_NEXT, "fgets"));
return real(buffer, size, stream);
}
}
#endif // __APPLE__
namespace {
void writeAllToStdout(const void* data, size_t size) {
const auto* bytes = static_cast<const char*>(data);
size_t remaining = size;
while (remaining > 0) {
ssize_t written = app_io_write(STDOUT_FILENO, bytes, remaining);
if (written <= 0) {
break;
}
bytes += written;
remaining -= static_cast<size_t>(written);
}
}
// Formats into stdout via app_io_write() rather than through a FILE*'s own buffering, since that
// buffering is exactly what glibc's internal write() call sidesteps --wrap for in the first place.
int formatToStdout(const char* format, va_list args) {
char stackBuffer[256];
va_list argsForStack;
va_copy(argsForStack, args);
int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack);
va_end(argsForStack);
if (needed < 0) {
return needed;
}
if (static_cast<size_t>(needed) < sizeof(stackBuffer)) {
writeAllToStdout(stackBuffer, static_cast<size_t>(needed));
return needed;
}
auto heapBuffer = std::make_unique<char[]>(static_cast<size_t>(needed) + 1);
va_list argsForHeap;
va_copy(argsForHeap, args);
vsnprintf(heapBuffer.get(), static_cast<size_t>(needed) + 1, format, argsForHeap);
va_end(argsForHeap);
writeAllToStdout(heapBuffer.get(), static_cast<size_t>(needed));
return needed;
}
int readOneFromStdin(char& out) {
return static_cast<int>(app_io_read(STDIN_FILENO, &out, 1));
}
} // namespace
extern "C" {
int __wrap_vprintf(const char* format, va_list args) {
return formatToStdout(format, args);
}
int __wrap_printf(const char* format, ...) {
va_list args;
va_start(args, format);
int result = formatToStdout(format, args);
va_end(args);
return result;
}
int __wrap_vfprintf(FILE* stream, const char* format, va_list args) {
if (stream == stdout) {
return formatToStdout(format, args);
}
return __real_vfprintf(stream, format, args);
}
int __wrap_fprintf(FILE* stream, const char* format, ...) {
va_list args;
va_start(args, format);
int result = (stream == stdout) ? formatToStdout(format, args) : __real_vfprintf(stream, format, args);
va_end(args);
return result;
}
int __wrap_puts(const char* s) {
writeAllToStdout(s, strlen(s));
writeAllToStdout("\n", 1);
return 0;
}
int __wrap_fputs(const char* s, FILE* stream) {
if (stream == stdout) {
writeAllToStdout(s, strlen(s));
return 0;
}
return __real_fputs(s, stream);
}
int __wrap_putchar(int c) {
auto ch = static_cast<char>(c);
writeAllToStdout(&ch, 1);
return c;
}
int __wrap_fputc(int c, FILE* stream) {
if (stream == stdout) {
return __wrap_putchar(c);
}
return __real_fputc(c, stream);
}
int __wrap_getchar() {
char c;
return readOneFromStdin(c) == 1 ? static_cast<unsigned char>(c) : EOF;
}
int __wrap_fgetc(FILE* stream) {
if (stream == stdin) {
return __wrap_getchar();
}
return __real_fgetc(stream);
}
char* __wrap_fgets(char* buffer, int size, FILE* stream) {
if (stream != stdin) {
return __real_fgets(buffer, size, stream);
}
if (size <= 0) {
return nullptr;
}
int i = 0;
for (; i < size - 1; ++i) {
char c;
if (readOneFromStdin(c) != 1) {
break;
}
buffer[i] = c;
if (c == '\n') {
++i;
break;
}
}
if (i == 0) {
return nullptr;
}
buffer[i] = '\0';
return buffer;
}
}
#ifdef __APPLE__
TT_DYLD_INTERPOSE(__wrap_vprintf, vprintf)
TT_DYLD_INTERPOSE(__wrap_printf, printf)
TT_DYLD_INTERPOSE(__wrap_vfprintf, vfprintf)
TT_DYLD_INTERPOSE(__wrap_fprintf, fprintf)
TT_DYLD_INTERPOSE(__wrap_puts, puts)
TT_DYLD_INTERPOSE(__wrap_fputs, fputs)
TT_DYLD_INTERPOSE(__wrap_putchar, putchar)
TT_DYLD_INTERPOSE(__wrap_fputc, fputc)
TT_DYLD_INTERPOSE(__wrap_getchar, getchar)
TT_DYLD_INTERPOSE(__wrap_fgetc, fgetc)
TT_DYLD_INTERPOSE(__wrap_fgets, fgets)
#endif // __APPLE__
#endif // !ESP_PLATFORM
// endregion

View File

@ -6,30 +6,12 @@ enable_language(C CXX ASM)
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
add_executable(AppModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
if (NOT APPLE)
# Provides __wrap_read/write/close for the -Wl,--wrap= below (see Tactility/CMakeLists.txt
# for the canonical pairing of this file with those flags).
target_sources(AppModuleTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../../../Tactility/Source/AppStdioWrap.cpp)
endif ()
target_include_directories(AppModuleTests PRIVATE ${DOCTESTINC} ${CMAKE_CURRENT_LIST_DIR}/../private)
add_test(NAME AppModuleTests COMMAND AppModuleTests)
# Matches app-module's own TT_APP_IO_WRAPS_STDIO (see Modules/app-module/CMakeLists.txt):
# io.cpp calls __real_read/write/close() on non-Apple POSIX, so any final link of it needs
# these wraps too, or those symbols go unresolved. The printf-family/getc-family flags are
# needed for the same reason: AppStdioWrap.cpp compiles those __wrap_* functions unconditionally
# on this platform (see its own #if guard), and they reference __real_vfprintf/fputs/fputc/
# fgetc/fgets - those only exist once the matching --wrap flag is passed.
if (NOT APPLE)
target_link_options(AppModuleTests PRIVATE
"-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
# No --wrap flags or stdio_wrap.cpp source needed here: linking app-module below already brings
# both along (see its own CMakeLists.txt).
target_link_libraries(AppModuleTests PUBLIC
TactilityKernel

View File

@ -15,6 +15,7 @@
#include <tactility/delay.h>
#include <tactility/freertos/task.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
@ -110,6 +111,11 @@ int32_t stream_writer_app_main(int, char*[]) {
return 7;
}
int32_t printf_stream_writer_app_main(int, char*[]) {
printf("loc");
return 7;
}
// Writes argv[0] to its own stdout, for the app_execute_with_streams() argv-delivery test.
int32_t argv_echo_app_main(int argc, char* argv[]) {
if (argc < 1) {
@ -321,3 +327,53 @@ TEST_CASE("app_execute_for_result_with_streams delivers both the stream data and
app_manager_stop(parent_id);
app_manager_remove("test.app.execute.parent_streams");
}
TEST_CASE("app_execute_for_result_with_streams pipes a child's plain printf() calls too") {
ensure_memory_loader_registered();
AppManifest parent_manifest { "test.app.execute.printf_parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(location_app_main) } };
REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE);
uint32_t parent_id = 0;
REQUIRE_EQ(app_start("test.app.execute.printf_parent", 0, nullptr, &parent_id), ERROR_NONE);
CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000));
TaskEventGroup parent_event_group {};
task_event_group_construct(&parent_event_group);
AppEventSubscription parent_sub {};
REQUIRE_EQ(app_event_subscribe_with_app_id(&parent_sub, &parent_event_group, parent_id), ERROR_NONE);
uint8_t storage[64];
AppStream child_stdout {};
AppStreamBinding binding { STDOUT_FILENO, &child_stdout, storage, sizeof(storage), &parent_event_group };
AppLocation location { APP_LOCATION_MEMORY, reinterpret_cast<void*>(printf_stream_writer_app_main) };
uint32_t child_id = 0;
REQUIRE_EQ(app_execute_for_result_with_streams(location, AppStackConfig {}, 0, nullptr, &binding, 1, parent_id, &child_id), ERROR_NONE);
std::vector<uint8_t> received;
while (app_stream_await(&child_stdout, APP_FILE_WAIT_READABLE, pdMS_TO_TICKS(1000)) == ERROR_NONE) {
uint8_t chunk[16];
size_t n = app_stream_read(&child_stdout, chunk, sizeof(chunk));
if (n == 0) {
break; // EOF
}
received.insert(received.end(), chunk, chunk + n);
}
REQUIRE_EQ(received.size(), 3u);
CHECK_EQ(std::memcmp(received.data(), "loc", 3), 0);
REQUIRE_EQ(task_event_group_wait(&parent_event_group, parent_sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);
AppEvent event {};
REQUIRE_EQ(app_event_poll(&parent_sub, &event), ERROR_NONE);
CHECK_EQ(event.type, APP_EVENT_RESULT);
CHECK_EQ(event.result.launch_id, child_id);
CHECK_EQ(event.result.result, 7);
app_stream_unsubscribe(&child_stdout);
app_event_unsubscribe(&parent_sub);
task_event_group_destruct(&parent_event_group);
app_manager_stop(child_id);
app_manager_stop(parent_id);
app_manager_remove("test.app.execute.printf_parent");
}

View File

@ -16,6 +16,7 @@
#include <unistd.h>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <vector>
@ -66,6 +67,11 @@ int32_t stdout_writer_app_main(int, char*[]) {
return 0;
}
int32_t stdout_printf_app_main(int, char*[]) {
printf("hello");
return 0;
}
std::atomic<bool> g_blocked_writer_saw_error { false };
std::atomic<bool> g_blocked_writer_done { false };
@ -180,6 +186,41 @@ TEST_CASE("app_start_with_streams pipes a child's app_io_write() calls into a pa
app_manager_remove("test.io.writer");
}
TEST_CASE("app_start_with_streams pipes a child's plain printf() calls into a parent-owned AppStream") {
ensure_memory_loader_registered();
AppManifest manifest { "test.io.printf_writer", "PrintfWriter", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(stdout_printf_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
uint8_t storage[64];
AppStream child_stdout {};
AppStreamBinding binding { STDOUT_FILENO, &child_stdout, storage, sizeof(storage), &event_group };
AppInstanceId child_id = 0;
REQUIRE_EQ(app_start_with_streams("test.io.printf_writer", &binding, 1, &child_id), ERROR_NONE);
std::vector<uint8_t> received;
while (app_stream_await(&child_stdout, APP_FILE_WAIT_READABLE, pdMS_TO_TICKS(1000)) == ERROR_NONE) {
uint8_t chunk[16];
size_t n = app_stream_read(&child_stdout, chunk, sizeof(chunk));
if (n == 0) {
break; // EOF
}
received.insert(received.end(), chunk, chunk + n);
}
REQUIRE_EQ(received.size(), 5u);
CHECK_EQ(std::memcmp(received.data(), "hello", 5), 0);
REQUIRE(wait_for_state(child_id, APP_INSTANCE_STATE_STOPPED, 1000));
app_stream_unsubscribe(&child_stdout);
task_event_group_destruct(&event_group);
app_manager_remove("test.io.printf_writer");
}
TEST_CASE("a write blocked on a full stream wakes with an error once the consumer closes it") {
ensure_memory_loader_registered();
g_blocked_writer_saw_error.store(false, std::memory_order_relaxed);

View File

@ -27,7 +27,6 @@ constexpr auto HID_PROC_TASK_STACK = 4096;
constexpr auto HID_PROC_TASK_PRIORITY = 5;
constexpr auto HID_STOP_TIMEOUT_MS = 2000;
constexpr auto MAX_SUBSCRIBERS = 4;
constexpr auto USB_HID_KB_QUEUE_SIZE = 16;
typedef struct {
hid_host_device_handle_t handle;
@ -437,47 +436,9 @@ static const UsbHidApi hid_api = {
extern "C" {
// region Dynamic KEYBOARD_TYPE device
//
// While a physical USB keyboard is connected, a KEYBOARD_TYPE child device is constructed so the
// rest of the system (lvgl_hardware_keyboard_is_available(), Tactility's KeyboardDeviceListener)
// sees a real hardware keyboard through the same generic device model as any other keyboard, e.g.
// Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp. Real key events are delivered exclusively
// through this device (kb_handle's hid_interface_callback pushes into its queue below); the
// generic UsbHidEvent publish/subscribe channel above is unaffected for mouse move/button/scroll
// and the right-click-as-ESC synthesis it already does.
static error_t usb_hid_kb_device_start(Device* device) {
auto* queue = xQueueCreate(USB_HID_KB_QUEUE_SIZE, sizeof(KeyboardKeyData));
if (queue == nullptr) {
return ERROR_RESOURCE;
}
device_set_driver_data(device, queue);
return ERROR_NONE;
}
static error_t usb_hid_kb_device_stop(Device* device) {
auto* queue = static_cast<QueueHandle_t>(device_get_driver_data(device));
vQueueDelete(queue);
device_set_driver_data(device, nullptr);
return ERROR_NONE;
}
static error_t usb_hid_kb_device_read_key(Device* device, KeyboardKeyData* data) {
auto* queue = static_cast<QueueHandle_t>(device_get_driver_data(device));
if (queue == nullptr) {
*data = {};
return ERROR_NONE;
}
if (xQueueReceive(queue, data, 0) != pdTRUE) {
*data = {};
return ERROR_NONE;
}
data->continue_reading = uxQueueMessagesWaiting(queue) > 0;
return ERROR_NONE;
}
static const KeyboardApi esp32_usbhost_hid_keyboard_api = {
.read_key = usb_hid_kb_device_read_key,
.read_key = nullptr,
.get_backlight = nullptr,
.is_present = nullptr,
};
@ -485,8 +446,9 @@ static const KeyboardApi esp32_usbhost_hid_keyboard_api = {
Driver esp32_usbhost_hid_keyboard_driver = {
.name = "esp32_usbhost_hid_keyboard",
.compatible = (const char*[]) { nullptr },
.start_device = usb_hid_kb_device_start,
.stop_device = usb_hid_kb_device_stop,
.start_device = nullptr,
.stop_device = nullptr,
.probe = nullptr,
.api = &esp32_usbhost_hid_keyboard_api,
.device_type = &KEYBOARD_TYPE,
.owner = nullptr,
@ -543,9 +505,8 @@ static void usb_hid_keyboard_publish_key(UsbHidContext* ctx, uint32_t lv_key, bo
if (!ctx->kb_device_active) {
return;
}
auto* queue = static_cast<QueueHandle_t>(device_get_driver_data(&ctx->kb_device));
KeyboardKeyData data = { lv_key, pressed, false, ctrl, alt, hid_keycode, hid_modifier };
xQueueSend(queue, &data, 0);
keyboard_emit_key(&ctx->kb_device, data);
}
// endregion

View File

@ -6,3 +6,12 @@ add_library(platform-posix OBJECT)
target_sources(platform-posix PRIVATE ${SOURCES})
#target_include_directories(platform-posix PUBLIC include/)
target_link_libraries(platform-posix PUBLIC TactilityKernel)
# Routes every pthread_attr_setstack() call to __wrap_pthread_attr_setstack() in source/pthread_stack_wrap.c,
# which no-ops it. see that file for why.
# --wrap is a GNU ld option; Apple's linker doesn't support it, so that file instead self-registers a dyld interpose
# for this symbol on Apple, needing no link flag. PUBLIC so every consumer of this OBJECT library
# (Tactility, and every test target that links platform-posix directly) gets it applied too.
if (NOT APPLE)
target_link_options(platform-posix PUBLIC "-Wl,--wrap=pthread_attr_setstack")
endif ()

View File

@ -2,20 +2,15 @@
#include <stddef.h>
/**
* Linked in with -Wl,--wrap=pthread_attr_setstack (see Tactility/CMakeLists.txt).
* FreeRTOS's POSIX port hands every task a stack carved from its own heap (pvPortMalloc) via this call.
* pthread_attr_setstack requires page alignment that pvPortMalloc doesn't guarantee.
* When it succeeds anyway, the task's real pthread stack ends up living inside that small FreeRTOS heap region,
* where a thread doing heavier stack work (e.g. Mesa GL shader compilation) can silently corrupt adjacent heap_4 objects.
* No-op'ing the call instead leaves every task's pthread_attr_t at its pthread_attr_init() default,
* so pthread_create() gives it a real, properly sized stack.
*
* FreeRTOS's POSIX port (Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/port.c)
* hands every task a stack carved out of its own heap (pvPortMalloc) via this call.
* pthread_attr_setstack requires page alignment, which pvPortMalloc doesn't guarantee;
* when it happens to succeed anyway (allocator alignment can vary run to run), the
* task's real pthread stack ends up living inside that small FreeRTOS heap region -
* fine for typical embedded task code, but a desktop GL driver doing on-the-fly shader
* compilation on that thread (e.g. Mesa on first frame present) can overflow it and
* silently corrupt adjacent heap_4 objects.
*
* Wrapping the call out entirely (rather than patching the vendored port.c) leaves every
* task's pthread_attr_t at its pthread_attr_init() default, so pthread_create() always
* gives it a real, properly allocated default-size stack instead.
* Linked via -Wl,--wrap=pthread_attr_setstack (this module's own CMakeLists.txt) on non-Apple
* platforms; reused as a dyld interpose target below on Apple platforms, whose linker lacks --wrap.
*/
int __wrap_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t stacksize) {
(void)attr;
@ -23,3 +18,17 @@ int __wrap_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t s
(void)stacksize;
return 0;
}
#ifdef __APPLE__
// <mach-o/dyld-interposing.h> isn't a public SDK header (it ships with dyld's own source, not
// Xcode/Command Line Tools), so this reimplements its DYLD_INTERPOSE macro locally.
#define TT_DYLD_INTERPOSE(replacement, replacee) \
__attribute__((used)) static struct { const void* replacement; const void* replacee; } \
tt_interpose_##replacee __attribute__((section("__DATA,__interpose"))) = { \
(const void*)(unsigned long)&(replacement), (const void*)(unsigned long)&(replacee) \
};
TT_DYLD_INTERPOSE(__wrap_pthread_attr_setstack, pthread_attr_setstack)
#endif

View File

@ -185,31 +185,9 @@ else ()
# Exports Tactility's own symbols (-rdynamic) so a dlopen()ed app-posix-module app can resolve
# calls back into it - the OS-native equivalent of app-esp32-module's custom symbol resolver.
set_target_properties(Tactility PROPERTIES ENABLE_EXPORTS ON)
# Routes every pthread_attr_setstack() call (FreeRTOS's POSIX port hands each task a stack
# carved out of its own heap through this) to __wrap_pthread_attr_setstack() in
# Platforms/platform-posix/source/pthread_stack_wrap.c, which no-ops it - see that file for why.
# --wrap is a GNU ld option; Apple's linker doesn't support it.
if (NOT APPLE)
target_link_options(Tactility PRIVATE "-Wl,--wrap=pthread_attr_setstack")
# Routes every read()/write()/close() call to Tactility/Source/AppStdioWrap.cpp's
# __wrap_read/write/close(), which forward into app_io_read/write/close() - the fd-table
# dispatch that lets an app's own stdio (e.g. a fileselection dialog's printf'd result
# path) reach an AppStream a parent bound via app_manager_start_with_streams(). Mirrors
# what ESP-IDF's build already does for the ESP32 target (see top-level CMakeLists.txt).
target_link_options(Tactility PRIVATE "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close")
# glibc's printf/fprintf/etc don't call the public write() symbol internally (they're
# already compiled into libc.so, out of --wrap's reach), so the read/write/close wrap
# above can't see them. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the
# wrappable syscall stubs - so this block is POSIX-only. Wrapping these symbols instead
# redirects OUR OWN calls to them (the only ones --wrap can rewrite) through
# AppStdioWrap.cpp's __wrap_* functions, which check the target stream (stdout/stdin) and
# fall back to the real libc function for any other FILE*.
target_link_options(Tactility PRIVATE
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
# pthread_attr_setstack and read/write/close/printf-family aren't wrapped here: linking
# platform-posix and app-module already brings those wraps along (see their own
# CMakeLists.txt files).
endif ()
#

View File

@ -1,200 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// Paired with -Wl,--wrap=read/write/close - see Tactility/CMakeLists.txt (POSIX) and the
// top-level CMakeLists.txt (ESP32) for where that's applied. On a platform where it isn't
// (currently: macOS, whose linker doesn't support --wrap), these are simply never called - real
// read()/write()/close() calls go straight through unredirected.
#include <app/io.h>
#include <sys/types.h>
extern "C" {
ssize_t __wrap_read(int fd, void* buffer, size_t size) {
return app_io_read(fd, buffer, size);
}
ssize_t __wrap_write(int fd, const void* buffer, size_t size) {
return app_io_write(fd, buffer, size);
}
int __wrap_close(int fd) {
return app_io_close(fd);
}
}
// region glibc stdio wraps
//
// glibc's printf/fprintf/etc are compiled into libc.so and call an internal, non-exported write()
// alias - --wrap=write (above) can't reach that call, only calls WE make to the public symbol.
// These wraps instead redirect calls WE make to printf/fprintf/etc, the same trick as read/write/
// close above. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the wrappable syscall
// stubs - so Tactility/CMakeLists.txt only applies the matching -Wl,--wrap= flags on POSIX.
//
// Scoped to the printf/getc families only: fread/fwrite take an arbitrary FILE* and are already
// used sitewide for real file I/O (e.g. File.cpp's readBinaryInternal), so wrapping them would
// route every such call through this file's stdin/stdout check - a correctness risk for unrelated
// code that isn't worth taking here. putc/getc are excluded too since glibc defines them as
// macros, not real calls, so wrapping those symbols wouldn't reliably intercept them.
#if !defined(ESP_PLATFORM) && !defined(__APPLE__)
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <memory>
#include <unistd.h>
extern "C" {
int __real_vfprintf(FILE* stream, const char* format, va_list args);
int __real_fputs(const char* s, FILE* stream);
int __real_fputc(int c, FILE* stream);
int __real_fgetc(FILE* stream);
char* __real_fgets(char* buffer, int size, FILE* stream);
}
namespace {
void writeAllToStdout(const void* data, size_t size) {
const auto* bytes = static_cast<const char*>(data);
size_t remaining = size;
while (remaining > 0) {
ssize_t written = app_io_write(STDOUT_FILENO, bytes, remaining);
if (written <= 0) {
break;
}
bytes += written;
remaining -= static_cast<size_t>(written);
}
}
// Formats into stdout via app_io_write() rather than through a FILE*'s own buffering, since that
// buffering is exactly what glibc's internal write() call sidesteps --wrap for in the first place.
int formatToStdout(const char* format, va_list args) {
char stackBuffer[256];
va_list argsForStack;
va_copy(argsForStack, args);
int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack);
va_end(argsForStack);
if (needed < 0) {
return needed;
}
if (static_cast<size_t>(needed) < sizeof(stackBuffer)) {
writeAllToStdout(stackBuffer, static_cast<size_t>(needed));
return needed;
}
auto heapBuffer = std::make_unique<char[]>(static_cast<size_t>(needed) + 1);
va_list argsForHeap;
va_copy(argsForHeap, args);
vsnprintf(heapBuffer.get(), static_cast<size_t>(needed) + 1, format, argsForHeap);
va_end(argsForHeap);
writeAllToStdout(heapBuffer.get(), static_cast<size_t>(needed));
return needed;
}
int readOneFromStdin(char& out) {
return static_cast<int>(app_io_read(STDIN_FILENO, &out, 1));
}
} // namespace
extern "C" {
int __wrap_vprintf(const char* format, va_list args) {
return formatToStdout(format, args);
}
int __wrap_printf(const char* format, ...) {
va_list args;
va_start(args, format);
int result = formatToStdout(format, args);
va_end(args);
return result;
}
int __wrap_vfprintf(FILE* stream, const char* format, va_list args) {
if (stream == stdout) {
return formatToStdout(format, args);
}
return __real_vfprintf(stream, format, args);
}
int __wrap_fprintf(FILE* stream, const char* format, ...) {
va_list args;
va_start(args, format);
int result = (stream == stdout) ? formatToStdout(format, args) : __real_vfprintf(stream, format, args);
va_end(args);
return result;
}
int __wrap_puts(const char* s) {
writeAllToStdout(s, strlen(s));
writeAllToStdout("\n", 1);
return 0;
}
int __wrap_fputs(const char* s, FILE* stream) {
if (stream == stdout) {
writeAllToStdout(s, strlen(s));
return 0;
}
return __real_fputs(s, stream);
}
int __wrap_putchar(int c) {
auto ch = static_cast<char>(c);
writeAllToStdout(&ch, 1);
return c;
}
int __wrap_fputc(int c, FILE* stream) {
if (stream == stdout) {
return __wrap_putchar(c);
}
return __real_fputc(c, stream);
}
int __wrap_getchar() {
char c;
return readOneFromStdin(c) == 1 ? static_cast<unsigned char>(c) : EOF;
}
int __wrap_fgetc(FILE* stream) {
if (stream == stdin) {
return __wrap_getchar();
}
return __real_fgetc(stream);
}
char* __wrap_fgets(char* buffer, int size, FILE* stream) {
if (stream != stdin) {
return __real_fgets(buffer, size, stream);
}
if (size <= 0) {
return nullptr;
}
int i = 0;
for (; i < size - 1; ++i) {
char c;
if (readOneFromStdin(c) != 1) {
break;
}
buffer[i] = c;
if (c == '\n') {
++i;
break;
}
}
if (i == 0) {
return nullptr;
}
buffer[i] = '\0';
return buffer;
}
}
#endif // !ESP_PLATFORM && !__APPLE__
// endregion

View File

@ -85,7 +85,7 @@ int32_t appMain(int argc, char* argv[]) {
if (ctx.resultCode == 0) {
// The parent captures this via an AppStream bound to our stdout (see startWithMode()) -
// see AppStdioWrap.cpp for how printf() itself gets routed there on POSIX.
// see Modules/app-module/source/stdio_wrap.cpp for how printf() itself gets routed there on POSIX.
LOG_I(TAG, "Result: %s", ctx.resultPath.c_str());
printf("%s", ctx.resultPath.c_str());
}

View File

@ -138,7 +138,7 @@ int32_t appMain(int argc, char* argv[]) {
if (ctx.resultCode == 0) {
// The caller captures this via an AppStream bound to our stdout (see start()); see
// AppStdioWrap.cpp for how printf() itself gets routed there on POSIX.
// Modules/app-module/source/stdio_wrap.cpp for how printf() itself gets routed there on POSIX.
printf("%s", ctx.resultText.c_str());
}
return ctx.resultCode;

View File

@ -19,18 +19,9 @@ target_include_directories(TactilityTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../P
add_test(NAME TactilityTests COMMAND TactilityTests)
# Matches Tactility/CMakeLists.txt's own set of --wrap flags: this binary also compiles
# AppStdioWrap.cpp and links app-module (whose io.cpp calls __real_read/write/close() on
# non-Apple POSIX - see TT_APP_IO_WRAPS_STDIO in Modules/app-module/CMakeLists.txt), so it
# needs the same wraps applied or those go unresolved.
if (NOT APPLE)
target_link_options(TactilityTests PRIVATE
"-Wl,--wrap=pthread_attr_setstack" "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close"
"-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf"
"-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc"
"-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets"
)
endif ()
# No --wrap flags declared here: linking platform-posix and app-module below already brings all
# of pthread_attr_setstack/read/write/close/the printf family along (see their own CMakeLists.txt
# files).
target_link_libraries(TactilityTests PRIVATE
TactilityKernel

View File

@ -35,7 +35,7 @@ typedef uint8_t device_flags_t;
#define DEVICE_FLAG_VIRTUAL BIT(2) /* No physical hardware */
#define DEVICE_FLAG_REMOVABLE BIT(3) /* May disappear (USB, SDIO, etc.) */
#define DEVICE_FLAG_HOTPLUG BIT(4) /* Supports hotplug */
#define DEVICE_FLAG_HOTPLUG BIT(4) /* Automatically set by device_hotplug_register() when the driver has a probe */
/** Represents a piece of hardware */
struct Device {
@ -113,6 +113,7 @@ error_t device_remove(struct Device* device);
* device's lock for its entire body, so doing so would deadlock.
* @param[in,out] device non-null device pointer
* @retval ERROR_INVALID_STATE if the device is already started or not added
* @retval ERROR_NOT_FOUND probe() reported the device isn't present
* @retval ERROR_RESOURCE when driver binding fails
* @retval ERROR_NONE on success
*/
@ -389,6 +390,37 @@ bool device_has_active_by_type(const struct DeviceType* type);
*/
error_t device_get_first_by_compatible(const char* compatible, struct Device** out_device);
/** Consecutive device_hotplug_poll_once() calls a probe() result must hold before it takes effect. */
#define DEVICE_HOTPLUG_DEBOUNCE_COUNT 2
/**
* Registers @a device with the hotplug poller (see device_hotplug_poll_once()): started once
* probe() (or immediately if NULL) reports present, stopped when it reports absent. Sets
* DEVICE_FLAG_HOTPLUG on @a device if its driver has a `probe`. A device never registered here
* is never touched by the poller. No-op if already registered. Safe to call concurrently with
* device_hotplug_poll_once() and with device_hotplug_unregister().
*/
void device_hotplug_register(struct Device* device);
/**
* Reverses device_hotplug_register(), clearing DEVICE_FLAG_HOTPLUG. No-op if not registered.
* Safe to call concurrently with device_hotplug_poll_once(); call this before
* device_remove()/device_destruct() if a registered device is being permanently torn down.
*/
void device_hotplug_unregister(struct Device* device);
/**
* Starts/stops every device_hotplug_register()'d device per its driver's probe() (or
* unconditionally, once, if probe is NULL), debounced across DEVICE_HOTPLUG_DEBOUNCE_COUNT
* calls. Never removes/destructs a device.
* @retval ERROR_NONE on success
* @retval (other) a mandatory (probe == NULL) device failed to start
*/
error_t device_hotplug_poll_once(void);
/** Starts a periodic timer that calls device_hotplug_poll_once(). */
void device_hotplug_start_polling(void);
#ifdef __cplusplus
}
#endif

View File

@ -23,6 +23,14 @@ struct Driver {
error_t (*start_device)(struct Device* dev);
/** Function to deinitialize the driver for a device */
error_t (*stop_device)(struct Device* dev);
/**
* Optional: reports whether the hardware is physically present. Checked by driver_bind() and periodically by device_hotplug_poll_once().
* NULL means always present.
* @retval ERROR_NONE the hardware is present
* @retval (other) not present (or the probe itself failed - treated the same)
* @warning Must not call device_add()/device_remove() - may run while the ledger is locked.
*/
error_t (*probe)(struct Device* device);
/** Contains the driver's functions */
const void* api;
/** Which type of devices this driver creates (can be NULL) */
@ -100,6 +108,7 @@ error_t driver_remove_destruct(struct Driver* driver);
*
* @param driver The driver to bind.
* @param device The device to bind to.
* @retval ERROR_NOT_FOUND probe() reported the device isn't present
* @return ERROR_NONE if successful, or an error code otherwise.
*/
error_t driver_bind(struct Driver* driver, struct Device* device);

View File

@ -14,12 +14,9 @@ extern "C" {
/**
* @brief Named Unicode codepoints for KeyboardKeyData::key.
*
* Keys that produce an ordinary character use that character's own codepoint directly (e.g. 'a',
* ' ') and don't need a name here. These are the standard Unicode symbol codepoints used instead
* of LVGL's LV_KEY_* sentinels for keys with no character of their own (arrows, Home/End,
* Tab/Shift+Tab-as-focus-navigation), plus names for the handful of C0 control keys every driver
* needs (Enter/Escape/Backspace/Delete/Tab). Modules/lvgl-module/source/devices/keyboard.cpp
* translates these back into LV_KEY_* internally for LVGL.
* Some common codepoints.
* There are no official codepoints for arrow keys, so we use the symbols as placeholder.
* Subsystems like LVGL can translate these special codepoints to specific actions.
*/
typedef enum {
CODEPOINT_ENTER = '\r',
@ -77,11 +74,11 @@ struct KeyboardKeyData {
bool alt;
/**
* @brief Standard USB HID keyboard usage code for this key (USB HID Usage Tables, page 0x07),
* or 0 if this driver doesn't compute one (most don't - `key` is the only field most consumers
* need). Populated by drivers whose hardware layout maps cleanly onto HID usage codes, so
* consumers that want to mirror physical key presses as real HID reports (e.g. USB HID output)
* don't have to reverse-engineer one out of `key`'s codepoint encoding - which is lossy for
* keys with no Unicode/LVGL representation at all, e.g. F1-F12.
* or 0 if this driver doesn't compute one (most don't - `key` is the only field most consumers need).
* Populated by drivers whose hardware layout maps cleanly onto HID usage codes, so consumers that
* want to mirror physical key presses as real HID reports (e.g. USB HID output) don't have to
* reverse-engineer one out of `key`'s codepoint encoding.
* Encoding is lossy for keys with no Unicode/LVGL representation at all, e.g. F1-F12.
*/
uint8_t hid_keycode;
/**
@ -91,12 +88,35 @@ struct KeyboardKeyData {
uint8_t hid_modifier;
};
/**
* @brief Number of key events that can be queued per subscription in the default
* subscribe/unsubscribe/poll implementation before older subscribers' fanned-out events start
* being dropped for that subscriber (silently, preserving FIFO order of what's already queued).
*/
#define KEYBOARD_EVENT_QUEUE_CAPACITY 4
/**
* @brief Caller-owned subscription node for keyboard_subscribe()/keyboard_unsubscribe()/
* keyboard_poll().
* @warning `internal` is private to keyboard.cpp; do not read or write it directly.
*/
struct KeyboardEventSubscription {
struct {
struct Device* device;
struct KeyboardKeyData queue[KEYBOARD_EVENT_QUEUE_CAPACITY];
uint8_t head;
uint8_t count;
struct KeyboardEventSubscription* next;
} internal;
};
/**
* @brief API for keyboard drivers.
*/
struct KeyboardApi {
/**
* @brief Reads the next pending key event, if any.
* May be NULL if the driver instead pushes events itself via keyboard_emit_key() (e.g. from a dedicated interrupt-handling task).
* @param[in] device the keyboard device
* @param[out] data the key event data
* @retval ERROR_NONE when the operation was successful
@ -104,7 +124,7 @@ struct KeyboardApi {
error_t (*read_key)(struct Device* device, struct KeyboardKeyData* data);
/**
* @brief Returns the baclight if the keyboard has one.
* @brief Returns the backlight if the keyboard has one.
* @warning Returns a referenced device. Must call device_put() afterwards.
* @param[in] device the keyboard device
* @param[out] backlight_device the output backlight device
@ -114,22 +134,34 @@ struct KeyboardApi {
error_t (*get_backlight)(struct Device* device, struct Device** backlight_device);
/**
* @brief Optional: reports whether the keyboard is physically present right now. Only
* meaningful for hot-pluggable/detachable keyboards (e.g. a removable accessory) whose
* kernel device is constructed and started once at boot regardless of physical attachment -
* leave NULL for a keyboard that's always physically present whenever its device is active
* (the common case; callers must treat NULL the same as "always present").
* @param[in] device the keyboard device
* @return true if physically attached/present
* @deprecated Use Driver::probe + device_hotplug_register() instead (see DEVICE_FLAG_HOTPLUG)
* The kernel then starts/stops the device itself instead of leaving it started forever.
* When DEVICE_FLAG_HOTPLUG is used, use device_is_ready() only for checking if a keyboard is usable.
*/
bool (*is_present)(struct Device* device);
};
/**
* @brief Reads the next pending key event using the specified keyboard device.
* @brief Reads the next pending key event using the specified keyboard device. Also fans the
* result out to every keyboard_subscribe()'d subscriber, same as keyboard_emit_key().
* @retval ERROR_NONE @a data filled - key == 0 if nothing is pending
* @retval ERROR_INVALID_STATE @a device isn't currently started (e.g. hotplug-absent)
* @retval ERROR_NOT_SUPPORTED the driver has no KeyboardApi::read_key (it pushes events via
* keyboard_emit_key() instead)
*/
error_t keyboard_read_key(struct Device* device, struct KeyboardKeyData* data);
/**
* @brief Push a key event from @a device to every current subscriber (see keyboard_subscribe()).
* For a driver whose hardware delivers key events asynchronously (e.g. from a dedicated
* interrupt-handling task) rather than on demand - KeyboardApi::read_key may be left NULL when
* the driver calls this instead.
* @warning Not ISR-safe (takes a mutex); call from a task, not directly from interrupt context.
* @param[in] device the keyboard device the event originated from
* @param[in] data the key event
*/
void keyboard_emit_key(struct Device* device, struct KeyboardKeyData data);
/**
* @brief Returns the backlight if the keyboard has one.
* @warning Returns a referenced device. Must call device_put() afterwards.
@ -141,13 +173,33 @@ error_t keyboard_read_key(struct Device* device, struct KeyboardKeyData* data);
error_t keyboard_get_backlight(struct Device* device, struct Device** backlight_device);
/**
* @brief Whether the keyboard device is physically present right now. True when the driver
* doesn't implement KeyboardApi::is_present (i.e. it's always physically present whenever its
* device is active) - see that field's doc comment.
* @deprecated See KeyboardApi::is_present.
* @param[in] device the keyboard device
*/
bool keyboard_is_present(struct Device* device);
/**
* @brief Register @a sub for async key events from @a device, derived from `read_key`.
* @param[in,out] sub subscription to register; owns the storage, must stay alive (and stationary)
* until unsubscribed
* @retval ERROR_NONE on success
*/
error_t keyboard_subscribe(struct Device* device, struct KeyboardEventSubscription* sub);
/**
* @brief Remove a subscription previously registered with keyboard_subscribe().
* @retval ERROR_NONE on success
*/
error_t keyboard_unsubscribe(struct Device* device, struct KeyboardEventSubscription* sub);
/**
* @brief Non-blocking: pop the next event for @a sub if one is already queued.
* @warning Never blocks.
* @retval ERROR_NONE @a out_data was filled
* @retval ERROR_TIMEOUT nothing queued right now
*/
error_t keyboard_poll(struct Device* device, struct KeyboardEventSubscription* sub, struct KeyboardKeyData* out_data);
extern const struct DeviceType KEYBOARD_TYPE;
#ifdef __cplusplus

View File

@ -7,6 +7,7 @@
#include <tactility/log.h>
#include <tactility/check.h>
#include <tactility/concurrent/recursive_mutex.h>
#include <tactility/concurrent/timer.h>
#include <ranges>
#include <cassert>
@ -43,6 +44,11 @@ struct DeviceInternal {
} state;
/** Attached child devices */
std::vector<Device*> children {};
// device_hotplug_poll_once() debounce state.
struct {
uint8_t consecutive = 0; // probe() results differing from `present` so far
bool present = false;
} hotplug;
// Outstanding device_get() holders. Guarded by `mutex`. Independent of state.started -
// device_get()/device_put() bracket construct/destruct, not start/stop, so a ref can be held
// across a device_stop(). device_destruct() refuses to run while this is > 0.
@ -225,7 +231,7 @@ error_t device_start(Device* device) {
unlock_internal(internal);
if (bind_error != ERROR_NONE) {
return ERROR_RESOURCE;
return bind_error == ERROR_NOT_FOUND ? ERROR_NOT_FOUND : ERROR_RESOURCE;
}
device_listener_notify(device, DEVICE_EVENT_STARTED);
@ -559,4 +565,131 @@ error_t device_get_first_by_compatible(const char* compatible, Device** out_devi
return error;
}
struct HotplugAction {
Device* device;
bool start; // vs. stop
bool mandatory; // probe == nullptr; a start failure here is a real error
};
// Guards hotplug_registered_devices only - never held during probe()/device_start()/device_stop().
struct HotplugRegistryMutex {
Mutex handle {};
HotplugRegistryMutex() { mutex_construct(&handle); }
~HotplugRegistryMutex() { mutex_destruct(&handle); }
};
static HotplugRegistryMutex hotplug_registry_mutex;
static std::vector<Device*> hotplug_registered_devices;
static Timer* hotplug_timer = nullptr;
static void hotplug_timer_callback(void*) {
device_hotplug_poll_once();
}
void device_hotplug_register(Device* device) {
mutex_lock(&hotplug_registry_mutex.handle);
if (std::ranges::find(hotplug_registered_devices, device) == hotplug_registered_devices.end()) {
if (device->internal->driver != nullptr && device->internal->driver->probe != nullptr) {
device->flags |= DEVICE_FLAG_HOTPLUG;
}
hotplug_registered_devices.push_back(device);
}
mutex_unlock(&hotplug_registry_mutex.handle);
}
void device_hotplug_unregister(Device* device) {
mutex_lock(&hotplug_registry_mutex.handle);
const auto iterator = std::ranges::find(hotplug_registered_devices, device);
if (iterator != hotplug_registered_devices.end()) {
hotplug_registered_devices.erase(iterator);
device->flags &= ~DEVICE_FLAG_HOTPLUG;
}
mutex_unlock(&hotplug_registry_mutex.handle);
}
error_t device_hotplug_poll_once(void) {
// Pin each device (device_get()) so a concurrent unregister+destruct can't free it mid-loop -
// device_destruct() refuses to run while a reference is outstanding.
std::vector<Device*> devices;
mutex_lock(&hotplug_registry_mutex.handle);
for (Device* device : hotplug_registered_devices) {
if (device_get(device) == ERROR_NONE) {
devices.push_back(device);
}
}
mutex_unlock(&hotplug_registry_mutex.handle);
// Collect actions first: start_device()/stop_device() may add/remove child devices
// (ledger_lock), so don't call them mid-iteration. Device state is read/written under its own
// mutex, released before calling probe() - never run caller code under a lock.
std::vector<HotplugAction> actions;
for (Device* device : devices) {
auto* internal = device->internal;
lock_internal(internal);
auto* driver = internal->driver;
bool started = internal->state.started;
unlock_internal(internal);
if (driver == nullptr) {
continue;
}
if (driver->probe == nullptr) {
if (!started) {
actions.push_back({ device, true, true });
}
continue;
}
bool present = driver->probe(device) == ERROR_NONE;
lock_internal(internal);
bool act = false;
if (present == internal->hotplug.present) {
internal->hotplug.consecutive = 0;
} else if (++internal->hotplug.consecutive >= DEVICE_HOTPLUG_DEBOUNCE_COUNT) {
// hotplug.present isn't committed yet - only once the action below actually
// succeeds. Otherwise a failed device_start()/device_stop() would leave it matching
// the next stable probe(), and the poller would never retry.
internal->hotplug.consecutive = 0;
act = true;
}
unlock_internal(internal);
if (act) {
actions.push_back({ device, present, false });
}
}
error_t first_mandatory_error = ERROR_NONE;
for (const auto& action : actions) {
error_t error = action.start ? device_start(action.device) : device_stop(action.device);
if (error == ERROR_NONE && !action.mandatory) {
auto* internal = action.device->internal;
lock_internal(internal);
internal->hotplug.present = action.start;
unlock_internal(internal);
}
if (action.mandatory && action.start && error != ERROR_NONE && first_mandatory_error == ERROR_NONE) {
first_mandatory_error = error;
}
}
for (Device* device : devices) {
device_put(device);
}
return first_mandatory_error;
}
void device_hotplug_start_polling(void) {
if (hotplug_timer != nullptr) {
return;
}
hotplug_timer = timer_alloc(TIMER_TYPE_PERIODIC, pdMS_TO_TICKS(500), hotplug_timer_callback, nullptr);
timer_start(hotplug_timer);
}
} // extern "C"

View File

@ -129,6 +129,11 @@ error_t driver_bind(Driver* driver, Device* device) {
goto error;
}
if (driver->probe != nullptr && driver->probe(device) != ERROR_NONE) {
error = ERROR_NOT_FOUND;
goto error;
}
if (driver->start_device != nullptr) {
error = driver->start_device(device);
if (error != ERROR_NONE) {

View File

@ -3,21 +3,86 @@
#include <tactility/drivers/keyboard.h>
#include <tactility/error.h>
#include <tactility/concurrent/mutex.h>
#define KEYBOARD_DRIVER_API(driver) ((struct KeyboardApi*)driver->api)
// Subscriptions, filtered by `device` on fan-out.
static KeyboardEventSubscription* subscriptions = nullptr;
struct KeyboardEventMutex {
Mutex handle {};
KeyboardEventMutex() { mutex_construct(&handle); }
~KeyboardEventMutex() { mutex_destruct(&handle); }
};
// One coarse mutex is fine: fan-out is just a struct copy, never caller code.
static KeyboardEventMutex subscriptions_mutex;
// Caller must hold subscriptions_mutex.
static void fan_out_to_subscribers(Device* device, const KeyboardKeyData& data) {
for (KeyboardEventSubscription* sub = subscriptions; sub != nullptr; sub = sub->internal.next) {
if (sub->internal.device != device) {
continue;
}
if (sub->internal.count < KEYBOARD_EVENT_QUEUE_CAPACITY) {
uint8_t tail = (sub->internal.head + sub->internal.count) % KEYBOARD_EVENT_QUEUE_CAPACITY;
sub->internal.queue[tail] = data;
sub->internal.count++;
}
}
}
static bool try_pop(KeyboardEventSubscription* sub, KeyboardKeyData* out_data) {
mutex_lock(&subscriptions_mutex.handle);
bool has_event = sub->internal.count > 0;
if (has_event) {
*out_data = sub->internal.queue[sub->internal.head];
sub->internal.head = (sub->internal.head + 1) % KEYBOARD_EVENT_QUEUE_CAPACITY;
sub->internal.count--;
}
mutex_unlock(&subscriptions_mutex.handle);
return has_event;
}
extern "C" {
error_t keyboard_read_key(Device* device, KeyboardKeyData* data) {
// Under the hotplug poller a keyboard device can be constructed but not started (probe
// hasn't confirmed presence, or it's since detached) - a sync caller like LVGL's indev still
// polls it every tick regardless, so bail out rather than touching the driver's (possibly
// unallocated) state.
if (!device_is_ready(device)) {
*data = {};
return ERROR_INVALID_STATE;
}
const auto* driver = device_get_driver(device);
// Default the modifier/HID fields here rather than in each driver: only drivers whose hardware
// can report them set them, and the rest would otherwise leave whatever the caller's stack held.
if (KEYBOARD_DRIVER_API(driver)->read_key == nullptr) {
return ERROR_NOT_SUPPORTED;
}
// Defaulted here, not per-driver: only drivers whose hardware reports them set them.
data->ctrl = false;
data->alt = false;
data->hid_keycode = 0;
data->hid_modifier = 0;
return KEYBOARD_DRIVER_API(driver)->read_key(device, data);
error_t result = KEYBOARD_DRIVER_API(driver)->read_key(device, data);
if (result == ERROR_NONE && data->key != 0) {
// A sync caller (e.g. LVGL) isn't the only consumer of this key.
mutex_lock(&subscriptions_mutex.handle);
fan_out_to_subscribers(device, *data);
mutex_unlock(&subscriptions_mutex.handle);
}
return result;
}
void keyboard_emit_key(Device* device, KeyboardKeyData data) {
mutex_lock(&subscriptions_mutex.handle);
fan_out_to_subscribers(device, data);
mutex_unlock(&subscriptions_mutex.handle);
}
error_t keyboard_get_backlight(Device* device, Device** backlight_device) {
@ -40,8 +105,59 @@ bool keyboard_is_present(Device* device) {
return KEYBOARD_DRIVER_API(driver)->is_present(device);
}
error_t keyboard_subscribe(Device* device, KeyboardEventSubscription* sub) {
mutex_lock(&subscriptions_mutex.handle);
// Avoid cyclic subscription list that would loop forever
for (auto* current = subscriptions; current != nullptr; current = current->internal.next) {
if (current == sub) {
mutex_unlock(&subscriptions_mutex.handle);
return ERROR_INVALID_STATE;
}
}
sub->internal.device = device;
sub->internal.head = 0;
sub->internal.count = 0;
sub->internal.next = subscriptions;
subscriptions = sub;
mutex_unlock(&subscriptions_mutex.handle);
return ERROR_NONE;
}
error_t keyboard_unsubscribe(Device*, KeyboardEventSubscription* sub) {
error_t result = ERROR_NOT_FOUND;
mutex_lock(&subscriptions_mutex.handle);
for (KeyboardEventSubscription** link = &subscriptions; *link != nullptr; link = &(*link)->internal.next) {
if (*link == sub) {
*link = sub->internal.next;
result = ERROR_NONE;
break;
}
}
mutex_unlock(&subscriptions_mutex.handle);
return result;
}
error_t keyboard_poll(Device* device, KeyboardEventSubscription* sub, KeyboardKeyData* out_data) {
if (try_pop(sub, out_data)) {
return ERROR_NONE;
}
// keyboard_read_key() fans the result out to every subscriber, including `sub`.
KeyboardKeyData data;
if (keyboard_read_key(device, &data) != ERROR_NONE || data.key == 0) {
return ERROR_TIMEOUT;
}
return try_pop(sub, out_data) ? ERROR_NONE : ERROR_TIMEOUT;
}
const DeviceType KEYBOARD_TYPE {
.name = "keyboard"
.name = "keyboard",
};
}

View File

@ -65,13 +65,16 @@ error_t kernel_init(Module* const dts_modules[], const DtsDevice dts_devices[])
dts_module++;
}
// Starting is deferred to device_hotplug_poll_once() below so probe() can run first.
// DISABLED devices are never registered, so the poller never starts them.
const DtsDevice* dts_device = dts_devices;
while (dts_device->device != nullptr) {
if (dts_device->status == DTS_DEVICE_STATUS_OKAY) {
if (device_construct_add_start(dts_device->device, dts_device->compatible) != ERROR_NONE) {
LOG_E(TAG, "kernel_init failed to construct+add+start device: %s (%s)", dts_device->device->name, dts_device->compatible);
if (device_construct_add(dts_device->device, dts_device->compatible) != ERROR_NONE) {
LOG_E(TAG, "kernel_init failed to construct+add device: %s (%s)", dts_device->device->name, dts_device->compatible);
return ERROR_RESOURCE;
}
device_hotplug_register(dts_device->device);
} else if (dts_device->status == DTS_DEVICE_STATUS_DISABLED) {
if (device_construct_add(dts_device->device, dts_device->compatible) != ERROR_NONE) {
LOG_E(TAG, "kernel_init failed to construct+add device: %s (%s)", dts_device->device->name, dts_device->compatible);
@ -83,6 +86,13 @@ error_t kernel_init(Module* const dts_modules[], const DtsDevice dts_devices[])
dts_device++;
}
// A mandatory (probe == NULL) device's start failure aborts boot, as before.
if (device_hotplug_poll_once() != ERROR_NONE) {
LOG_E(TAG, "kernel_init failed to start a mandatory device");
return ERROR_RESOURCE;
}
device_hotplug_start_polling();
LOG_I(TAG, "init done");
return ERROR_NONE;
}

View File

@ -0,0 +1,129 @@
#include "doctest.h"
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/module.h>
static Module hotplug_test_module = {
.name = "hotplug_test_module",
.start = nullptr,
.stop = nullptr
};
static bool probe_present = false;
static int probe_driver_start_called = 0;
static int probe_driver_stop_called = 0;
static error_t probe_driver_probe(Device*) { return probe_present ? ERROR_NONE : ERROR_NOT_FOUND; }
static error_t probe_driver_start(Device*) { probe_driver_start_called++; return ERROR_NONE; }
static error_t probe_driver_stop(Device*) { probe_driver_stop_called++; return ERROR_NONE; }
static Driver probe_driver = {
.name = "hotplug_test_probe_driver",
.compatible = (const char*[]) { "hotplug_test,probe", nullptr },
.start_device = probe_driver_start,
.stop_device = probe_driver_stop,
.probe = probe_driver_probe,
.api = nullptr,
.device_type = nullptr,
.owner = &hotplug_test_module,
.internal = nullptr,
};
TEST_CASE("device_hotplug_poll_once starts/stops a probe-capable device after debounce") {
probe_present = false;
probe_driver_start_called = 0;
probe_driver_stop_called = 0;
static Device device {
.name = "hotplug_probe_device",
.config = nullptr,
.parent = nullptr,
};
CHECK_EQ(driver_construct_add(&probe_driver), ERROR_NONE);
CHECK_EQ(device_construct_add(&device, "hotplug_test,probe"), ERROR_NONE);
device_hotplug_register(&device);
// Not present: repeated polling never starts it.
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK_EQ(probe_driver_start_called, 0);
CHECK_FALSE(device_is_ready(&device));
// Present: needs DEVICE_HOTPLUG_DEBOUNCE_COUNT consecutive polls before it actually starts.
probe_present = true;
for (int i = 0; i < DEVICE_HOTPLUG_DEBOUNCE_COUNT - 1; i++) {
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK_FALSE(device_is_ready(&device));
}
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK(device_is_ready(&device));
CHECK_EQ(probe_driver_start_called, 1);
// Already started: further polls while still present don't restart it.
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK_EQ(probe_driver_start_called, 1);
// Absent again: needs the same debounce before it stops.
probe_present = false;
for (int i = 0; i < DEVICE_HOTPLUG_DEBOUNCE_COUNT - 1; i++) {
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK(device_is_ready(&device));
}
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK_FALSE(device_is_ready(&device));
CHECK_EQ(probe_driver_stop_called, 1);
// Only started/stopped, never removed/destructed.
CHECK(device_is_added(&device));
device_hotplug_unregister(&device);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&probe_driver), ERROR_NONE);
}
static int mandatory_driver_start_called = 0;
static error_t mandatory_driver_start(Device*) { mandatory_driver_start_called++; return ERROR_NONE; }
static error_t mandatory_driver_stop(Device*) { return ERROR_NONE; }
static Driver mandatory_driver = {
.name = "hotplug_test_mandatory_driver",
.compatible = (const char*[]) { "hotplug_test,mandatory", nullptr },
.start_device = mandatory_driver_start,
.stop_device = mandatory_driver_stop,
.probe = nullptr,
.api = nullptr,
.device_type = nullptr,
.owner = &hotplug_test_module,
.internal = nullptr,
};
TEST_CASE("device_hotplug_poll_once starts a mandatory (probe == NULL) device unconditionally on the first call") {
mandatory_driver_start_called = 0;
static Device device {
.name = "hotplug_mandatory_device",
.config = nullptr,
.parent = nullptr,
};
CHECK_EQ(driver_construct_add(&mandatory_driver), ERROR_NONE);
CHECK_EQ(device_construct_add(&device, "hotplug_test,mandatory"), ERROR_NONE);
device_hotplug_register(&device);
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK(device_is_ready(&device));
CHECK_EQ(mandatory_driver_start_called, 1);
// Already started: further polls don't restart it.
CHECK_EQ(device_hotplug_poll_once(), ERROR_NONE);
CHECK_EQ(mandatory_driver_start_called, 1);
device_hotplug_unregister(&device);
CHECK_EQ(device_stop(&device), ERROR_NONE);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&mandatory_driver), ERROR_NONE);
}

View File

@ -0,0 +1,225 @@
#include "doctest.h"
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/drivers/keyboard.h>
#include <tactility/module.h>
static Module keyboard_test_module = {
.name = "keyboard_test_module",
.start = nullptr,
.stop = nullptr
};
static int read_key_call_count = 0;
// First call reports a key press, every call after that reports nothing pending (key == 0),
// matching the convention every real driver uses (see e.g. sdl_keyboard_read_key).
static error_t fake_read_key(Device*, KeyboardKeyData* data) {
read_key_call_count++;
if (read_key_call_count == 1) {
data->key = 'a';
data->pressed = true;
data->continue_reading = false;
} else {
data->key = 0;
data->pressed = false;
data->continue_reading = false;
}
return ERROR_NONE;
}
static const KeyboardApi fake_keyboard_api = {
.read_key = fake_read_key,
.get_backlight = nullptr,
.is_present = nullptr,
};
static Driver fake_keyboard_driver = {
.name = "fake_keyboard_driver",
.compatible = (const char*[]) { "keyboard_test,fake", nullptr },
.start_device = nullptr,
.stop_device = nullptr,
.api = &fake_keyboard_api,
.device_type = &KEYBOARD_TYPE,
.owner = &keyboard_test_module,
.internal = nullptr,
};
// No read_key: this driver only ever pushes events via keyboard_emit_key().
static const KeyboardApi fake_emit_only_keyboard_api = {
.read_key = nullptr,
.get_backlight = nullptr,
.is_present = nullptr,
};
static Driver fake_emit_only_keyboard_driver = {
.name = "fake_emit_only_keyboard_driver",
.compatible = (const char*[]) { "keyboard_test,fake_emit_only", nullptr },
.start_device = nullptr,
.stop_device = nullptr,
.api = &fake_emit_only_keyboard_api,
.device_type = &KEYBOARD_TYPE,
.owner = &keyboard_test_module,
.internal = nullptr,
};
TEST_CASE("keyboard_read_key returns ERROR_INVALID_STATE, without touching the driver, on a device that isn't started") {
read_key_call_count = 0;
static Device fake_device {
.name = "fake_keyboard_device_not_started",
.config = nullptr,
.parent = nullptr,
};
CHECK_EQ(driver_construct_add(&fake_keyboard_driver), ERROR_NONE);
CHECK_EQ(device_construct_add(&fake_device, "keyboard_test,fake"), ERROR_NONE);
// Deliberately not started - a hotplug-managed keyboard can be constructed+added long before
// (or after) its driver is actually running, but LVGL's indev still polls it unconditionally.
KeyboardKeyData out { .key = 'x' };
CHECK_EQ(keyboard_read_key(&fake_device, &out), ERROR_INVALID_STATE);
CHECK_EQ(out.key, 0);
CHECK_EQ(read_key_call_count, 0);
CHECK_EQ(device_remove(&fake_device), ERROR_NONE);
CHECK_EQ(device_destruct(&fake_device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&fake_keyboard_driver), ERROR_NONE);
}
TEST_CASE("keyboard_poll's default implementation fans out a read_key result to other subscribers") {
read_key_call_count = 0;
static Device fake_device {
.name = "fake_keyboard_device",
.config = nullptr,
.parent = nullptr,
};
CHECK_EQ(driver_construct_add(&fake_keyboard_driver), ERROR_NONE);
CHECK_EQ(device_construct_add(&fake_device, "keyboard_test,fake"), ERROR_NONE);
CHECK_EQ(device_start(&fake_device), ERROR_NONE);
KeyboardEventSubscription sub_a {};
KeyboardEventSubscription sub_b {};
CHECK_EQ(keyboard_subscribe(&fake_device, &sub_a), ERROR_NONE);
CHECK_EQ(keyboard_subscribe(&fake_device, &sub_b), ERROR_NONE);
KeyboardKeyData out {};
CHECK_EQ(keyboard_poll(&fake_device, &sub_a, &out), ERROR_NONE);
CHECK_EQ(out.key, 'a');
CHECK_EQ(read_key_call_count, 1);
// sub_b receives the same event without a second hardware read - it was fanned out by sub_a's poll.
CHECK_EQ(keyboard_poll(&fake_device, &sub_b, &out), ERROR_NONE);
CHECK_EQ(out.key, 'a');
CHECK_EQ(read_key_call_count, 1);
// sub_b's queue is now empty; polling again falls through to a real (empty) hardware read.
CHECK_EQ(keyboard_poll(&fake_device, &sub_b, &out), ERROR_TIMEOUT);
CHECK_EQ(read_key_call_count, 2);
CHECK_EQ(keyboard_unsubscribe(&fake_device, &sub_a), ERROR_NONE);
CHECK_EQ(keyboard_unsubscribe(&fake_device, &sub_b), ERROR_NONE);
CHECK_EQ(device_stop(&fake_device), ERROR_NONE);
CHECK_EQ(device_remove(&fake_device), ERROR_NONE);
CHECK_EQ(device_destruct(&fake_device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&fake_keyboard_driver), ERROR_NONE);
}
TEST_CASE("keyboard_read_key fans out to subscribers even when called directly") {
read_key_call_count = 0;
static Device fake_device {
.name = "fake_keyboard_device_read_key_fanout",
.config = nullptr,
.parent = nullptr,
};
CHECK_EQ(driver_construct_add(&fake_keyboard_driver), ERROR_NONE);
CHECK_EQ(device_construct_add(&fake_device, "keyboard_test,fake"), ERROR_NONE);
CHECK_EQ(device_start(&fake_device), ERROR_NONE);
KeyboardEventSubscription sub {};
CHECK_EQ(keyboard_subscribe(&fake_device, &sub), ERROR_NONE);
KeyboardKeyData out {};
CHECK_EQ(keyboard_read_key(&fake_device, &out), ERROR_NONE);
CHECK_EQ(out.key, 'a');
// sub never called keyboard_poll() itself - it received this via keyboard_read_key()'s fan-out.
KeyboardKeyData polled {};
CHECK_EQ(keyboard_poll(&fake_device, &sub, &polled), ERROR_NONE);
CHECK_EQ(polled.key, 'a');
CHECK_EQ(read_key_call_count, 1);
CHECK_EQ(keyboard_unsubscribe(&fake_device, &sub), ERROR_NONE);
CHECK_EQ(device_stop(&fake_device), ERROR_NONE);
CHECK_EQ(device_remove(&fake_device), ERROR_NONE);
CHECK_EQ(device_destruct(&fake_device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&fake_keyboard_driver), ERROR_NONE);
}
TEST_CASE("keyboard_emit_key delivers to subscribers of a driver with no read_key") {
static Device fake_device {
.name = "fake_emit_only_keyboard_device",
.config = nullptr,
.parent = nullptr,
};
CHECK_EQ(driver_construct_add(&fake_emit_only_keyboard_driver), ERROR_NONE);
CHECK_EQ(device_construct_add(&fake_device, "keyboard_test,fake_emit_only"), ERROR_NONE);
CHECK_EQ(device_start(&fake_device), ERROR_NONE);
KeyboardEventSubscription sub {};
CHECK_EQ(keyboard_subscribe(&fake_device, &sub), ERROR_NONE);
// Nothing pushed yet, and there's no read_key to fall back on.
KeyboardKeyData out {};
CHECK_EQ(keyboard_poll(&fake_device, &sub, &out), ERROR_TIMEOUT);
KeyboardKeyData emitted { .key = 'z', .pressed = true, .continue_reading = false };
keyboard_emit_key(&fake_device, emitted);
CHECK_EQ(keyboard_poll(&fake_device, &sub, &out), ERROR_NONE);
CHECK_EQ(out.key, 'z');
CHECK_EQ(keyboard_unsubscribe(&fake_device, &sub), ERROR_NONE);
CHECK_EQ(device_stop(&fake_device), ERROR_NONE);
CHECK_EQ(device_remove(&fake_device), ERROR_NONE);
CHECK_EQ(device_destruct(&fake_device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&fake_emit_only_keyboard_driver), ERROR_NONE);
}
TEST_CASE("keyboard_unsubscribe stops a subscription from receiving further fanned-out events") {
read_key_call_count = 0;
static Device fake_device {
.name = "fake_keyboard_device_2",
.config = nullptr,
.parent = nullptr,
};
CHECK_EQ(driver_construct_add(&fake_keyboard_driver), ERROR_NONE);
CHECK_EQ(device_construct_add(&fake_device, "keyboard_test,fake"), ERROR_NONE);
CHECK_EQ(device_start(&fake_device), ERROR_NONE);
KeyboardEventSubscription sub_a {};
KeyboardEventSubscription sub_b {};
CHECK_EQ(keyboard_subscribe(&fake_device, &sub_a), ERROR_NONE);
CHECK_EQ(keyboard_subscribe(&fake_device, &sub_b), ERROR_NONE);
CHECK_EQ(keyboard_unsubscribe(&fake_device, &sub_b), ERROR_NONE);
KeyboardKeyData out {};
CHECK_EQ(keyboard_poll(&fake_device, &sub_a, &out), ERROR_NONE);
CHECK_EQ(out.key, 'a');
// sub_b was unsubscribed before the poll, so nothing was fanned out to it.
CHECK_EQ(keyboard_poll(&fake_device, &sub_b, &out), ERROR_TIMEOUT);
CHECK_EQ(keyboard_unsubscribe(&fake_device, &sub_a), ERROR_NONE);
CHECK_EQ(device_stop(&fake_device), ERROR_NONE);
CHECK_EQ(device_remove(&fake_device), ERROR_NONE);
CHECK_EQ(device_destruct(&fake_device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&fake_keyboard_driver), ERROR_NONE);
}