Implement async keyboard API

This commit is contained in:
Ken Van Hoeylandt 2026-09-19 12:18:35 +02:00
parent 65ee223876
commit 996aea8dac
3 changed files with 342 additions and 14 deletions

View File

@ -77,11 +77,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 +91,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 +127,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,10 +137,10 @@ 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
* @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
@ -127,9 +150,22 @@ struct KeyboardApi {
/**
* @brief Reads the next pending key event using the specified keyboard device.
* @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 +177,34 @@ 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.
* @brief Whether the keyboard device is physically present right now.
* Returns true when the driver doesn't implement 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

@ -3,13 +3,49 @@
#include <tactility/drivers/keyboard.h>
#include <tactility/error.h>
#include <tactility/concurrent/mutex.h>
#define KEYBOARD_DRIVER_API(driver) ((struct KeyboardApi*)driver->api)
/**
* Intrusive singly-linked list of subscriptions, filtered by `device` on fan-out. Guarded by a
* single coarse-grained mutex - same rationale as app_event.cpp's subscriptions_mutex: fanning
* out here is just a struct copy, never caller code.
*/
static KeyboardEventSubscription* subscriptions = nullptr;
struct KeyboardEventMutex {
Mutex handle {};
KeyboardEventMutex() { mutex_construct(&handle); }
~KeyboardEventMutex() { mutex_destruct(&handle); }
};
static KeyboardEventMutex subscriptions_mutex;
// Pushes `data` onto every subscription for `device` except `exclude` (pass nullptr to include
// all of them). Caller must hold subscriptions_mutex.
static void fan_out_to_subscribers(Device* device, const KeyboardKeyData& data, const KeyboardEventSubscription* exclude) {
for (KeyboardEventSubscription* sub = subscriptions; sub != nullptr; sub = sub->internal.next) {
if (sub == exclude || 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++;
}
}
}
extern "C" {
error_t keyboard_read_key(Device* device, KeyboardKeyData* data) {
const auto* driver = device_get_driver(device);
if (KEYBOARD_DRIVER_API(driver)->read_key == nullptr) {
return ERROR_NOT_SUPPORTED;
}
// 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.
data->ctrl = false;
@ -20,6 +56,12 @@ error_t keyboard_read_key(Device* device, KeyboardKeyData* data) {
return KEYBOARD_DRIVER_API(driver)->read_key(device, data);
}
void keyboard_emit_key(Device* device, KeyboardKeyData data) {
mutex_lock(&subscriptions_mutex.handle);
fan_out_to_subscribers(device, data, nullptr);
mutex_unlock(&subscriptions_mutex.handle);
}
error_t keyboard_get_backlight(Device* device, Device** backlight_device) {
const auto* driver = device_get_driver(device);
@ -40,8 +82,69 @@ 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
if (subscriptions == 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) {
mutex_lock(&subscriptions_mutex.handle);
if (sub->internal.count > 0) {
*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 ERROR_NONE;
}
// No queued event of our own - try a synchronous read (if the driver has one; a driver that
// only pushes via keyboard_emit_key() has no read_key, and there's simply nothing new yet).
KeyboardKeyData data;
if (keyboard_read_key(device, &data) != ERROR_NONE || data.key == 0) {
mutex_unlock(&subscriptions_mutex.handle);
return ERROR_TIMEOUT;
}
fan_out_to_subscribers(device, data, sub);
*out_data = data;
mutex_unlock(&subscriptions_mutex.handle);
return ERROR_NONE;
}
const DeviceType KEYBOARD_TYPE {
.name = "keyboard"
.name = "keyboard",
};
}

View File

@ -0,0 +1,168 @@
#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_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_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);
}