From c1a60a2293e9679cfcb40e6617e40734f64b88ab Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Tue, 19 May 2026 00:10:54 +0100 Subject: [PATCH 1/7] Add keyboard and joystick REST API --- software/api/json.cc | 31 +- software/api/json.h | 2 +- software/api/route_input.cc | 764 ++++++++++++++++++++++ software/api/route_machine.cc | 16 + software/api/routes.cc | 8 + software/io/c64/joystick_output.cc | 197 ++++++ software/io/c64/joystick_output.h | 40 ++ software/io/usb/keyboard_usb.cc | 174 ++++- software/io/usb/keyboard_usb.h | 30 + software/io/usb/usb_hid.cc | 98 +-- target/u64/nios2/ultimate/Makefile | 2 + target/u64ii/riscv/ultimate/Makefile | 2 + tools/api/input_test.py | 767 +++++++++++++++++++++++ tools/api/input_tool.py | 606 ++++++++++++++++++ tools/api/test_input_tool_gamepad.py | 72 +++ tools/api/test_input_tool_interactive.py | 132 ++++ tools/api/test_unit_validation.py | 624 ++++++++++++++++++ 17 files changed, 3513 insertions(+), 52 deletions(-) create mode 100644 software/api/route_input.cc create mode 100644 software/io/c64/joystick_output.cc create mode 100644 software/io/c64/joystick_output.h create mode 100755 tools/api/input_test.py create mode 100755 tools/api/input_tool.py create mode 100644 tools/api/test_input_tool_gamepad.py create mode 100644 tools/api/test_input_tool_interactive.py create mode 100644 tools/api/test_unit_validation.py diff --git a/software/api/json.cc b/software/api/json.cc index 12bb090ca..394bdbf71 100644 --- a/software/api/json.cc +++ b/software/api/json.cc @@ -22,12 +22,22 @@ static int parse_json(char *text, size_t text_size, jsmntok_t *tokens, size_t to static JSON *convert(char *text, jsmntok_t *tokens, size_t num_tokens) { + if (num_tokens == 0) { + return NULL; + } JSON **objects = (JSON **)malloc(sizeof(JSON *) * num_tokens); + if (!objects) { + return NULL; + } memset(objects, 0, num_tokens * sizeof(JSON *)); bool invalidJson = false; for (unsigned int i = 0; i < num_tokens; i++) { jsmntok_t *current = &(tokens[i]); + if ((current->start < 0) || (current->end < current->start)) { + invalidJson = true; + break; + } text[current->end] = 0; jsmntok_t *parent = current->parent < 0 ? NULL : (&tokens[current->parent]); @@ -36,7 +46,7 @@ static JSON *convert(char *text, jsmntok_t *tokens, size_t num_tokens) // printf("STR %.*s\n", current->end - current->start, raw_space + current->start); // If the parent is an object, then the string is a key value. For key values // we don't create a string object. - if (parent->type != JSMN_OBJECT) { + if (!parent || (parent->type != JSMN_OBJECT)) { objects[i] = new JSON_String(text + current->start); } else { current->type = JSMN_KEY; @@ -94,7 +104,14 @@ static JSON *convert(char *text, jsmntok_t *tokens, size_t num_tokens) } } } else if (parent->type == JSMN_ARRAY) { - ((JSON_List *)objects[current->parent])->add(objects[i]); + JSON *o = objects[current->parent]; + if (!o || o->type() != eList || !objects[i]) { + invalidJson = true; + delete objects[i]; + objects[i] = NULL; + } else { + ((JSON_List *)o)->add(objects[i]); + } } else if (objects[i]) { //printf("Current object %s cannot be attached.\n", objects[i]->render()); invalidJson = true; @@ -114,7 +131,14 @@ static JSON *convert(char *text, jsmntok_t *tokens, size_t num_tokens) int convert_text_to_json_objects(char *text, size_t text_size, size_t max_tokens, JSON **out) { + *out = NULL; + if ((text_size == 0) || (max_tokens == 0)) { + return JSMN_ERROR_ILLEGAL; + } jsmntok_t *tokens = (jsmntok_t *)malloc(sizeof(jsmntok_t) * max_tokens); + if (!tokens) { + return JSMN_ERROR_NOMEM; + } // memset(tokens, 0, max_tokens * sizeof(jsmntok_t)); // not necessary? int tokens_used = parse_json(text, text_size, tokens, max_tokens); @@ -130,7 +154,6 @@ int convert_text_to_json_objects(char *text, size_t text_size, size_t max_tokens } else { printf("Parsing JSON succeeded: %d tokens.\n", tokens_used); } - *out = NULL; *out = convert(text, tokens, tokens_used); free(tokens); if (!(*out)) { @@ -155,4 +178,4 @@ int main() printf("%s\n", list->render()); delete list; } -#endif \ No newline at end of file +#endif diff --git a/software/api/json.h b/software/api/json.h index 382f5322a..fdebb1bf2 100644 --- a/software/api/json.h +++ b/software/api/json.h @@ -226,4 +226,4 @@ class JSON_List : public JSON int convert_text_to_json_objects(char *text, size_t text_size, size_t max_tokens, JSON **out); -#endif \ No newline at end of file +#endif diff --git a/software/api/route_input.cc b/software/api/route_input.cc new file mode 100644 index 000000000..26789a0a2 --- /dev/null +++ b/software/api/route_input.cc @@ -0,0 +1,764 @@ +#include "routes.h" +#include "attachment_writer.h" +#include "itu.h" +#include "keyboard_usb.h" +#include "joystick_output.h" + +#include "FreeRTOS.h" +#include "semphr.h" + +#include +#include +#include +#include + +static const char *INPUT_CAPABILITY_ERROR = "Keyboard and joystick injection require Ultimate 64-class hardware."; +static const char *INPUT_JOYSTICK_PORT2_ERROR = "Joystick port 2 injection is not supported by this FPGA build."; +static const int INPUT_ERROR_SIZE = 160; + +#if U64 + +static const uint8_t REST_TAP_HOLD_TICKS = 1; +static SemaphoreHandle_t rest_input_mutex = NULL; + +enum ParsedKind { + PARSED_KEYBOARD, + PARSED_JOYSTICK, + PARSED_RELEASE_ALL +}; + +enum ParsedTransition { + PARSED_PRESS, + PARSED_RELEASE, + PARSED_TAP +}; + +struct KeyboardMapEntry { + const char *name; + uint8_t row; + uint8_t col; + bool restore; +}; + +struct JoystickMapEntry { + const char *name; + uint8_t bit; +}; + +struct ParsedEvent { + ParsedKind kind; + ParsedTransition transition; + uint8_t port; + uint8_t keyboard_count; + uint8_t keyboard_index[8]; + uint8_t joystick_mask; +}; + +static const KeyboardMapEntry keyboard_map[] = { + { "inst_del", 0, 0, false }, + { "return", 0, 1, false }, + { "cursor_left_right", 0, 2, false }, + { "f7", 0, 3, false }, + { "f1", 0, 4, false }, + { "f3", 0, 5, false }, + { "f5", 0, 6, false }, + { "cursor_up_down", 0, 7, false }, + { "3", 1, 0, false }, + { "w", 1, 1, false }, + { "a", 1, 2, false }, + { "4", 1, 3, false }, + { "z", 1, 4, false }, + { "s", 1, 5, false }, + { "e", 1, 6, false }, + { "left_shift", 1, 7, false }, + { "5", 2, 0, false }, + { "r", 2, 1, false }, + { "d", 2, 2, false }, + { "6", 2, 3, false }, + { "c", 2, 4, false }, + { "f", 2, 5, false }, + { "t", 2, 6, false }, + { "x", 2, 7, false }, + { "7", 3, 0, false }, + { "y", 3, 1, false }, + { "g", 3, 2, false }, + { "8", 3, 3, false }, + { "b", 3, 4, false }, + { "h", 3, 5, false }, + { "u", 3, 6, false }, + { "v", 3, 7, false }, + { "9", 4, 0, false }, + { "i", 4, 1, false }, + { "j", 4, 2, false }, + { "0", 4, 3, false }, + { "m", 4, 4, false }, + { "k", 4, 5, false }, + { "o", 4, 6, false }, + { "n", 4, 7, false }, + { "plus", 5, 0, false }, + { "p", 5, 1, false }, + { "l", 5, 2, false }, + { "minus", 5, 3, false }, + { "period", 5, 4, false }, + { "colon", 5, 5, false }, + { "at", 5, 6, false }, + { "comma", 5, 7, false }, + { "pound", 6, 0, false }, + { "star", 6, 1, false }, + { "semicolon", 6, 2, false }, + { "clr_home", 6, 3, false }, + { "right_shift", 6, 4, false }, + { "equals", 6, 5, false }, + { "arrow_up", 6, 6, false }, + { "slash", 6, 7, false }, + { "1", 7, 0, false }, + { "arrow_left", 7, 1, false }, + { "ctrl", 7, 2, false }, + { "2", 7, 3, false }, + { "space", 7, 4, false }, + { "commodore", 7, 5, false }, + { "q", 7, 6, false }, + { "run_stop", 7, 7, false }, + { "restore", 0, 0, true }, +}; + +static const JoystickMapEntry joystick_map[] = { + { "up", 0 }, + { "down", 1 }, + { "left", 2 }, + { "right", 3 }, + { "fire", 4 }, +}; + +static SemaphoreHandle_t input_mutex(void) +{ + if (!rest_input_mutex) { + rest_input_mutex = xSemaphoreCreateMutex(); + } + return rest_input_mutex; +} + +static int keyboard_map_count(void) +{ + return sizeof(keyboard_map) / sizeof(keyboard_map[0]); +} + +static int joystick_map_count(void) +{ + return sizeof(joystick_map) / sizeof(joystick_map[0]); +} + +static void copy_preview(char *dst, size_t dst_size, const char *src) +{ + if (!dst || (dst_size == 0)) { + return; + } + if (!src) { + dst[0] = 0; + return; + } + size_t i = 0; + while (src[i] && ((i + 1) < dst_size)) { + dst[i] = src[i]; + i++; + } + if (src[i] && (dst_size >= 4)) { + dst[dst_size - 4] = '.'; + dst[dst_size - 3] = '.'; + dst[dst_size - 2] = '.'; + dst[dst_size - 1] = 0; + return; + } + dst[i] = 0; +} + +static void set_error(char *err, const char *msg) +{ + copy_preview(err, INPUT_ERROR_SIZE, msg); +} + +static bool has_key(JSON_Object *obj, const char *name) +{ + return obj->get(name) != NULL; +} + +static bool key_allowed(const char *key, const char *const *allowed, int allowed_count) +{ + for (int i = 0; i < allowed_count; i++) { + if (strcmp(key, allowed[i]) == 0) { + return true; + } + } + return false; +} + +static bool reject_unknown_keys(JSON_Object *obj, const char *const *allowed, int allowed_count, char *err) +{ + IndexedList *keys = obj->get_keys(); + for (int i = 0; i < keys->get_elements(); i++) { + const char *key = (*keys)[i]; + if (!key_allowed(key, allowed, allowed_count)) { + char preview[32]; + copy_preview(preview, sizeof(preview), key); + sprintf(err, "Unknown field `%s`.", preview); + return false; + } + } + return true; +} + +static bool get_string_field(JSON_Object *obj, const char *name, const char **out, char *err) +{ + JSON *value = obj->get(name); + if (!value) { + sprintf(err, "`%s` is required.", name); + return false; + } + if (value->type() != eString) { + sprintf(err, "`%s` must be a string.", name); + return false; + } + *out = ((JSON_String *)value)->get_string(); + return true; +} + +static bool parse_transition(JSON_Object *obj, ParsedTransition &transition, char *err) +{ + const char *name; + if (!get_string_field(obj, "transition", &name, err)) { + return false; + } + if (strcmp(name, "press") == 0) { + transition = PARSED_PRESS; + return true; + } + if (strcmp(name, "release") == 0) { + transition = PARSED_RELEASE; + return true; + } + if (strcmp(name, "tap") == 0) { + transition = PARSED_TAP; + return true; + } + set_error(err, "`transition` must be one of `press`, `release`, or `tap`."); + return false; +} + +static int find_keyboard_input(const char *name) +{ + for (int i = 0; i < keyboard_map_count(); i++) { + if (strcmp(name, keyboard_map[i].name) == 0) { + return i; + } + } + return -1; +} + +static int find_joystick_input(const char *name) +{ + for (int i = 0; i < joystick_map_count(); i++) { + if (strcmp(name, joystick_map[i].name) == 0) { + return i; + } + } + return -1; +} + +static bool parse_keyboard_event(JSON_Object *obj, ParsedEvent &out, char *err) +{ + static const char *const allowed[] = { "kind", "inputs", "transition" }; + if (!reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err)) { + return false; + } + if (has_key(obj, "port")) { + set_error(err, "`port` is not allowed for keyboard events."); + return false; + } + if (!parse_transition(obj, out.transition, err)) { + return false; + } + JSON *inputs = obj->get("inputs"); + if (!inputs) { + set_error(err, "`inputs` is required."); + return false; + } + if (inputs->type() != eList) { + set_error(err, "`inputs` must be an array."); + return false; + } + JSON_List *list = (JSON_List *)inputs; + int count = list->get_num_elements(); + if ((count < 1) || (count > 8)) { + set_error(err, "`inputs` must contain 1..8 entries."); + return false; + } + + bool seen[sizeof(keyboard_map) / sizeof(keyboard_map[0])] = { false }; + bool has_restore = false; + out.kind = PARSED_KEYBOARD; + out.keyboard_count = count; + for (int i = 0; i < count; i++) { + JSON *entry = (*list)[i]; + if (entry->type() != eString) { + set_error(err, "Keyboard inputs must be strings."); + return false; + } + const char *name = ((JSON_String *)entry)->get_string(); + int index = find_keyboard_input(name); + if (index < 0) { + char preview[32]; + copy_preview(preview, sizeof(preview), name); + sprintf(err, "`%s` is not a valid keyboard input.", preview); + return false; + } + if (seen[index]) { + char preview[32]; + copy_preview(preview, sizeof(preview), name); + sprintf(err, "`%s` appears more than once in `inputs`.", preview); + return false; + } + seen[index] = true; + out.keyboard_index[i] = index; + if (keyboard_map[index].restore) { + has_restore = true; + } + } + + if (has_restore && ((count != 1) || (out.transition != PARSED_TAP))) { + set_error(err, "`restore` must appear alone in `inputs` and only with transition `tap`."); + return false; + } + return true; +} + +static bool parse_joystick_event(JSON_Object *obj, ParsedEvent &out, char *err) +{ + static const char *const allowed[] = { "kind", "port", "inputs", "transition" }; + if (!reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err)) { + return false; + } + if (!parse_transition(obj, out.transition, err)) { + return false; + } + JSON *port = obj->get("port"); + if (!port) { + set_error(err, "`port` is required."); + return false; + } + if (port->type() != eInteger) { + set_error(err, "`port` must be an integer."); + return false; + } + int port_value = ((JSON_Integer *)port)->get_value(); + if ((port_value != 1) && (port_value != 2)) { + set_error(err, "`port` must be 1 or 2."); + return false; + } + + JSON *inputs = obj->get("inputs"); + if (!inputs) { + set_error(err, "`inputs` is required."); + return false; + } + if (inputs->type() != eList) { + set_error(err, "`inputs` must be an array."); + return false; + } + JSON_List *list = (JSON_List *)inputs; + int count = list->get_num_elements(); + if ((count < 1) || (count > 5)) { + set_error(err, "`inputs` must contain 1..5 entries."); + return false; + } + + bool seen[sizeof(joystick_map) / sizeof(joystick_map[0])] = { false }; + out.kind = PARSED_JOYSTICK; + out.port = port_value; + out.joystick_mask = 0; + for (int i = 0; i < count; i++) { + JSON *entry = (*list)[i]; + if (entry->type() != eString) { + set_error(err, "Joystick inputs must be strings."); + return false; + } + const char *name = ((JSON_String *)entry)->get_string(); + int index = find_joystick_input(name); + if (index < 0) { + char preview[32]; + copy_preview(preview, sizeof(preview), name); + sprintf(err, "`%s` is not a valid joystick input.", preview); + return false; + } + if (seen[index]) { + char preview[32]; + copy_preview(preview, sizeof(preview), name); + sprintf(err, "`%s` appears more than once in `inputs`.", preview); + return false; + } + seen[index] = true; + out.joystick_mask |= (1 << joystick_map[index].bit); + } + return true; +} + +static bool parse_release_all_event(JSON_Object *obj, ParsedEvent &out, char *err) +{ + static const char *const allowed[] = { "kind" }; + if (!reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err)) { + return false; + } + if (has_key(obj, "port") || has_key(obj, "inputs") || has_key(obj, "transition")) { + set_error(err, "`release_all` events may only contain `kind`."); + return false; + } + out.kind = PARSED_RELEASE_ALL; + return true; +} + +static bool parse_event(JSON *json, ParsedEvent &out, char *err) +{ + if (json->type() != eObject) { + set_error(err, "Each event must be an object."); + return false; + } + JSON_Object *obj = (JSON_Object *)json; + const char *kind; + if (!get_string_field(obj, "kind", &kind, err)) { + return false; + } + if (strcmp(kind, "keyboard") == 0) { + return parse_keyboard_event(obj, out, err); + } + if (strcmp(kind, "joystick") == 0) { + return parse_joystick_event(obj, out, err); + } + if (strcmp(kind, "release_all") == 0) { + return parse_release_all_event(obj, out, err); + } + set_error(err, "`kind` must be one of `keyboard`, `joystick`, or `release_all`."); + return false; +} + +static bool validate_batch(ResponseWrapper *resp, JSON *root, ParsedEvent events[64], int &event_count, int &pace_ms) +{ + if (root->type() != eObject) { + resp->error("Root must be an object."); + return false; + } + JSON_Object *obj = (JSON_Object *)root; + static const char *const allowed[] = { "events", "pace_ms" }; + char err[INPUT_ERROR_SIZE]; + if (!reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err)) { + resp->error("%s", err); + return false; + } + pace_ms = 0; + JSON *pace = obj->get("pace_ms"); + if (pace) { + if (pace->type() != eInteger) { + resp->error("`pace_ms` must be an integer."); + return false; + } + pace_ms = ((JSON_Integer *)pace)->get_value(); + if ((pace_ms < 0) || (pace_ms > 1000)) { + resp->error("`pace_ms` must be between 0 and 1000."); + return false; + } + } + JSON *events_json = obj->get("events"); + if (!events_json) { + resp->error("`events` is required."); + return false; + } + if (events_json->type() != eList) { + resp->error("`events` must be an array."); + return false; + } + JSON_List *list = (JSON_List *)events_json; + event_count = list->get_num_elements(); + if ((event_count < 1) || (event_count > 64)) { + resp->error("`events` must contain 1..64 entries."); + return false; + } + for (int i = 0; i < event_count; i++) { + err[0] = 0; + if (!parse_event((*list)[i], events[i], err)) { + resp->error("events[%d]: %s", i, err); + return false; + } + } + return true; +} + +static bool batch_uses_unsupported_port2(const ParsedEvent *events, int event_count) +{ + if (JoystickOutput::port2Supported()) { + return false; + } + for (int i = 0; i < event_count; i++) { + if ((events[i].kind == PARSED_JOYSTICK) && (events[i].port == 2)) { + return true; + } + } + return false; +} + +static void apply_joystick_event(const ParsedEvent &event) +{ + uint8_t p1, p2; + uint8_t active_low; + uint8_t hold[5] = { 0, 0, 0, 0, 0 }; + + JoystickOutput::instance().restPersistentSnapshot(p1, p2); + active_low = (event.port == 1) ? p1 : p2; + + switch (event.transition) { + case PARSED_PRESS: + active_low &= ~event.joystick_mask; + if (event.port == 1) { + JoystickOutput::instance().setRestPort1Persistent(active_low); + } else { + JoystickOutput::instance().setRestPort2Persistent(active_low); + } + break; + case PARSED_RELEASE: + active_low |= event.joystick_mask; + if (event.port == 1) { + JoystickOutput::instance().setRestPort1Persistent(active_low); + } else { + JoystickOutput::instance().setRestPort2Persistent(active_low); + } + break; + case PARSED_TAP: + for (int i = 0; i < 5; i++) { + if (event.joystick_mask & (1 << i)) { + hold[i] = REST_TAP_HOLD_TICKS; + } + } + active_low = 0x1F & ~event.joystick_mask; + if (event.port == 1) { + JoystickOutput::instance().armRestPort1Overlay(active_low, hold); + } else { + JoystickOutput::instance().armRestPort2Overlay(active_low, hold); + } + break; + } +} + +static void apply_keyboard_event(const ParsedEvent &event) +{ + for (int i = 0; i < event.keyboard_count; i++) { + const KeyboardMapEntry &entry = keyboard_map[event.keyboard_index[i]]; + if (entry.restore) { + system_usb_keyboard.restTapRestore(REST_TAP_HOLD_TICKS); + continue; + } + switch (event.transition) { + case PARSED_PRESS: + system_usb_keyboard.restPress(entry.row, entry.col); + break; + case PARSED_RELEASE: + system_usb_keyboard.restRelease(entry.row, entry.col); + break; + case PARSED_TAP: + system_usb_keyboard.restTap(entry.row, entry.col, REST_TAP_HOLD_TICKS); + break; + } + } +} + +static void apply_batch(const ParsedEvent *events, int event_count, int pace_ms) +{ + TickType_t delay_ticks = 0; + if (pace_ms > 0) { + delay_ticks = pdMS_TO_TICKS(pace_ms); + if (delay_ticks == 0) { + delay_ticks = 1; + } + } + for (int i = 0; i < event_count; i++) { + switch (events[i].kind) { + case PARSED_KEYBOARD: + apply_keyboard_event(events[i]); + break; + case PARSED_JOYSTICK: + apply_joystick_event(events[i]); + break; + case PARSED_RELEASE_ALL: + system_usb_keyboard.restReleaseAll(); + JoystickOutput::instance().releaseAllRest(); + break; + } + if (delay_ticks && ((i + 1) < event_count)) { + vTaskDelay(delay_ticks); + } + } +} + +static void emit_joystick_inputs(JSON_Object *obj, uint8_t active_low) +{ + JSON_List *inputs = JSON::List(); + for (int i = 0; i < joystick_map_count(); i++) { + uint8_t bit = (1 << joystick_map[i].bit); + if (!(active_low & bit)) { + inputs->add(joystick_map[i].name); + } + } + obj->add("inputs", inputs); +} + +static void emit_state_snapshot(ResponseWrapper *resp) +{ + uint8_t matrix[8]; + bool restore; + uint8_t joy1; + uint8_t joy2; + + system_usb_keyboard.restSnapshot(matrix, restore); + JoystickOutput::instance().snapshot(joy1, joy2); + + JSON_List *keyboard_inputs = JSON::List(); + for (int i = 0; i < keyboard_map_count(); i++) { + const KeyboardMapEntry &entry = keyboard_map[i]; + if (entry.restore) { + if (restore) { + keyboard_inputs->add(entry.name); + } + } else if (matrix[entry.row] & (1 << entry.col)) { + keyboard_inputs->add(entry.name); + } + } + + resp->json->add("keyboard", JSON::Obj()->add("inputs", keyboard_inputs)); + + JSON_List *joysticks = JSON::List(); + JSON_Object *port1 = JSON::Obj()->add("port", 1); + emit_joystick_inputs(port1, joy1); + joysticks->add(port1); + + JSON_Object *port2 = JSON::Obj()->add("port", 2); + emit_joystick_inputs(port2, joy2); + joysticks->add(port2); + + resp->json->add("joysticks", joysticks); +} + +static bool ensure_input_capability(ResponseWrapper *resp) +{ + if (!(getFpgaCapabilities() & CAPAB_ULTIMATE64)) { + resp->error(INPUT_CAPABILITY_ERROR); + resp->json_response(HTTP_NOT_IMPLEMENTED); + return false; + } + return true; +} + +#endif + +API_CALL(GET, machine, input, NULL, ARRAY( { })) +{ +#if U64 + if (!ensure_input_capability(resp)) { + return; + } + SemaphoreHandle_t mutex = input_mutex(); + if (!mutex) { + resp->error("Could not create REST input mutex."); + resp->json_response(HTTP_INTERNAL_SERVER_ERROR); + return; + } + xSemaphoreTake(mutex, portMAX_DELAY); + emit_state_snapshot(resp); + xSemaphoreGive(mutex); + resp->json_response(HTTP_OK); +#else + resp->error(INPUT_CAPABILITY_ERROR); + resp->json_response(HTTP_NOT_IMPLEMENTED); +#endif +} + +API_CALL(POST, machine, input, &attachment_writer, ARRAY( { })) +{ +#if U64 + if (!ensure_input_capability(resp)) { + return; + } + if (!input_mutex()) { + resp->error("Could not create REST input mutex."); + resp->json_response(HTTP_INTERNAL_SERVER_ERROR); + return; + } + if (!req->ContentType || (strcasecmp(req->ContentType, "application/json") != 0)) { + resp->error("Content type should be 'application/json'."); + resp->json_response(HTTP_BAD_REQUEST); + return; + } + TempfileWriter *handler = (TempfileWriter *)body; + if (!handler) { + resp->error("Request body is required."); + resp->json_response(HTTP_BAD_REQUEST); + return; + } + int buffered = handler->buffer_file(0, 4096); + if (buffered != 0) { + if (buffered == -1) { + resp->error("Request body is required."); + } else if (buffered == -2) { + resp->error("JSON body is too large."); + } else { + resp->error("Could not buffer attachment."); + } + resp->json_response(HTTP_BAD_REQUEST); + return; + } + JSON *obj = NULL; + size_t text_size = handler->get_filesize(0); + char *text = (char *)malloc(text_size + 1); + if (!text) { + resp->error("Could not allocate JSON buffer."); + resp->json_response(HTTP_INTERNAL_SERVER_ERROR); + return; + } + memcpy(text, handler->get_buffer(0), text_size); + text[text_size] = 0; + + int tokens = convert_text_to_json_objects(text, text_size, 1024, &obj); + if (tokens < 0) { + resp->error("JSON could not be parsed. Error: %d", tokens); + free(text); + resp->json_response(HTTP_BAD_REQUEST); + return; + } + + static ParsedEvent events[64]; + int event_count = 0; + int pace_ms = 0; + SemaphoreHandle_t mutex = input_mutex(); + xSemaphoreTake(mutex, portMAX_DELAY); + if (!validate_batch(resp, obj, events, event_count, pace_ms)) { + xSemaphoreGive(mutex); + delete obj; + free(text); + resp->json_response(HTTP_BAD_REQUEST); + return; + } + if (batch_uses_unsupported_port2(events, event_count)) { + xSemaphoreGive(mutex); + delete obj; + free(text); + resp->error(INPUT_JOYSTICK_PORT2_ERROR); + resp->json_response(HTTP_NOT_IMPLEMENTED); + return; + } + + apply_batch(events, event_count, pace_ms); + emit_state_snapshot(resp); + xSemaphoreGive(mutex); + resp->json_response(HTTP_OK); + delete obj; + free(text); +#else + resp->error(INPUT_CAPABILITY_ERROR); + resp->json_response(HTTP_NOT_IMPLEMENTED); +#endif +} diff --git a/software/api/route_machine.cc b/software/api/route_machine.cc index 332d6377b..f73e07ca4 100644 --- a/software/api/route_machine.cc +++ b/software/api/route_machine.cc @@ -4,6 +4,10 @@ #include "subsys.h" #include "c64.h" #include "c64_subsys.h" +#if U64 +#include "keyboard_usb.h" +#include "joystick_output.h" +#endif #define MENU_C64_PAUSE 0x640B #define MENU_C64_RESUME 0x640C @@ -31,6 +35,12 @@ API_CALL(PUT, machine, reset, NULL, ARRAY( { })) { SubsysCommand *cmd = new SubsysCommand(NULL, SUBSYSID_C64, MENU_C64_RESET, 0); SubsysResultCode_t retval = cmd->execute(); + if (retval.status == SSRET_OK) { +#if U64 + system_usb_keyboard.restReleaseAll(); + JoystickOutput::instance().releaseAllRest(); +#endif + } resp->error(SubsysCommand::error_string(retval.status)); resp->json_response(SubsysCommand::http_response_map(retval.status)); } @@ -39,6 +49,12 @@ API_CALL(PUT, machine, reboot, NULL, ARRAY( { })) { SubsysCommand *cmd = new SubsysCommand(NULL, SUBSYSID_C64, MENU_C64_REBOOT, 0); SubsysResultCode_t retval = cmd->execute(); + if (retval.status == SSRET_OK) { +#if U64 + system_usb_keyboard.restReleaseAll(); + JoystickOutput::instance().releaseAllRest(); +#endif + } resp->error(SubsysCommand::error_string(retval.status)); resp->json_response(SubsysCommand::http_response_map(retval.status)); } diff --git a/software/api/routes.cc b/software/api/routes.cc index 01cf6007f..192d68de5 100644 --- a/software/api/routes.cc +++ b/software/api/routes.cc @@ -36,6 +36,10 @@ void writer_complete(TempfileWriter *writer, const void *context1, void *context TempfileWriter *attachment_writer(HTTPReqMessage *req, HTTPRespMessage *resp, const ApiCall_t *func, ArgsURI *args) { if (req->bodyType != eNoBody) { + if ((req->bodyType == eTotalSize) && (req->bodySize == 0)) { + req->bodyType = eNoBody; + return NULL; + } TempfileWriter *writer = new TempfileWriter(req, resp, writer_complete, func, args); setup_multipart(req, &TempfileWriter::collect_wrapper, writer); return writer; @@ -47,6 +51,10 @@ TempfileWriter *attachment_writer(HTTPReqMessage *req, HTTPRespMessage *resp, co REUWriter *attachment_reu(HTTPReqMessage *req, HTTPRespMessage *resp, const ApiCall_t *func, ArgsURI *args) { if (req->bodyType != eNoBody) { + if ((req->bodyType == eTotalSize) && (req->bodySize == 0)) { + req->bodyType = eNoBody; + return NULL; + } REUWriter *writer = new REUWriter(); writer->create_callback(req, resp, args, (const ApiCall_t *)func); setup_multipart(req, &REUWriter::collect_wrapper, writer); diff --git a/software/io/c64/joystick_output.cc b/software/io/c64/joystick_output.cc new file mode 100644 index 000000000..82ccdb59b --- /dev/null +++ b/software/io/c64/joystick_output.cc @@ -0,0 +1,197 @@ +#include "joystick_output.h" +#include + +#if U64 +#include "FreeRTOS.h" +#if !RECOVERYAPP +#include "timers.h" +#endif +#include "u64.h" + +#ifndef portENTER_CRITICAL +#define portENTER_CRITICAL() +#define portEXIT_CRITICAL() +#endif +#endif + +#if U64 && !RECOVERYAPP +static const uint32_t JOYSTICK_REST_TIMER_TICKS = (pdMS_TO_TICKS(20) > 0) ? pdMS_TO_TICKS(20) : 1; + +static void joystick_overlay_timer(TimerHandle_t timer) +{ + (void)timer; + JoystickOutput::instance().tickOverlays(); +} +#endif + +JoystickOutput :: JoystickOutput() +{ + usb_p1 = 0x1F; + rest_p1_persistent = 0x1F; + rest_p2_persistent = 0x1F; + rest_p1_overlay = 0x1F; + rest_p2_overlay = 0x1F; + memset(rest_p1_hold, 0, sizeof(rest_p1_hold)); + memset(rest_p2_hold, 0, sizeof(rest_p2_hold)); +#if U64 && !RECOVERYAPP + TimerHandle_t timer = xTimerCreate("RestJoy", JOYSTICK_REST_TIMER_TICKS, pdTRUE, NULL, joystick_overlay_timer); + if (timer) { + xTimerStart(timer, 0); + } +#endif +} + +JoystickOutput &JoystickOutput :: instance() +{ + static JoystickOutput output; + return output; +} + +bool JoystickOutput :: port2Supported(void) +{ + return true; +} + +void JoystickOutput :: apply(void) +{ +#if U64 + C64_JOY1_SWOUT = ((usb_p1 & rest_p1_persistent & rest_p1_overlay) & 0x1F) | 0xE0; + C64_JOY2_SWOUT = ((0x1F & rest_p2_persistent & rest_p2_overlay) & 0x1F) | 0xE0; +#endif +} + +void JoystickOutput :: setUsbPort1(uint8_t active_low_mask) +{ +#if U64 + portENTER_CRITICAL(); +#endif + usb_p1 = active_low_mask & 0x1F; + apply(); +#if U64 + portEXIT_CRITICAL(); +#endif +} + +void JoystickOutput :: setRestPort1Persistent(uint8_t active_low_mask) +{ +#if U64 + portENTER_CRITICAL(); +#endif + rest_p1_persistent = active_low_mask & 0x1F; + apply(); +#if U64 + portEXIT_CRITICAL(); +#endif +} + +void JoystickOutput :: setRestPort2Persistent(uint8_t active_low_mask) +{ +#if U64 + portENTER_CRITICAL(); +#endif + rest_p2_persistent = active_low_mask & 0x1F; + apply(); +#if U64 + portEXIT_CRITICAL(); +#endif +} + +void JoystickOutput :: restPersistentSnapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const +{ + port1_active_low = rest_p1_persistent & 0x1F; + port2_active_low = rest_p2_persistent & 0x1F; +} + +static void arm_overlay_bits(uint8_t &overlay, uint8_t hold_state[5], uint8_t active_low_mask, const uint8_t hold[5]) +{ + active_low_mask &= 0x1F; + for (int i = 0; i < 5; i++) { + uint8_t bit = (1 << i); + if (hold[i] != 0) { + hold_state[i] = hold[i]; + if (active_low_mask & bit) { + overlay |= bit; + } else { + overlay &= ~bit; + } + } + } +} + +void JoystickOutput :: armRestPort1Overlay(uint8_t active_low_mask, const uint8_t hold[5]) +{ +#if U64 + portENTER_CRITICAL(); +#endif + arm_overlay_bits(rest_p1_overlay, rest_p1_hold, active_low_mask, hold); + apply(); +#if U64 + portEXIT_CRITICAL(); +#endif +} + +void JoystickOutput :: armRestPort2Overlay(uint8_t active_low_mask, const uint8_t hold[5]) +{ +#if U64 + portENTER_CRITICAL(); +#endif + arm_overlay_bits(rest_p2_overlay, rest_p2_hold, active_low_mask, hold); + apply(); +#if U64 + portEXIT_CRITICAL(); +#endif +} + +static bool tick_overlay_bits(uint8_t &overlay, uint8_t hold_state[5]) +{ + bool changed = false; + for (int i = 0; i < 5; i++) { + if (hold_state[i] == 0) { + continue; + } + hold_state[i]--; + if (hold_state[i] == 0) { + overlay |= (1 << i); + changed = true; + } + } + return changed; +} + +void JoystickOutput :: tickOverlays(void) +{ +#if U64 + portENTER_CRITICAL(); +#endif + bool changed = tick_overlay_bits(rest_p1_overlay, rest_p1_hold); + changed |= tick_overlay_bits(rest_p2_overlay, rest_p2_hold); + if (changed) { + apply(); + } +#if U64 + portEXIT_CRITICAL(); +#endif +} + +void JoystickOutput :: releaseAllRest(void) +{ +#if U64 + portENTER_CRITICAL(); +#endif + rest_p1_persistent = 0x1F; + rest_p2_persistent = 0x1F; + rest_p1_overlay = 0x1F; + rest_p2_overlay = 0x1F; + memset(rest_p1_hold, 0, sizeof(rest_p1_hold)); + memset(rest_p2_hold, 0, sizeof(rest_p2_hold)); + apply(); +#if U64 + portEXIT_CRITICAL(); +#endif +} + +void JoystickOutput :: snapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const +{ + port1_active_low = (rest_p1_persistent & rest_p1_overlay) & 0x1F; + port2_active_low = (rest_p2_persistent & rest_p2_overlay) & 0x1F; +} diff --git a/software/io/c64/joystick_output.h b/software/io/c64/joystick_output.h new file mode 100644 index 000000000..7ef8f7996 --- /dev/null +++ b/software/io/c64/joystick_output.h @@ -0,0 +1,40 @@ +#ifndef JOYSTICK_OUTPUT_H +#define JOYSTICK_OUTPUT_H + +#include "integer.h" + +class JoystickOutput +{ + uint8_t usb_p1; + uint8_t rest_p1_persistent; + uint8_t rest_p2_persistent; + uint8_t rest_p1_overlay; + uint8_t rest_p2_overlay; + uint8_t rest_p1_hold[5]; + uint8_t rest_p2_hold[5]; + + JoystickOutput(); + void apply(void); + +public: + static JoystickOutput &instance(); + + void setUsbPort1(uint8_t active_low_mask); + + void setRestPort1Persistent(uint8_t active_low_mask); + void setRestPort2Persistent(uint8_t active_low_mask); + void restPersistentSnapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const; + + void armRestPort1Overlay(uint8_t active_low_mask, const uint8_t hold[5]); + void armRestPort2Overlay(uint8_t active_low_mask, const uint8_t hold[5]); + + void tickOverlays(void); + + void releaseAllRest(void); + + void snapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const; + + static bool port2Supported(void); +}; + +#endif /* JOYSTICK_OUTPUT_H */ diff --git a/software/io/usb/keyboard_usb.cc b/software/io/usb/keyboard_usb.cc index da2fd299e..b5b21586d 100644 --- a/software/io/usb/keyboard_usb.cc +++ b/software/io/usb/keyboard_usb.cc @@ -10,6 +10,10 @@ #include "task.h" #include +#if U64 && !RECOVERYAPP +#include "timers.h" +#endif + #ifndef portENTER_CRITICAL #define portENTER_CRITICAL() #define portEXIT_CRITICAL() @@ -31,6 +35,10 @@ uint8_t usb_matrix_lookup(const uint8_t *map, size_t map_size, uint8_t key) return (key < map_size) ? map[key] : 0xFF; } +#if U64 && !RECOVERYAPP +static const uint32_t REST_INPUT_TIMER_TICKS = (pdMS_TO_TICKS(20) > 0) ? pdMS_TO_TICKS(20) : 1; +#endif + } const uint8_t keymap_normal[] = { @@ -116,6 +124,20 @@ Keyboard_USB :: Keyboard_USB() matrixEnabled = false; memset(matrix_state, 0, sizeof(matrix_state)); memset(injected_matrix_state, 0, sizeof(injected_matrix_state)); + memset(rest_matrix_state, 0, sizeof(rest_matrix_state)); + memset(rest_matrix_overlay, 0, sizeof(rest_matrix_overlay)); + memset(rest_overlay_hold, 0, sizeof(rest_overlay_hold)); + usb_restore = 0; + usb_freeze = 0; + rest_restore = false; + rest_restore_overlay = 0; + rest_restore_hold = 0; +#if U64 && !RECOVERYAPP + rest_timer = xTimerCreate("RestInput", REST_INPUT_TIMER_TICKS, pdTRUE, this, Keyboard_USB :: S_rest_timer); + if (rest_timer) { + xTimerStart(rest_timer, 0); + } +#endif key_head = 0; key_tail = 0; injected_head = 0; @@ -134,7 +156,13 @@ Keyboard_USB :: Keyboard_USB() Keyboard_USB :: ~Keyboard_USB() { - +#if U64 && !RECOVERYAPP + if (rest_timer) { + xTimerStop(rest_timer, 0); + xTimerDelete(rest_timer, 0); + rest_timer = NULL; + } +#endif } void Keyboard_USB :: putch(uint8_t ch) @@ -156,8 +184,15 @@ void Keyboard_USB :: applyMatrixState(void) return; } for (int i = 0; i < 8; i++) { - matrix[i] = matrixEnabled ? (matrix_state[i] | injected_matrix_state[i]) : 0; + matrix[i] = matrixEnabled ? (matrix_state[i] | injected_matrix_state[i] | rest_matrix_state[i] | rest_matrix_overlay[i]) : 0; } + matrix[9] = matrixEnabled ? effectiveRestoreBit() : 0; + matrix[10] = matrixEnabled ? usb_freeze : 0; +} + +uint8_t Keyboard_USB :: effectiveRestoreBit(void) const +{ + return usb_restore | (rest_restore ? 1 : 0) | (rest_restore_overlay ? 1 : 0); } void Keyboard_USB :: clearInjectedMatrixState(void) @@ -269,8 +304,8 @@ void Keyboard_USB :: usb2matrix(uint8_t *kd) } } - matrix[9] = restore; - matrix[10] = freeze; + usb_restore = restore; + usb_freeze = freeze; if (!something_else_pressed) { if (modi & 0x02) { // left shift @@ -288,6 +323,16 @@ void Keyboard_USB :: usb2matrix(uint8_t *kd) applyMatrixState(); } +#if U64 && !RECOVERYAPP +void Keyboard_USB :: S_rest_timer(TimerHandle_t a) +{ + Keyboard_USB *keyboard = (Keyboard_USB *)pvTimerGetTimerID(a); + if (keyboard) { + keyboard->tickRestOverlays(); + } +} +#endif + // called from USB thread void Keyboard_USB :: process_data(uint8_t *kbdata) { @@ -495,6 +540,126 @@ void Keyboard_USB :: clear_buffer(void) clearInjectedMatrixState(); } +void Keyboard_USB :: restPress(uint8_t row, uint8_t col_bit) +{ + if ((row >= 8) || (col_bit >= 8)) { + return; + } + portENTER_CRITICAL(); + rest_matrix_state[row] |= (1 << col_bit); + applyMatrixState(); + portEXIT_CRITICAL(); +} + +void Keyboard_USB :: restRelease(uint8_t row, uint8_t col_bit) +{ + if ((row >= 8) || (col_bit >= 8)) { + return; + } + portENTER_CRITICAL(); + rest_matrix_state[row] &= ~(1 << col_bit); + applyMatrixState(); + portEXIT_CRITICAL(); +} + +void Keyboard_USB :: restTap(uint8_t row, uint8_t col_bit, uint8_t hold_ticks) +{ + if ((row >= 8) || (col_bit >= 8) || (hold_ticks == 0)) { + return; + } + portENTER_CRITICAL(); + rest_matrix_overlay[row] |= (1 << col_bit); + rest_overlay_hold[row][col_bit] = hold_ticks; + applyMatrixState(); + portEXIT_CRITICAL(); +} + +void Keyboard_USB :: restPressRestore(void) +{ + portENTER_CRITICAL(); + rest_restore = true; + applyMatrixState(); + portEXIT_CRITICAL(); +} + +void Keyboard_USB :: restReleaseRestore(void) +{ + portENTER_CRITICAL(); + rest_restore = false; + applyMatrixState(); + portEXIT_CRITICAL(); +} + +void Keyboard_USB :: restTapRestore(uint8_t hold_ticks) +{ + if (hold_ticks == 0) { + return; + } + portENTER_CRITICAL(); + rest_restore_overlay = 1; + rest_restore_hold = hold_ticks; + applyMatrixState(); + portEXIT_CRITICAL(); +} + +void Keyboard_USB :: restReleaseAll(void) +{ + portENTER_CRITICAL(); + memset(rest_matrix_state, 0, sizeof(rest_matrix_state)); + memset(rest_matrix_overlay, 0, sizeof(rest_matrix_overlay)); + memset(rest_overlay_hold, 0, sizeof(rest_overlay_hold)); + rest_restore = false; + rest_restore_overlay = 0; + rest_restore_hold = 0; + applyMatrixState(); + portEXIT_CRITICAL(); +} + +void Keyboard_USB :: restSnapshot(uint8_t out_matrix[8], bool &out_restore) const +{ + for (int i = 0; i < 8; i++) { + out_matrix[i] = rest_matrix_state[i] | rest_matrix_overlay[i]; + } + out_restore = rest_restore || (rest_restore_overlay != 0); +} + +void Keyboard_USB :: restPersistentSnapshot(uint8_t out_matrix[8], bool &out_restore) const +{ + for (int i = 0; i < 8; i++) { + out_matrix[i] = rest_matrix_state[i]; + } + out_restore = rest_restore; +} + +void Keyboard_USB :: tickRestOverlays(void) +{ + portENTER_CRITICAL(); + bool changed = false; + for (int row = 0; row < 8; row++) { + for (int col = 0; col < 8; col++) { + if (rest_overlay_hold[row][col] == 0) { + continue; + } + rest_overlay_hold[row][col]--; + if (rest_overlay_hold[row][col] == 0) { + rest_matrix_overlay[row] &= ~(1 << col); + changed = true; + } + } + } + if (rest_restore_hold > 0) { + rest_restore_hold--; + if (rest_restore_hold == 0) { + rest_restore_overlay = 0; + changed = true; + } + } + if (changed) { + applyMatrixState(); + } + portEXIT_CRITICAL(); +} + void Keyboard_USB :: setMatrix(volatile uint8_t *matrix) { if (this->matrix) { @@ -513,6 +678,7 @@ void Keyboard_USB :: setMatrix(volatile uint8_t *matrix) matrix[i] = 0x00; } } + applyMatrixState(); } void Keyboard_USB :: enableMatrix(bool enable) diff --git a/software/io/usb/keyboard_usb.h b/software/io/usb/keyboard_usb.h index ed9a528eb..9db95a774 100644 --- a/software/io/usb/keyboard_usb.h +++ b/software/io/usb/keyboard_usb.h @@ -11,6 +11,10 @@ #include "keyboard.h" #include "integer.h" +#if U64 && !RECOVERYAPP +typedef struct tmrTimerControl * TimerHandle_t; +#endif + static const int USB_KEY_BUFFER_SIZE = 64; #define USB_DATA_SIZE 8 @@ -22,9 +26,20 @@ class Keyboard_USB : public Keyboard bool matrixEnabled; uint8_t matrix_state[8]; uint8_t injected_matrix_state[8]; + uint8_t rest_matrix_state[8]; + uint8_t rest_matrix_overlay[8]; + uint8_t rest_overlay_hold[8][8]; uint8_t key_buffer[USB_KEY_BUFFER_SIZE]; uint8_t injected_buffer[USB_KEY_BUFFER_SIZE]; uint8_t last_data[USB_DATA_SIZE]; + uint8_t usb_restore; + uint8_t usb_freeze; + bool rest_restore; + uint8_t rest_restore_overlay; + uint8_t rest_restore_hold; +#if U64 && !RECOVERYAPP + TimerHandle_t rest_timer; +#endif int key_head; int key_tail; int injected_head; @@ -41,6 +56,10 @@ class Keyboard_USB : public Keyboard void applyMatrixState(void); void clearInjectedMatrixState(void); void setInjectedMatrixKey(int key); + uint8_t effectiveRestoreBit(void) const; +#if U64 && !RECOVERYAPP + static void S_rest_timer(TimerHandle_t a); +#endif public: Keyboard_USB(); ~Keyboard_USB(); @@ -58,6 +77,17 @@ class Keyboard_USB : public Keyboard void wait_free(void); void clear_buffer(void); + void restPress(uint8_t row, uint8_t col_bit); + void restRelease(uint8_t row, uint8_t col_bit); + void restTap(uint8_t row, uint8_t col_bit, uint8_t hold_ticks); + void restPressRestore(void); + void restReleaseRestore(void); + void restTapRestore(uint8_t hold_ticks); + void restReleaseAll(void); + void restSnapshot(uint8_t out_matrix[8], bool &out_restore) const; + void restPersistentSnapshot(uint8_t out_matrix[8], bool &out_restore) const; + void tickRestOverlays(void); + // attach / detach matrix peripheral to send keystrokes to. void setMatrix(volatile uint8_t *matrix); void enableMatrix(bool enable); diff --git a/software/io/usb/usb_hid.cc b/software/io/usb/usb_hid.cc index 57b9dd71e..93d4f775c 100644 --- a/software/io/usb/usb_hid.cc +++ b/software/io/usb/usb_hid.cc @@ -8,9 +8,12 @@ #include "usb_hid_selection.h" #include "profiler.h" #include "FreeRTOS.h" -#include "task.h" -#include "keyboard_usb.h" -#include "u64.h" +#include "task.h" +#include "keyboard_usb.h" +#include "u64.h" +#if U64 && !RECOVERYAPP +#include "joystick_output.h" +#endif // Standards-based HID support only. Devices must expose standard HID mouse or // keyboard collections; proprietary non-HID control protocols are intentionally @@ -49,9 +52,18 @@ static const uint8_t USB_HID_MOUSE_WHEEL_BURST_LIMIT = 16; static const int USB_HID_MENU_WHEEL_EXTRA_STEP_THRESHOLD = 15; static const int USB_HID_MENU_MAX_PENDING_VERTICAL_KEYS = 2; -int usb_hid_active_mouse_interfaces = 0; - -struct t_usb_hid_visibility +int usb_hid_active_mouse_interfaces = 0; + +void usb_hid_set_joy1_output(uint8_t active_low_mask) +{ +#if U64 && !RECOVERYAPP + JoystickOutput::instance().setUsbPort1(active_low_mask); +#else + C64_JOY1_SWOUT = active_low_mask; +#endif +} + +struct t_usb_hid_visibility { char name[33]; char mode[12]; @@ -286,12 +298,12 @@ void usb_hid_clear_visibility_if_source_matches(t_usb_hid_visibility& visibility void usb_hid_apply_mouse_output_enable() { #if U64 - C64_MOUSE_EN_1 = (usb_hid_active_mouse_interfaces > 0) ? 1 : 0; - if (usb_hid_active_mouse_interfaces == 0) { - C64_JOY1_SWOUT = 0x1F; - } -#endif -} + C64_MOUSE_EN_1 = (usb_hid_active_mouse_interfaces > 0) ? 1 : 0; + if (usb_hid_active_mouse_interfaces == 0) { + usb_hid_set_joy1_output(0x1F); + } +#endif +} } @@ -763,10 +775,10 @@ void UsbHidDriver :: service_native_wheel_timer(void) native_wheel_output_active = 0; portEXIT_CRITICAL(); - C64_JOY1_SWOUT = output_mouse_joy; - if (wheel_pulse_timer) { - xTimerStop(wheel_pulse_timer, 0); - } + usb_hid_set_joy1_output(output_mouse_joy); + if (wheel_pulse_timer) { + xTimerStop(wheel_pulse_timer, 0); + } return; } @@ -793,10 +805,10 @@ void UsbHidDriver :: service_native_wheel_timer(void) } portEXIT_CRITICAL(); - C64_JOY1_SWOUT = output_mouse_joy; - if (!wheel_pulse_timer) { - return; - } + usb_hid_set_joy1_output(output_mouse_joy); + if (!wheel_pulse_timer) { + return; + } if (active) { xTimerChangePeriod(wheel_pulse_timer, delay_ticks, 0); } else { @@ -1004,11 +1016,11 @@ void UsbHidDriver :: disable() native_wheel_base_joy, 0x1F); usb_hid_set_native_wheel_output_active(native_wheel_output_active, false); -#if U64 - if (usb_hid_active_mouse_interfaces == 0) { - C64_JOY1_SWOUT = 0x1F; - } -#endif +#if U64 + if (usb_hid_active_mouse_interfaces == 0) { + usb_hid_set_joy1_output(0x1F); + } +#endif previous_left_button_pressed = false; menu_left_button_consumed = false; menu_right_button_consumed = false; @@ -1094,10 +1106,10 @@ void UsbHidDriver :: poll(void) wheel_pulse_next_tick, wheel_pulse_burst_direction, wheel_pulse_burst_count); - C64_JOY1_SWOUT = output_mouse_joy; - } - usb_hid_set_native_wheel_output_active(native_wheel_output_active, false); - return; + usb_hid_set_joy1_output(output_mouse_joy); + } + usb_hid_set_native_wheel_output_active(native_wheel_output_active, false); + return; } if (had_native_wheel_input) { @@ -1380,16 +1392,16 @@ void UsbHidDriver :: interrupt_handler() output_mouse_joy = HidMouseInterpreter::applyWheelPulseMask(output_mouse_joy, HidMouseInterpreter::WHEEL_PULSE_PHASE_IDLE, 0); - usb_hid_publish_native_wheel_input(native_wheel_delta_queue, - sizeof(native_wheel_delta_queue) / sizeof(native_wheel_delta_queue[0]), - native_wheel_queue_head, - native_wheel_queue_tail, - native_wheel_base_joy, - 0, - output_mouse_joy); - if (!usb_hid_get_native_wheel_output_active(native_wheel_output_active)) { - C64_JOY1_SWOUT = output_mouse_joy; - } + usb_hid_publish_native_wheel_input(native_wheel_delta_queue, + sizeof(native_wheel_delta_queue) / sizeof(native_wheel_delta_queue[0]), + native_wheel_queue_head, + native_wheel_queue_tail, + native_wheel_base_joy, + 0, + output_mouse_joy); + if (!usb_hid_get_native_wheel_output_active(native_wheel_output_active)) { + usb_hid_set_joy1_output(output_mouse_joy); + } } else { output_mouse_joy = HidMouseInterpreter::applyWheelPulseMask(output_mouse_joy, HidMouseInterpreter::WHEEL_PULSE_PHASE_IDLE, @@ -1400,10 +1412,10 @@ void UsbHidDriver :: interrupt_handler() output_mouse_joy); } -#if U64 - if (!HidMouseInterpreter::mouseModeRoutesWheelToNative(mouse_mode)) { - C64_JOY1_SWOUT = output_mouse_joy; - } +#if U64 + if (!HidMouseInterpreter::mouseModeRoutesWheelToNative(mouse_mode)) { + usb_hid_set_joy1_output(output_mouse_joy); + } C64_PADDLE_1_X = mouse_x & 0x7F; C64_PADDLE_1_Y = mouse_y & 0x7F; #else diff --git a/target/u64/nios2/ultimate/Makefile b/target/u64/nios2/ultimate/Makefile index 1543dd5c8..8246622a1 100755 --- a/target/u64/nios2/ultimate/Makefile +++ b/target/u64/nios2/ultimate/Makefile @@ -66,6 +66,7 @@ SRCS_CC = u2p_init.cc \ u64_config.cc \ rtc_i2c.cc \ c64.cc \ + joystick_output.cc \ c64_crt.cc \ u64_machine.cc \ c64_subsys.cc \ @@ -177,6 +178,7 @@ SRCS_CC = u2p_init.cc \ route_runners.cc \ route_configs.cc \ route_machine.cc \ + route_input.cc \ route_streams.cc \ assembly_search.cc \ assembly.cc \ diff --git a/target/u64ii/riscv/ultimate/Makefile b/target/u64ii/riscv/ultimate/Makefile index d9bd85b4f..ea9e83ddb 100755 --- a/target/u64ii/riscv/ultimate/Makefile +++ b/target/u64ii/riscv/ultimate/Makefile @@ -69,6 +69,7 @@ SRCS_CC = u64ii_init.cc \ ramdisk.cc \ rtc_dummy.cc \ c64.cc \ + joystick_output.cc \ c64_crt.cc \ u64_machine.cc \ c64_subsys.cc \ @@ -174,6 +175,7 @@ SRCS_CC = u64ii_init.cc \ route_runners.cc \ route_configs.cc \ route_machine.cc \ + route_input.cc \ route_streams.cc \ assembly_search.cc \ assembly.cc \ diff --git a/tools/api/input_test.py b/tools/api/input_test.py new file mode 100755 index 000000000..c7ce5cf6a --- /dev/null +++ b/tools/api/input_test.py @@ -0,0 +1,767 @@ +#!/usr/bin/env python3 +import argparse +import http.client +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from contextlib import contextmanager +from typing import Any, Dict, List, Optional, Tuple + +CHECK_COUNT = 0 +READY_SCREEN_CODES = bytes((0x12, 0x05, 0x01, 0x04, 0x19, 0x2E)) +KEY_HOLD_SECONDS = 0.12 +KEY_GAP_SECONDS = 0.03 +JOYSTICK_PROBE_ADDR = 0xC000 +JOYSTICK_PROBE_SCREEN_ADDR = 0x0400 +JOYSTICK_PROBE_CODE = bytes( + ( + 0xAD, + 0x00, + 0xDC, + 0x8D, + 0x00, + 0x04, + 0xAD, + 0x01, + 0xDC, + 0x8D, + 0x01, + 0x04, + 0x4C, + 0x00, + 0xC0, + ) +) +KEYBOARD_MATRIX: Dict[str, Tuple[int, int]] = { + "inst_del": (0, 0), + "return": (0, 1), + "cursor_left_right": (0, 2), + "f7": (0, 3), + "f1": (0, 4), + "f3": (0, 5), + "f5": (0, 6), + "cursor_up_down": (0, 7), + "3": (1, 0), + "w": (1, 1), + "a": (1, 2), + "4": (1, 3), + "z": (1, 4), + "s": (1, 5), + "e": (1, 6), + "left_shift": (1, 7), + "5": (2, 0), + "r": (2, 1), + "d": (2, 2), + "6": (2, 3), + "c": (2, 4), + "f": (2, 5), + "t": (2, 6), + "x": (2, 7), + "7": (3, 0), + "y": (3, 1), + "g": (3, 2), + "8": (3, 3), + "b": (3, 4), + "h": (3, 5), + "u": (3, 6), + "v": (3, 7), + "9": (4, 0), + "i": (4, 1), + "j": (4, 2), + "0": (4, 3), + "m": (4, 4), + "k": (4, 5), + "o": (4, 6), + "n": (4, 7), + "plus": (5, 0), + "p": (5, 1), + "l": (5, 2), + "minus": (5, 3), + "period": (5, 4), + "colon": (5, 5), + "at": (5, 6), + "comma": (5, 7), + "pound": (6, 0), + "star": (6, 1), + "semicolon": (6, 2), + "clr_home": (6, 3), + "right_shift": (6, 4), + "equals": (6, 5), + "arrow_up": (6, 6), + "slash": (6, 7), + "1": (7, 0), + "arrow_left": (7, 1), + "ctrl": (7, 2), + "2": (7, 3), + "space": (7, 4), + "commodore": (7, 5), + "q": (7, 6), + "run_stop": (7, 7), +} + + +class Failure(RuntimeError): + pass + + +@contextmanager +def check(label: str): + global CHECK_COUNT + CHECK_COUNT += 1 + print(f"[{CHECK_COUNT:02d}] {label} ... ", end="", flush=True) + try: + yield + except Exception: + print("FAIL", flush=True) + raise + print("OK", flush=True) + + +def format_exception(exc: BaseException) -> str: + if isinstance(exc, urllib.error.URLError) and getattr(exc, "reason", None) is not None: + return f"{exc} ({exc.reason})" + return str(exc) + + +def device_unavailable(exc: BaseException) -> bool: + text = format_exception(exc).lower() + markers = ( + "no route to host", + "network is unreachable", + "connection refused", + "timed out", + "temporary failure in name resolution", + "not found", + ) + return any(marker in text for marker in markers) + + +class RestInputSession: + def __init__(self, host: str, password: Optional[str], timeout: float) -> None: + self.host = host + self.password = password + self.timeout = timeout + + def url(self, path: str, params: Optional[Dict[str, Any]] = None) -> str: + query = "" + if params: + query = "?" + urllib.parse.urlencode(params) + return f"http://{self.host}{path}{query}" + + def request( + self, + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + body: Optional[bytes] = None, + content_type: Optional[str] = "application/json", + ) -> bytes: + headers = {} + if self.password: + headers["X-Password"] = self.password + if body is not None and content_type is not None: + headers["Content-Type"] = content_type + request = urllib.request.Request(self.url(path, params), data=body, headers=headers, method=method) + with urllib.request.urlopen(request, timeout=self.timeout) as response: + return response.read() + + def json_request( + self, + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + body = None if payload is None else json.dumps(payload).encode("utf-8") + data = self.request(method, path, params=params, body=body) + return json.loads(data.decode("utf-8")) + + def get_state(self) -> Dict[str, Any]: + return self.json_request("GET", "/v1/machine:input") + + def post_events(self, events: List[Dict[str, Any]]) -> Dict[str, Any]: + return self.json_request("POST", "/v1/machine:input", payload={"events": events}) + + def post_payload_expect_error(self, payload: Dict[str, Any]) -> Dict[str, Any]: + try: + self.json_request("POST", "/v1/machine:input", payload=payload) + except urllib.error.HTTPError as exc: + if exc.code != 400: + raise + return json.loads(exc.read().decode("utf-8")) + raise Failure("Expected HTTP 400, but request succeeded") + + def post_events_expect_error(self, events: List[Dict[str, Any]]) -> Dict[str, Any]: + return self.post_payload_expect_error({"events": events}) + + def post_raw_expect_error(self, body: bytes, content_type: Optional[str]) -> Dict[str, Any]: + try: + self.request("POST", "/v1/machine:input", body=body, content_type=content_type) + except urllib.error.HTTPError as exc: + if exc.code != 400: + raise + return json.loads(exc.read().decode("utf-8")) + raise Failure("Expected HTTP 400, but request succeeded") + + def post_without_body_expect_error(self, expected_code: int = 412) -> Dict[str, Any]: + headers: Dict[str, str] = {"Content-Type": "application/json"} + if self.password: + headers["X-Password"] = self.password + connection = None + try: + connection = http.client.HTTPConnection(self.host, timeout=self.timeout) + connection.request("POST", "/v1/machine:input", body=b"", headers=headers) + response = connection.getresponse() + body = response.read() + if response.status != expected_code: + raise Failure(f"Expected HTTP {expected_code}, got HTTP {response.status}") + return json.loads(body.decode("utf-8")) + except http.client.HTTPException as exc: + raise Failure(f"HTTP client failure: {exc}") from exc + finally: + try: + if connection is not None: + connection.close() + except Exception: + pass + + def put(self, command: str) -> None: + self.request("PUT", f"/v1/machine:{command}") + + def reset(self) -> None: + self.put("reset") + + def pause(self) -> None: + self.put("pause") + + def resume(self) -> None: + self.put("resume") + + def read_memory(self, address: int, length: int) -> bytes: + return self.request("GET", "/v1/machine:readmem", params={"address": f"{address:04X}", "length": length}) + + def write_memory(self, address: int, data: bytes) -> None: + if not data: + raise Failure("write_memory requires at least one byte") + self.request("PUT", "/v1/machine:writemem", params={"address": f"{address:04X}", "data": data.hex().upper()}) + + +def wait_for_input_ready(session: RestInputSession, timeout: float) -> None: + deadline = time.time() + timeout + last_error: Optional[BaseException] = None + while time.time() < deadline: + try: + session.get_state() + return + except (OSError, TimeoutError, urllib.error.URLError) as exc: + last_error = exc + time.sleep(0.5) + if last_error is not None: + raise last_error + raise TimeoutError(f"Timed out waiting for /v1/machine:input on {session.host}") + + +def wait_for_basic_ready(session: RestInputSession) -> None: + deadline = time.time() + 6.0 + while time.time() < deadline: + screen = session.read_memory(0x0400, 256) + if READY_SCREEN_CODES in screen: + return + time.sleep(0.25) + raise Failure("BASIC READY prompt not visible; device may be running a cartridge") + + +def screen_code(text: str) -> bytes: + result = bytearray() + for ch in text: + if "A" <= ch <= "Z": + result.append(ord(ch) - 64) + else: + result.append(ord(ch)) + return bytes(result) + + +def cursor_address(session: RestInputSession) -> int: + pointer = session.read_memory(0x00D1, 3) + return pointer[0] + (pointer[1] << 8) + pointer[2] + + +def reset_to_basic(session: RestInputSession) -> None: + session.reset() + wait_for_basic_ready(session) + session.post_events([{"kind": "release_all"}]) + + +def text_to_key_inputs(text: str) -> List[List[str]]: + result: List[List[str]] = [] + for ch in text: + if "a" <= ch <= "z": + result.append([ch]) + elif "A" <= ch <= "Z": + result.append([ch.lower()]) + elif "0" <= ch <= "9": + result.append([ch]) + elif ch == " ": + result.append(["space"]) + elif ch == ",": + result.append(["comma"]) + elif ch == ":": + result.append(["colon"]) + elif ch == "(": + result.append(["left_shift", "8"]) + elif ch == ")": + result.append(["left_shift", "9"]) + elif ch in ("\r", "\n"): + result.append(["return"]) + else: + raise Failure(f"No C64 key mapping for {ch!r}") + return result + + +def send_c64_keys(session: RestInputSession, sequence: List[List[str]]) -> None: + for inputs in sequence: + session.post_events([{"kind": "keyboard", "inputs": inputs, "transition": "press"}]) + time.sleep(KEY_HOLD_SECONDS) + session.post_events([{"kind": "keyboard", "inputs": inputs, "transition": "release"}]) + time.sleep(KEY_GAP_SECONDS) + + +def type_c64_text(session: RestInputSession, text: str) -> None: + send_c64_keys(session, text_to_key_inputs(text)) + + +def basic_input_line_start(screen: bytes) -> int: + ready = screen.find(READY_SCREEN_CODES) + if ready < 0: + raise Failure("BASIC READY prompt not visible.") + return ((ready // 40) + 1) * 40 + + +def wait_for_screen_bytes(session: RestInputSession, address: int, expected: bytes, timeout: float = 5.0) -> None: + deadline = time.time() + timeout + actual = b"" + while time.time() < deadline: + actual = session.read_memory(address, len(expected)) + if actual == expected: + return + time.sleep(0.1) + raise Failure(f"Expected {expected.hex().upper()} at ${address:04X}, got {actual.hex().upper()}") + + +def assert_c64_typed_text(session: RestInputSession, text: str) -> None: + screen = session.read_memory(0x0400, 1000) + address = 0x0400 + basic_input_line_start(screen) + type_c64_text(session, text) + wait_for_screen_bytes(session, address, screen_code(text.upper())) + + +def start_joystick_probe(session: RestInputSession) -> None: + reset_to_basic(session) + session.write_memory(JOYSTICK_PROBE_ADDR, JOYSTICK_PROBE_CODE) + type_c64_text(session, "SYS49152\n") + wait_for_screen_bytes(session, JOYSTICK_PROBE_SCREEN_ADDR, b"\xFF\xFF") + + +def assert_c64_joystick_probe(session: RestInputSession, port1: int, port2: int) -> None: + wait_for_screen_bytes(session, JOYSTICK_PROBE_SCREEN_ADDR, bytes(((port2 & 0x1F) | 0xE0, (port1 & 0x1F) | 0xE0))) + + +def assert_joystick_ports(session: RestInputSession, port1: int, port2: int) -> None: + actual_port1, actual_port2 = read_joystick_cia(session) + if (actual_port1 & 0x1F) != port1 or (actual_port2 & 0x1F) != port2: + raise Failure( + f"Expected joy1=${port1:02X} joy2=${port2:02X}, " + f"got joy1=${actual_port1 & 0x1F:02X} joy2=${actual_port2 & 0x1F:02X}" + ) + + +def assert_state_empty(session: RestInputSession) -> None: + state = session.get_state() + if state.get("keyboard", {}).get("inputs") != []: + raise Failure(f"Expected empty keyboard state, got {state}") + if state.get("joysticks") != [{"port": 1, "inputs": []}, {"port": 2, "inputs": []}]: + raise Failure(f"Expected empty joystick state, got {state}") + + +def assert_error_body_only(body: Dict[str, Any]) -> None: + if not body.get("errors"): + raise Failure(f"Expected validation errors, got {body}") + if "keyboard" in body or "joysticks" in body: + raise Failure(f"Error response must not include state snapshots, got {body}") + + +def assert_input_state( + session: RestInputSession, + keyboard: List[str], + joystick1: List[str], + joystick2: List[str], +) -> None: + state = session.get_state() + if state.get("errors") != []: + raise Failure(f"Expected no errors in state snapshot, got {state}") + if state.get("keyboard", {}).get("inputs") != keyboard: + raise Failure(f"Keyboard state mismatch; expected {keyboard}, got {state}") + if state.get("joysticks") != [{"port": 1, "inputs": joystick1}, {"port": 2, "inputs": joystick2}]: + raise Failure(f"Joystick state mismatch; expected {joystick1}/{joystick2}, got {state}") + + +def read_joystick_cia(session: RestInputSession) -> Tuple[int, int]: + session.pause() + try: + session.write_memory(0xDC02, b"\x00") + session.write_memory(0xDC03, b"\x00") + regs = session.read_memory(0xDC00, 2) + port_a = regs[0] + port_b = regs[1] + finally: + session.resume() + return port_a, port_b + + +def read_keyboard_row(session: RestInputSession, row: int) -> int: + session.pause() + try: + session.write_memory(0xDC02, b"\xFF") + session.write_memory(0xDC03, b"\x00") + session.write_memory(0xDC00, bytes([(~(1 << row)) & 0xFF])) + regs = session.read_memory(0xDC00, 2) + return regs[1] + finally: + session.resume() + + +def assert_keyboard_matrix(session: RestInputSession, input_name: str, active: bool) -> None: + mapping = KEYBOARD_MATRIX.get(input_name) + if mapping is None: + raise Failure(f"No keyboard matrix mapping for {input_name!r}") + row, bit = mapping + row_value = read_keyboard_row(session, row) + pressed = (row_value & (1 << bit)) == 0 + if pressed != active: + state = "pressed" if active else "released" + raise Failure( + f"Expected {input_name} to be {state} on row {row}, bit {bit}; " + f"read ${row_value:02X}" + ) + + +def assert_keyboard_matrix_inputs(session: RestInputSession, inputs: List[str]) -> None: + for input_name in inputs: + assert_keyboard_matrix(session, input_name, True) + + +def run_contract_tests(session: RestInputSession) -> None: + with check("input snapshot has stable empty response shape"): + session.post_events([{"kind": "release_all"}]) + assert_input_state(session, [], [], []) + + with check("POST accepts 64 event batch"): + session.post_events([{"kind": "release_all"}] * 64) + assert_state_empty(session) + + with check("bad content-type is rejected without mutation"): + session.post_events([{"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}]) + body = session.post_raw_expect_error( + b'{"events":[{"kind":"release_all"}]}', + "text/plain", + ) + assert_error_body_only(body) + assert_input_state(session, ["ctrl"], [], []) + session.post_events([{"kind": "release_all"}]) + + with check("missing JSON body is rejected without mutation"): + session.post_events([{"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}]) + body = session.post_without_body_expect_error() + assert_error_body_only(body) + assert_input_state(session, ["ctrl"], [], []) + session.post_events([{"kind": "release_all"}]) + + with check("malformed JSON is rejected without mutation"): + session.post_events([{"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}]) + body = session.post_raw_expect_error(b'{"events":[', "application/json") + assert_error_body_only(body) + assert_input_state(session, [], ["fire"], []) + session.post_events([{"kind": "release_all"}]) + + with check("unknown root field is rejected without mutation"): + session.post_events([{"kind": "keyboard", "inputs": ["commodore"], "transition": "press"}]) + body = session.post_payload_expect_error({"events": [{"kind": "release_all"}], "extra": True}) + assert_error_body_only(body) + assert_input_state(session, ["commodore"], [], []) + session.post_events([{"kind": "release_all"}]) + + with check("late invalid event keeps whole batch atomic"): + session.post_events( + [ + {"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, + ] + ) + body = session.post_events_expect_error( + [ + {"kind": "release_all"}, + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "joystick", "port": 3, "inputs": ["up"], "transition": "press"}, + ] + ) + assert_error_body_only(body) + assert_input_state(session, ["ctrl"], ["fire"], []) + session.post_events([{"kind": "release_all"}]) + + +def run_keyboard_tests(session: RestInputSession) -> None: + with check("keyboard single letter reaches the live C64 matrix"): + reset_to_basic(session) + session.post_events([{"kind": "keyboard", "inputs": ["l"], "transition": "press"}]) + assert_keyboard_matrix_inputs(session, ["l"]) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + with check("keyboard shifted pair reaches the live C64 matrix"): + reset_to_basic(session) + session.post_events([{"kind": "keyboard", "inputs": ["left_shift", "a"], "transition": "press"}]) + assert_keyboard_matrix_inputs(session, ["left_shift", "a"]) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + with check("paced keyboard tap batch types consecutive characters"): + reset_to_basic(session) + screen = session.read_memory(0x0400, 1000) + address = 0x0400 + basic_input_line_start(screen) + session.json_request( + "POST", + "/v1/machine:input", + payload={ + "events": [ + {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["b"], "transition": "tap"}, + ], + "pace_ms": 25, + }, + ) + wait_for_screen_bytes(session, address, screen_code("AB")) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + with check("keyboard ordered batch and idempotent release"): + reset_to_basic(session) + session.post_events( + [ + {"kind": "keyboard", "inputs": ["left_shift", "ctrl"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["space"], "transition": "release"}, + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "release"}, + ] + ) + assert_input_state(session, ["ctrl"], [], []) + session.post_events([{"kind": "release_all"}]) + + with check("keyboard release_all can be followed by press in same batch"): + reset_to_basic(session) + session.post_events( + [ + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "release_all"}, + {"kind": "keyboard", "inputs": ["commodore"], "transition": "press"}, + ] + ) + assert_input_state(session, ["commodore"], [], []) + session.post_events([{"kind": "release_all"}]) + + with check("keyboard accepts eight simultaneous inputs"): + reset_to_basic(session) + inputs = ["a", "s", "d", "f", "j", "k", "l", "space"] + session.post_events([{"kind": "keyboard", "inputs": inputs, "transition": "press"}]) + assert_input_state(session, ["a", "s", "d", "f", "j", "k", "l", "space"], [], []) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + with check("keyboard tap does not release persistent key"): + reset_to_basic(session) + session.post_events( + [ + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["left_shift", "a"], "transition": "tap"}, + ] + ) + time.sleep(0.1) + inputs = session.get_state()["keyboard"]["inputs"] + if "left_shift" not in inputs or "a" in inputs: + raise Failure(f"Persistent/tap state mismatch: {inputs}") + session.post_events([{"kind": "release_all"}]) + + with check("keyboard release_all clears state"): + reset_to_basic(session) + session.post_events([{"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}]) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + with check("keyboard restore tap auto releases"): + reset_to_basic(session) + session.post_events([{"kind": "keyboard", "inputs": ["restore"], "transition": "tap"}]) + time.sleep(0.2) + assert_state_empty(session) + + with check("invalid keyboard batch does not mutate state"): + reset_to_basic(session) + body = session.post_events_expect_error( + [ + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["escape"], "transition": "tap"}, + ] + ) + assert_error_body_only(body) + assert_state_empty(session) + + +def run_joystick_tests(session: RestInputSession) -> None: + reset_to_basic(session) + + with check("joystick port 1 up press is visible on CIA reads"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 1, "inputs": ["up"], "transition": "press"}]) + assert_joystick_ports(session, 0x1E, 0x1F) + state = session.get_state()["joysticks"] + if state[0]["inputs"] != ["up"] or state[1]["inputs"] != []: + raise Failure(f"Joystick state mismatch: {state}") + + with check("joystick port 1 all inputs and idempotent release are visible on CIA reads"): + session.post_events([{"kind": "release_all"}]) + session.post_events( + [ + {"kind": "joystick", "port": 1, "inputs": ["up", "down", "left", "right", "fire"], "transition": "press"}, + {"kind": "joystick", "port": 1, "inputs": ["right"], "transition": "release"}, + {"kind": "joystick", "port": 1, "inputs": ["right"], "transition": "release"}, + ] + ) + assert_joystick_ports(session, 0x08, 0x1F) + assert_input_state(session, [], ["up", "down", "left", "fire"], []) + + with check("joystick port 2 diagonal and fire are visible on CIA reads"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["up", "right", "fire"], "transition": "press"}]) + assert_joystick_ports(session, 0x1F, 0x06) + inputs = session.get_state()["joysticks"][1]["inputs"] + if inputs != ["up", "right", "fire"]: + raise Failure(f"Joystick port 2 state mismatch: {inputs}") + + with check("joystick partial release is visible on CIA reads"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["up", "fire"], "transition": "press"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "release"}]) + assert_joystick_ports(session, 0x1F, 0x1E) + + with check("joystick release_all then press in same batch is visible on CIA reads"): + session.post_events( + [ + {"kind": "joystick", "port": 1, "inputs": ["up", "fire"], "transition": "press"}, + {"kind": "joystick", "port": 2, "inputs": ["down"], "transition": "press"}, + {"kind": "release_all"}, + {"kind": "joystick", "port": 2, "inputs": ["left"], "transition": "press"}, + ] + ) + assert_joystick_ports(session, 0x1F, 0x1B) + assert_input_state(session, [], [], ["left"]) + + with check("joystick unusual combination is visible on CIA reads"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 1, "inputs": ["up", "down"], "transition": "press"}]) + assert_joystick_ports(session, 0x1C, 0x1F) + + with check("joystick tap does not release persistent input"): + session.post_events([{"kind": "release_all"}]) + response = session.post_events( + [ + {"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "press"}, + {"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "tap"}, + ] + ) + if response["joysticks"][1]["inputs"] != ["up", "fire"]: + raise Failure(f"Expected immediate persistent/tap state, got {response}") + time.sleep(0.2) + assert_joystick_ports(session, 0x1F, 0x1E) + assert_input_state(session, [], [], ["up"]) + + with check("joystick tap auto releases"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["left"], "transition": "tap"}]) + time.sleep(0.2) + assert_joystick_ports(session, 0x1F, 0x1F) + if session.get_state()["joysticks"][1]["inputs"] != []: + raise Failure(f"Expected port 2 state empty, got {session.get_state()}") + + with check("invalid joystick batch does not mutate state"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "press"}]) + body = session.post_events_expect_error( + [ + {"kind": "release_all"}, + {"kind": "joystick", "port": 1, "inputs": ["jump"], "transition": "press"}, + ] + ) + assert_error_body_only(body) + assert_input_state(session, [], [], ["fire"]) + assert_joystick_ports(session, 0x1F, 0x0F) + session.post_events([{"kind": "release_all"}]) + + with check("joystick release_all clears both ports"): + session.post_events( + [ + {"kind": "joystick", "port": 1, "inputs": ["up"], "transition": "press"}, + {"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "press"}, + {"kind": "release_all"}, + ] + ) + assert_joystick_ports(session, 0x1F, 0x1F) + assert_state_empty(session) + + with check("machine reset clears keyboard and joystick REST state"): + session.post_events( + [ + {"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}, + {"kind": "joystick", "port": 1, "inputs": ["up"], "transition": "press"}, + ] + ) + reset_to_basic(session) + assert_state_empty(session) + + +def run_tests(session: RestInputSession) -> None: + wait_for_input_ready(session, timeout=15.0) + run_contract_tests(session) + run_keyboard_tests(session) + run_joystick_tests(session) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate U64 keyboard and joystick REST input injection") + parser.add_argument("--host", default=os.environ.get("U64_INPUT_HOST", "u64")) + parser.add_argument("--rest-host", default=os.environ.get("U64_INPUT_REST_HOST")) + parser.add_argument("--password", default=os.environ.get("U64_INPUT_PASSWORD", os.environ.get("C64U_PASSWORD"))) + parser.add_argument("--timeout", type=float, default=float(os.environ.get("U64_INPUT_TIMEOUT", "5.0"))) + args = parser.parse_args() + + rest_host = args.rest_host or args.host + session = RestInputSession(rest_host, args.password, args.timeout) + try: + run_tests(session) + except Failure as exc: + print(exc, file=sys.stderr) + return 1 + except (OSError, TimeoutError, urllib.error.URLError) as exc: + if device_unavailable(exc): + print(f"Connection failure: {format_exception(exc)}", file=sys.stderr) + else: + print(f"REST failure: {format_exception(exc)}", file=sys.stderr) + return 1 + + print(f"input_test: OK ({CHECK_COUNT} checks)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/api/input_tool.py b/tools/api/input_tool.py new file mode 100755 index 000000000..d8e5e6605 --- /dev/null +++ b/tools/api/input_tool.py @@ -0,0 +1,606 @@ +#!/usr/bin/env python3 +import argparse +from collections import deque +import glob +import os +import select +import struct +import sys +import termios +import time +import tty +import urllib.error +from contextlib import contextmanager +from typing import Callable, Dict, List, Optional, Set, Tuple + +from input_test import ( + Failure, + RestInputSession, + assert_joystick_ports, + assert_keyboard_matrix_inputs, + device_unavailable, + format_exception, + reset_to_basic, + wait_for_input_ready, +) + +KEY_MAP: Dict[str, str] = { + " ": "space", + "\r": "return", + "\n": "return", + "\t": "ctrl", + "\x7f": "inst_del", + "\b": "inst_del", + ":": "colon", + ";": "semicolon", + ",": "comma", + ".": "period", + "/": "slash", + "+": "plus", + "-": "minus", + "=": "equals", + "@": "at", + "*": "star", + "^": "arrow_up", + "_": "arrow_left", + "£": "pound", +} + +ARROW_KEYS = { + "A": "up", + "B": "down", + "C": "right", + "D": "left", +} +MAX_BATCH_EVENTS = 64 +BATCH_IDLE_SECONDS = float(os.environ.get("U64_INPUT_BATCH_IDLE", "0.02")) +BATCH_PACE_MS = int(os.environ.get("U64_INPUT_PACE_MS", "20")) +GAMEPAD_AXIS_THRESHOLD = int(os.environ.get("U64_INPUT_GAMEPAD_AXIS_THRESHOLD", "12000")) +GAMEPAD_FIRE_REPEAT_HZ = float(os.environ.get("U64_INPUT_GAMEPAD_FIRE_REPEAT_HZ", "10.0")) +QUIT_SEQUENCES = ("\x03", "\x04") +JOYSTICK_INPUT_ORDER = ["up", "down", "left", "right", "fire"] + +INPUT_EVENT_STRUCT = struct.Struct("llHHi") +EV_KEY = 0x01 +EV_ABS = 0x03 +ABS_X = 0x00 +ABS_Y = 0x01 +ABS_RX = 0x03 +ABS_RY = 0x04 +ABS_HAT0X = 0x10 +ABS_HAT0Y = 0x11 +BTN_SOUTH = 0x130 +BTN_EAST = 0x131 +GAMEPAD_NAME_KEYWORDS = ("xbox", "x-box", "controller", "gamepad", "joypad", "pad") + + +@contextmanager +def raw_terminal(): + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + yield + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +def read_key_sequence(timeout: float = 0.05) -> str: + ch = sys.stdin.read(1) + if ch != "\x1b": + return ch + seq = ch + while select.select([sys.stdin], [], [], timeout)[0]: + seq += sys.stdin.read(1) + if seq.endswith("~") or (len(seq) >= 3 and seq[-1].isalpha()): + break + return seq + + +def read_ready_key_sequence(timeout: float = BATCH_IDLE_SECONDS) -> Optional[str]: + if not select.select([sys.stdin], [], [], timeout)[0]: + return None + return read_key_sequence() + + +def keyboard_event(inputs: List[str]) -> Dict[str, object]: + return {"kind": "keyboard", "inputs": inputs, "transition": "tap"} + + +def joystick_event(port: int, inputs: List[str]) -> Dict[str, object]: + return {"kind": "joystick", "port": port, "inputs": inputs, "transition": "tap"} + + +def release_all_event() -> Dict[str, str]: + return {"kind": "release_all"} + + +class GamepadState: + def __init__( + self, + port: int, + axis_threshold: int = GAMEPAD_AXIS_THRESHOLD, + fire_repeat_hz: float = GAMEPAD_FIRE_REPEAT_HZ, + ) -> None: + self.port = port + self.axis_threshold = axis_threshold + self.fire_repeat_interval = 1.0 / fire_repeat_hz if fire_repeat_hz > 0 else 0.1 + self.left_x = 0 + self.left_y = 0 + self.right_x = 0 + self.right_y = 0 + self.hat_x = 0 + self.hat_y = 0 + self.a_pressed = False + self.b_pressed = False + self.repeat_fire_at: Optional[float] = None + self.logical_inputs: Set[str] = set() + + def _ordered(self, inputs: Set[str]) -> List[str]: + return [name for name in JOYSTICK_INPUT_ORDER if name in inputs] + + def _axis_inputs(self, x: int, y: int, threshold: int) -> Set[str]: + inputs: Set[str] = set() + if x <= -threshold: + inputs.add("left") + elif x >= threshold: + inputs.add("right") + if y <= -threshold: + inputs.add("up") + elif y >= threshold: + inputs.add("down") + return inputs + + def _recompute_logical_inputs(self) -> List[Dict[str, object]]: + new_inputs = ( + self._axis_inputs(self.left_x, self.left_y, self.axis_threshold) + | self._axis_inputs(self.right_x, self.right_y, self.axis_threshold) + | self._axis_inputs(self.hat_x, self.hat_y, 1) + ) + if self.a_pressed: + new_inputs.add("fire") + + released = self.logical_inputs - new_inputs + pressed = new_inputs - self.logical_inputs + self.logical_inputs = new_inputs + + events: List[Dict[str, object]] = [] + if released: + events.append( + { + "kind": "joystick", + "port": self.port, + "inputs": self._ordered(released), + "transition": "release", + } + ) + if pressed: + events.append( + { + "kind": "joystick", + "port": self.port, + "inputs": self._ordered(pressed), + "transition": "press", + } + ) + return events + + def apply_input_event(self, event_type: int, code: int, value: int, now: float) -> List[Dict[str, object]]: + if event_type == EV_ABS: + if code == ABS_X: + self.left_x = value + elif code == ABS_Y: + self.left_y = value + elif code == ABS_RX: + self.right_x = value + elif code == ABS_RY: + self.right_y = value + elif code == ABS_HAT0X: + self.hat_x = value + elif code == ABS_HAT0Y: + self.hat_y = value + else: + return [] + return self._recompute_logical_inputs() + + if event_type != EV_KEY: + return [] + + if code == BTN_SOUTH: + self.a_pressed = value != 0 + return self._recompute_logical_inputs() + + if code == BTN_EAST: + pressed = value != 0 + if pressed and not self.b_pressed: + self.b_pressed = True + self.repeat_fire_at = now + self.fire_repeat_interval + return [joystick_event(self.port, ["fire"])] + if not pressed: + self.b_pressed = False + self.repeat_fire_at = None + return [] + + return [] + + def poll_repeat_fire(self, now: float) -> Optional[Dict[str, object]]: + if not self.b_pressed or self.repeat_fire_at is None or now < self.repeat_fire_at: + return None + while self.repeat_fire_at is not None and now >= self.repeat_fire_at: + self.repeat_fire_at += self.fire_repeat_interval + return joystick_event(self.port, ["fire"]) + + def repeat_timeout(self, now: float) -> Optional[float]: + if not self.b_pressed or self.repeat_fire_at is None: + return None + return max(0.0, self.repeat_fire_at - now) + + +class GamepadDevice: + def __init__(self, path: str, port: int) -> None: + self.path = path + self.fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) + self.state = GamepadState(port) + + def fileno(self) -> int: + return self.fd + + def close(self) -> None: + os.close(self.fd) + + def read_events(self, now: float) -> List[Dict[str, object]]: + events: List[Dict[str, object]] = [] + while True: + try: + data = os.read(self.fd, INPUT_EVENT_STRUCT.size * 32) + except BlockingIOError: + break + if not data: + break + limit = len(data) - (len(data) % INPUT_EVENT_STRUCT.size) + for offset in range(0, limit, INPUT_EVENT_STRUCT.size): + _, _, event_type, code, value = INPUT_EVENT_STRUCT.unpack_from(data, offset) + events.extend(self.state.apply_input_event(event_type, code, value, now)) + if len(data) < INPUT_EVENT_STRUCT.size * 32: + break + return events + + +class BufferedSequenceReader: + def __init__( + self, + read_sequence: Callable[[], str], + read_ready_sequence: Callable[[], Optional[str]], + ) -> None: + self.read_sequence = read_sequence + self.read_ready_sequence = read_ready_sequence + self.buffer: deque[str] = deque() + + def get(self) -> str: + if self.buffer: + return self.buffer.popleft() + return self.read_sequence() + + def get_ready(self) -> Optional[str]: + if self.buffer: + return self.buffer.popleft() + return self.read_ready_sequence() + + def push(self, seq: str) -> None: + self.buffer.appendleft(seq) + + +def translate_sequence(seq: str, joystick_port: int) -> Optional[Dict[str, object]]: + if seq == "\x1b": + return release_all_event() + if seq == "\n": + return joystick_event(joystick_port, ["fire"]) + if seq.startswith("\x1b["): + if seq in ("\x1b[13;5u", "\x1b[27;5;13~"): + return joystick_event(joystick_port, ["fire"]) + if seq[-1:] in ARROW_KEYS and (";5" in seq or seq.startswith("\x1b[5")): + return joystick_event(joystick_port, [ARROW_KEYS[seq[-1]]]) + return None + if len(seq) != 1: + return None + if "a" <= seq <= "z" or "0" <= seq <= "9": + return keyboard_event([seq]) + if "A" <= seq <= "Z": + return keyboard_event(["left_shift", seq.lower()]) + mapped = KEY_MAP.get(seq) + if mapped: + return keyboard_event([mapped]) + return None + + +def input_event_name(path: str) -> str: + event_name = os.path.basename(os.path.realpath(path)) + if not event_name.startswith("event"): + return "" + sysfs_name = f"/sys/class/input/{event_name}/device/name" + try: + with open(sysfs_name, "r", encoding="utf-8") as fh: + return fh.read().strip() + except OSError: + return "" + + +def gamepad_device_rank(path: str) -> Tuple[int, str, str]: + name = input_event_name(path).lower() + basename = os.path.basename(path).lower() + score = 0 + if name: + score += 10 + if any(keyword in name for keyword in GAMEPAD_NAME_KEYWORDS): + score += 100 + if "system control" in name: + score -= 100 + if "keyboard" in name: + score -= 100 + if "mouse" in name: + score -= 100 + if any(keyword in basename for keyword in ("xbox", "controller", "gamepad", "joypad")): + score += 20 + return (-score, name, path) + + +def find_default_gamepad_device() -> Optional[str]: + candidates = sorted(glob.glob("/dev/input/by-id/*-event-joystick")) + if candidates: + return sorted(candidates, key=gamepad_device_rank)[0] + fallback = sorted(glob.glob("/dev/input/event*")) + filtered = [path for path in fallback if any(keyword in input_event_name(path).lower() for keyword in GAMEPAD_NAME_KEYWORDS)] + if filtered: + return sorted(filtered, key=gamepad_device_rank)[0] + return None + + +def open_gamepad(device_path: Optional[str], joystick_port: int) -> Optional[GamepadDevice]: + path = device_path or find_default_gamepad_device() + if not path: + return None + try: + return GamepadDevice(path, joystick_port) + except OSError: + return None + + +def print_mapping_overview(host: str, joystick_port: int, gamepad: Optional[GamepadDevice]) -> None: + print(f"REST input tool -> {host}") + print("keys: text=C64 keys; Ctrl+arrows=joy; Ctrl+Enter/Ctrl+J=fire; Esc=release_all; Ctrl+C/Ctrl+D=quit") + print(f"paced batch mode: {BATCH_PACE_MS} ms between queued events") + print(f"joystick port: {joystick_port}") + if gamepad: + print(f"gamepad: {gamepad.path} (left stick, right stick, d-pad -> movement; A=fire; B=repeat fire)") + else: + print("gamepad: not detected") + + +def post_interactive_events(session: RestInputSession, events: List[Dict[str, object]]) -> None: + payload: Dict[str, object] = {"events": events} + if len(events) > 1 and BATCH_PACE_MS > 0: + payload["pace_ms"] = BATCH_PACE_MS + session.json_request("POST", "/v1/machine:input", payload=payload) + + +def classify_sequence(seq: str, joystick_port: int) -> Tuple[str, Optional[Dict[str, object]]]: + if seq in QUIT_SEQUENCES: + return "quit", None + event = translate_sequence(seq, joystick_port) + if not event: + return "ignore", None + if event.get("kind") == "release_all": + return "release_all", event + return "event", event + + +def collect_event_batch( + first_event: Dict[str, object], + joystick_port: int, + reader: BufferedSequenceReader, +) -> Tuple[List[Dict[str, object]], Optional[str]]: + events = [first_event] + first_signature = ( + first_event.get("kind"), + tuple(first_event.get("inputs", [])), # type: ignore[arg-type] + first_event.get("transition"), + ) + while len(events) < MAX_BATCH_EVENTS: + seq = reader.get_ready() + if seq is None: + break + action, event = classify_sequence(seq, joystick_port) + if action == "ignore": + continue + if action == "event" and event is not None: + event_signature = ( + event.get("kind"), + tuple(event.get("inputs", [])), # type: ignore[arg-type] + event.get("transition"), + ) + if event_signature == first_signature and event.get("kind") == "keyboard": + reader.push(seq) + break + events.append(event) + continue + reader.push(seq) + return events, None + return events, None + + +def handle_interactive_sequence( + session: RestInputSession, + joystick_port: int, + seq: str, + reader: BufferedSequenceReader, +) -> bool: + action, event = classify_sequence(seq, joystick_port) + if action == "ignore": + return False + if action == "quit": + session.post_events([release_all_event()]) + return True + if action == "release_all": + session.post_events([release_all_event()]) + return False + if event is None: + return False + events, pending_action = collect_event_batch(event, joystick_port, reader) + post_interactive_events(session, events) + if pending_action == "release_all": + session.post_events([release_all_event()]) + elif pending_action == "quit": + session.post_events([release_all_event()]) + return True + return False + + +def run_interactive_loop( + session: RestInputSession, + joystick_port: int, + read_sequence: Callable[[], str], + read_ready_sequence: Callable[[], Optional[str]], +) -> None: + reader = BufferedSequenceReader(read_sequence, read_ready_sequence) + while True: + if handle_interactive_sequence(session, joystick_port, reader.get(), reader): + break + + +def run_interactive_with_gamepad( + session: RestInputSession, + joystick_port: int, + gamepad: GamepadDevice, + read_sequence: Callable[[], str], + read_ready_sequence: Callable[[], Optional[str]], +) -> None: + stdin_fd = sys.stdin.fileno() + gamepad_fd = gamepad.fileno() + reader = BufferedSequenceReader(read_sequence, read_ready_sequence) + + while True: + now = time.monotonic() + timeout = gamepad.state.repeat_timeout(now) + ready, _, _ = select.select([stdin_fd, gamepad_fd], [], [], timeout) + now = time.monotonic() + if not ready: + repeat_event = gamepad.state.poll_repeat_fire(now) + if repeat_event: + session.post_events([repeat_event]) + continue + if gamepad_fd in ready: + events = gamepad.read_events(now) + repeat_event = gamepad.state.poll_repeat_fire(now) + if repeat_event: + events.append(repeat_event) + if events: + session.post_events(events) + if stdin_fd in ready: + if handle_interactive_sequence(session, joystick_port, reader.get(), reader): + break + + +def run_interactive(session: RestInputSession, joystick_port: int, gamepad: Optional[GamepadDevice]) -> None: + print_mapping_overview(session.host, joystick_port, gamepad) + wait_for_input_ready(session, timeout=15.0) + session.post_events([release_all_event()]) + with raw_terminal(): + if gamepad: + run_interactive_with_gamepad(session, joystick_port, gamepad, read_key_sequence, read_ready_key_sequence) + else: + run_interactive_loop(session, joystick_port, read_key_sequence, read_ready_key_sequence) + + +def assert_input_state(session: RestInputSession, keyboard: List[str], joystick1: List[str], joystick2: List[str]) -> None: + state = session.get_state() + if state.get("keyboard", {}).get("inputs") != keyboard: + raise Failure(f"Keyboard state mismatch: {state}") + if state.get("joysticks") != [{"port": 1, "inputs": joystick1}, {"port": 2, "inputs": joystick2}]: + raise Failure(f"Joystick state mismatch: {state}") + + +def run_self_test(session: RestInputSession, joystick_port: int, gamepad: Optional[GamepadDevice]) -> None: + print_mapping_overview(session.host, joystick_port, gamepad) + wait_for_input_ready(session, timeout=15.0) + + print("[1] keyboard input reaches the live C64 matrix ... ", end="", flush=True) + reset_to_basic(session) + session.post_events([{"kind": "keyboard", "inputs": ["x"], "transition": "press"}]) + assert_keyboard_matrix_inputs(session, ["x"]) + session.post_events([release_all_event()]) + print("OK") + + print("[2] keyboard persistent state round-trips ... ", end="", flush=True) + session.post_events([{"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}]) + assert_input_state(session, ["ctrl"], [], []) + session.post_events([release_all_event()]) + assert_input_state(session, [], [], []) + print("OK") + + print("[3] joystick movement reaches the live CIA joystick lines ... ", end="", flush=True) + direction = translate_sequence("\x1b[1;5A", joystick_port) + fire = translate_sequence("\n", joystick_port) + reset_to_basic(session) + session.post_events( + [ + {"kind": "joystick", "port": joystick_port, "inputs": ["up"], "transition": "press"}, + {"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "press"}, + ] + ) + if joystick_port == 1: + assert_joystick_ports(session, 0x0E, 0x1F) + else: + assert_joystick_ports(session, 0x1F, 0x0E) + if direction is None or fire is None: + raise Failure("internal joystick key translation failed") + session.post_events([release_all_event()]) + assert_input_state(session, [], [], []) + print("OK") + + print("input_tool self-test: OK") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Send local keyboard input to U64 REST input injection") + parser.add_argument("--host", default=os.environ.get("U64_INPUT_HOST", "u64")) + parser.add_argument("--rest-host", default=os.environ.get("U64_INPUT_REST_HOST")) + parser.add_argument("--password", default=os.environ.get("U64_INPUT_PASSWORD", os.environ.get("C64U_PASSWORD"))) + parser.add_argument("--timeout", type=float, default=float(os.environ.get("U64_INPUT_TIMEOUT", "5.0"))) + parser.add_argument("--self-test", action="store_true", help="run key/joystick verification and exit") + parser.add_argument("--joystick-port", type=int, choices=(1, 2), default=2) + parser.add_argument("--gamepad-device", help="Linux /dev/input event device for the USB gamepad") + parser.add_argument("--no-gamepad", action="store_true", help="disable automatic USB gamepad support") + args = parser.parse_args() + + rest_host = args.rest_host or args.host + session = RestInputSession(rest_host, args.password, args.timeout) + gamepad = None if args.no_gamepad else open_gamepad(args.gamepad_device, args.joystick_port) + try: + if args.self_test: + run_self_test(session, args.joystick_port, gamepad) + else: + run_interactive(session, args.joystick_port, gamepad) + except KeyboardInterrupt: + try: + session.post_events([release_all_event()]) + except Exception: + pass + print() + return 0 + except Failure as exc: + print(exc, file=sys.stderr) + return 1 + except (OSError, TimeoutError, urllib.error.URLError) as exc: + if device_unavailable(exc): + print(f"Connection failure: {format_exception(exc)}", file=sys.stderr) + else: + print(f"REST failure: {format_exception(exc)}", file=sys.stderr) + return 1 + finally: + if gamepad: + gamepad.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/api/test_input_tool_gamepad.py b/tools/api/test_input_tool_gamepad.py new file mode 100644 index 000000000..0b20cbbd6 --- /dev/null +++ b/tools/api/test_input_tool_gamepad.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path +import sys +from unittest.mock import patch + + +TOOLS_API = Path(__file__).resolve().parent +if str(TOOLS_API) not in sys.path: + sys.path.insert(0, str(TOOLS_API)) + +import input_tool # noqa: E402 + + +def test_gamepad_state_combines_left_right_sticks_and_dpad_into_one_joystick() -> None: + state = input_tool.GamepadState(port=2, axis_threshold=10000) + + press_left = state.apply_input_event(input_tool.EV_ABS, input_tool.ABS_X, -20000, now=0.0) + press_right = state.apply_input_event(input_tool.EV_ABS, input_tool.ABS_RX, 20000, now=0.0) + press_dpad = state.apply_input_event(input_tool.EV_ABS, input_tool.ABS_HAT0Y, -1, now=0.0) + release_left = state.apply_input_event(input_tool.EV_ABS, input_tool.ABS_X, 0, now=0.0) + + assert press_left == [ + {"kind": "joystick", "port": 2, "inputs": ["left"], "transition": "press"} + ] + assert press_right == [ + {"kind": "joystick", "port": 2, "inputs": ["right"], "transition": "press"} + ] + assert press_dpad == [ + {"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "press"} + ] + assert release_left == [ + {"kind": "joystick", "port": 2, "inputs": ["left"], "transition": "release"} + ] + + +def test_gamepad_a_maps_to_fire_and_b_repeats_fire() -> None: + state = input_tool.GamepadState(port=1, axis_threshold=10000, fire_repeat_hz=10.0) + + fire_press = state.apply_input_event(input_tool.EV_KEY, input_tool.BTN_SOUTH, 1, now=0.0) + fire_release = state.apply_input_event(input_tool.EV_KEY, input_tool.BTN_SOUTH, 0, now=0.0) + b_initial = state.apply_input_event(input_tool.EV_KEY, input_tool.BTN_EAST, 1, now=1.0) + b_repeat = state.poll_repeat_fire(now=1.11) + b_release = state.apply_input_event(input_tool.EV_KEY, input_tool.BTN_EAST, 0, now=1.11) + b_repeat_after_release = state.poll_repeat_fire(now=1.25) + + assert fire_press == [ + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"} + ] + assert fire_release == [ + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "release"} + ] + assert b_initial == [{"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "tap"}] + assert b_repeat == {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "tap"} + assert b_release == [] + assert b_repeat_after_release is None + + +def test_find_default_gamepad_device_prefers_real_controller_over_system_control() -> None: + candidates = [ + "/dev/input/by-id/usb-Keychron_Keychron_C3_Pro-if02-event-joystick", + "/dev/input/by-id/usb-Microsoft_Controller-event-joystick", + ] + names = { + "/dev/input/by-id/usb-Keychron_Keychron_C3_Pro-if02-event-joystick": "Keychron Keychron C3 Pro System Control", + "/dev/input/by-id/usb-Microsoft_Controller-event-joystick": "Microsoft X-Box 360 pad", + } + + with patch.object(input_tool.glob, "glob", side_effect=[candidates]), patch.object( + input_tool, "input_event_name", side_effect=lambda path: names[path] + ): + assert input_tool.find_default_gamepad_device() == candidates[1] diff --git a/tools/api/test_input_tool_interactive.py b/tools/api/test_input_tool_interactive.py new file mode 100644 index 000000000..e437da98a --- /dev/null +++ b/tools/api/test_input_tool_interactive.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from collections import deque +from pathlib import Path +import sys + + +TOOLS_API = Path(__file__).resolve().parent +if str(TOOLS_API) not in sys.path: + sys.path.insert(0, str(TOOLS_API)) + +import input_tool # noqa: E402 + + +class FakeSession: + def __init__(self) -> None: + self.host = "u64" + self.calls: list[list[dict[str, object]]] = [] + self.payloads: list[dict[str, object]] = [] + + def post_events(self, events: list[dict[str, object]]) -> None: + self.calls.append(events) + + def json_request(self, method: str, path: str, payload: dict[str, object]) -> dict[str, object]: + assert method == "POST" + assert path == "/v1/machine:input" + self.payloads.append(payload) + self.calls.append(payload["events"]) # type: ignore[arg-type] + return {"errors": []} + + +def scripted_reader(*sequences: str): + queue = deque(sequences) + + def read() -> str: + if not queue: + raise AssertionError("interactive loop read past scripted input") + return queue.popleft() + + return read + + +def scripted_ready_reader(*sequences: str): + queue = deque(sequences) + + def read(): + if not queue: + return None + return queue.popleft() + + return read + + +def test_translate_sequence_uses_keyboard_taps_for_text_input() -> None: + assert input_tool.translate_sequence("a", 2) == { + "kind": "keyboard", + "inputs": ["a"], + "transition": "tap", + } + assert input_tool.translate_sequence("A", 2) == { + "kind": "keyboard", + "inputs": ["left_shift", "a"], + "transition": "tap", + } + + +def test_run_interactive_loop_posts_single_tap_for_keyboard_input() -> None: + session = FakeSession() + + input_tool.run_interactive_loop( + session, + 2, + scripted_reader("a", "\x03"), + scripted_ready_reader(), + ) + + assert session.calls == [ + [{"kind": "keyboard", "inputs": ["a"], "transition": "tap"}], + [{"kind": "release_all"}], + ] + assert session.payloads == [ + { + "events": [{"kind": "keyboard", "inputs": ["a"], "transition": "tap"}], + }, + ] + + +def test_run_interactive_loop_batches_ready_keys_with_pacing() -> None: + session = FakeSession() + + input_tool.run_interactive_loop( + session, + 2, + scripted_reader("a", "\x03"), + scripted_ready_reader("b", "C"), + ) + + assert session.calls == [ + [ + {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["b"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["left_shift", "c"], "transition": "tap"}, + ], + [{"kind": "release_all"}], + ] + assert session.payloads == [ + { + "events": [ + {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["b"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["left_shift", "c"], "transition": "tap"}, + ], + "pace_ms": input_tool.BATCH_PACE_MS, + }, + ] + + +def test_run_interactive_loop_preserves_release_all_and_joystick_events() -> None: + session = FakeSession() + + input_tool.run_interactive_loop( + session, + 2, + scripted_reader("\x1b[1;5A", "\x03"), + scripted_ready_reader("\x1b"), + ) + + assert session.calls == [ + [{"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "tap"}], + [{"kind": "release_all"}], + [{"kind": "release_all"}], + ] diff --git a/tools/api/test_unit_validation.py b/tools/api/test_unit_validation.py new file mode 100644 index 000000000..1d2bac608 --- /dev/null +++ b/tools/api/test_unit_validation.py @@ -0,0 +1,624 @@ +"""Unit-level contract tests for `/v1/machine:input`.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +import pytest + +KEYBOARD_INPUTS = [ + "inst_del", + "return", + "cursor_left_right", + "f7", + "f1", + "f3", + "f5", + "cursor_up_down", + "3", + "w", + "a", + "4", + "z", + "s", + "e", + "left_shift", + "5", + "r", + "d", + "6", + "c", + "f", + "t", + "x", + "7", + "y", + "g", + "8", + "b", + "h", + "u", + "v", + "9", + "i", + "j", + "0", + "m", + "k", + "o", + "n", + "plus", + "p", + "l", + "minus", + "period", + "colon", + "at", + "comma", + "pound", + "star", + "semicolon", + "clr_home", + "right_shift", + "equals", + "arrow_up", + "slash", + "1", + "arrow_left", + "ctrl", + "2", + "space", + "commodore", + "q", + "run_stop", + "restore", +] +JOYSTICK_INPUTS = ["up", "down", "left", "right", "fire"] +TRANSITIONS = {"press", "release", "tap"} +RESTORE = "restore" +UNSUPPORTED_MESSAGE = "Keyboard and joystick injection require Ultimate 64-class hardware." + + +class InputApiModel: + def __init__(self, supported: bool = True, port2_supported: bool = True) -> None: + self.supported = supported + self.port2_supported = port2_supported + self.keyboard_pressed: set[str] = set() + self.keyboard_overlay: set[str] = set() + self.joystick_pressed = {1: set[str](), 2: set[str]()} + self.joystick_overlay = {1: set[str](), 2: set[str]()} + + def get(self) -> tuple[int, dict[str, Any]]: + if not self.supported: + return self._unsupported() + return 200, self._snapshot() + + def post(self, payload: Any) -> tuple[int, dict[str, Any]]: + if not self.supported: + return self._unsupported() + errors = self._validate_payload(payload) + if errors: + return 400, {"errors": errors} + for event in payload["events"]: + self._apply(event) + return 200, self._snapshot() + + def post_raw( + self, body: Any, content_type: str | None = "application/json" + ) -> tuple[int, dict[str, Any]]: + if content_type != "application/json": + return 400, {"errors": ["Unsupported Content-Type; expected application/json."]} + if body is None: + return 412, {"errors": ["Expected Body, but got none."]} + return self.post(body) + + def tick(self) -> None: + self.keyboard_overlay.clear() + self.joystick_overlay[1].clear() + self.joystick_overlay[2].clear() + + def _unsupported(self) -> tuple[int, dict[str, Any]]: + return 501, {"errors": [UNSUPPORTED_MESSAGE]} + + def _snapshot(self) -> dict[str, Any]: + return { + "errors": [], + "keyboard": {"inputs": self._ordered(KEYBOARD_INPUTS, self.keyboard_pressed | self.keyboard_overlay)}, + "joysticks": [ + {"port": 1, "inputs": self._ordered(JOYSTICK_INPUTS, self.joystick_pressed[1] | self.joystick_overlay[1])}, + {"port": 2, "inputs": self._ordered(JOYSTICK_INPUTS, self.joystick_pressed[2] | self.joystick_overlay[2])}, + ], + } + + @staticmethod + def _ordered(order: list[str], values: set[str]) -> list[str]: + return [item for item in order if item in values] + + def _validate_payload(self, payload: Any) -> list[str]: + if not isinstance(payload, dict): + return ["Root must be an object."] + if set(payload) - {"events", "pace_ms"}: + return ["Root must contain only `events`."] + events = payload.get("events") + if not isinstance(events, list): + return ["`events` must be an array."] + pace_ms = payload.get("pace_ms") + if pace_ms is not None: + if type(pace_ms) is not int: + return ["`pace_ms` must be an integer."] + if not 0 <= pace_ms <= 1000: + return ["`pace_ms` must be between 0 and 1000."] + if not 1 <= len(events) <= 64: + return ["`events` must contain 1..64 entries."] + for event in events: + error = self._validate_event(event) + if error: + return [error] + return [] + + def _validate_event(self, event: Any) -> str | None: + if not isinstance(event, dict): + return "Each event must be an object." + kind = event.get("kind") + if not isinstance(kind, str) or kind not in {"keyboard", "joystick", "release_all"}: + return "`kind` must be one of `keyboard`, `joystick`, or `release_all`." + if kind == "keyboard": + return self._validate_keyboard(event) + if kind == "joystick": + return self._validate_joystick(event) + return self._validate_release_all(event) + + def _validate_transition(self, event: dict[str, Any]) -> str | None: + transition = event.get("transition") + if not isinstance(transition, str) or transition not in TRANSITIONS: + return "`transition` must be one of `press`, `release`, or `tap`." + return None + + def _validate_inputs(self, event: dict[str, Any], allowed: list[str], limit: int, label: str) -> str | None: + inputs = event.get("inputs") + if not isinstance(inputs, list): + return "`inputs` must be an array." + if not 1 <= len(inputs) <= limit: + return f"`inputs` must contain 1..{limit} entries." + seen: set[str] = set() + for input_name in inputs: + if not isinstance(input_name, str): + return f"{label} inputs must be strings." + if input_name not in allowed: + return f"`{input_name}` is not a valid {label.lower()} input." + if input_name in seen: + return f"`{input_name}` appears more than once in `inputs`." + seen.add(input_name) + return None + + def _validate_keyboard(self, event: dict[str, Any]) -> str | None: + if set(event) - {"kind", "inputs", "transition"}: + return "Unknown property in keyboard event." + error = self._validate_transition(event) + if error: + return error + error = self._validate_inputs(event, KEYBOARD_INPUTS, 8, "Keyboard") + if error: + return error + inputs = event["inputs"] + if RESTORE in inputs and (inputs != [RESTORE] or event["transition"] != "tap"): + return "`restore` must appear alone in `inputs` and only with transition `tap`." + return None + + def _validate_joystick(self, event: dict[str, Any]) -> str | None: + if set(event) - {"kind", "port", "inputs", "transition"}: + return "Unknown property in joystick event." + error = self._validate_transition(event) + if error: + return error + port = event.get("port") + if type(port) is not int: + return "`port` must be an integer." + if port not in {1, 2}: + return "`port` must be 1 or 2." + if port == 2 and not self.port2_supported: + return "Joystick port 2 is not supported by this device." + return self._validate_inputs(event, JOYSTICK_INPUTS, 5, "Joystick") + + @staticmethod + def _validate_release_all(event: dict[str, Any]) -> str | None: + if set(event) != {"kind"}: + return "`release_all` events may only contain `kind`." + return None + + def _apply(self, event: dict[str, Any]) -> None: + kind = event["kind"] + if kind == "release_all": + self.keyboard_pressed.clear() + self.keyboard_overlay.clear() + self.joystick_pressed[1].clear() + self.joystick_pressed[2].clear() + self.joystick_overlay[1].clear() + self.joystick_overlay[2].clear() + return + if kind == "keyboard": + target = self.keyboard_pressed + overlay = self.keyboard_overlay + else: + target = self.joystick_pressed[event["port"]] + overlay = self.joystick_overlay[event["port"]] + inputs = set(event["inputs"]) + transition = event["transition"] + if transition == "press": + target.update(inputs) + elif transition == "release": + target.difference_update(inputs) + else: + overlay.update(inputs) + + +def post_ok(api: InputApiModel, events: list[dict[str, Any]]) -> dict[str, Any]: + status, body = api.post({"events": events}) + assert status == 200 + assert body["errors"] == [] + return body + + +def post_bad(api: InputApiModel, payload: Any) -> dict[str, Any]: + before = deepcopy(api._snapshot()) + status, body = api.post(payload) + assert status == 400 + assert body["errors"] + assert "keyboard" not in body + assert "joysticks" not in body + assert api._snapshot() == before + return body + + +def test_get_success_response_shape_and_ordering() -> None: + api = InputApiModel() + body = post_ok( + api, + [ + {"kind": "joystick", "port": 2, "inputs": ["fire", "up"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["a", "left_shift"], "transition": "press"}, + ], + ) + + assert body == { + "errors": [], + "keyboard": {"inputs": ["a", "left_shift"]}, + "joysticks": [{"port": 1, "inputs": []}, {"port": 2, "inputs": ["up", "fire"]}], + } + assert api.get() == (200, body) + + +def test_content_type_is_required_for_post_body() -> None: + api = InputApiModel() + valid_body = {"events": [{"kind": "release_all"}]} + + assert api.post_raw(valid_body, "application/json")[0] == 200 + for content_type in (None, "", "text/plain", "application/json; charset=utf-8"): + status, body = api.post_raw(valid_body, content_type) + assert status == 400 + assert body["errors"] + + +def test_missing_json_body_is_rejected_without_mutation() -> None: + api = InputApiModel() + post_ok(api, [{"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}]) + + status, body = api.post_raw(None, "application/json") + + assert status == 412 + assert body["errors"] + assert api.get()[1]["keyboard"]["inputs"] == ["ctrl"] + + +@pytest.mark.parametrize("payload", [None, [], "{}", 1]) +def test_root_payload_must_be_object(payload: Any) -> None: + post_bad(InputApiModel(), payload) + + +@pytest.mark.parametrize("payload", [{}, {"events": [], "extra": True}, {"extra": []}]) +def test_root_must_contain_only_events(payload: Any) -> None: + post_bad(InputApiModel(), payload) + + +def test_root_accepts_optional_pace_ms() -> None: + api = InputApiModel() + + status, body = api.post({"events": [{"kind": "release_all"}], "pace_ms": 20}) + + assert status == 200 + assert body["errors"] == [] + + +@pytest.mark.parametrize("pace_ms", ["20", True, -1, 1001]) +def test_pace_ms_must_be_integer_in_range_when_present(pace_ms: Any) -> None: + post_bad(InputApiModel(), {"events": [{"kind": "release_all"}], "pace_ms": pace_ms}) + + +@pytest.mark.parametrize("events", [None, {}, "event", 1]) +def test_events_must_be_array(events: Any) -> None: + post_bad(InputApiModel(), {"events": events}) + + +def test_events_accepts_one_to_64_entries() -> None: + api = InputApiModel() + event = {"kind": "release_all"} + + assert api.post({"events": [event]})[0] == 200 + assert api.post({"events": [event] * 64})[0] == 200 + post_bad(api, {"events": []}) + post_bad(api, {"events": [event] * 65}) + + +@pytest.mark.parametrize("event", [None, [], "event", 1]) +def test_each_event_must_be_object(event: Any) -> None: + post_bad(InputApiModel(), {"events": [event]}) + + +@pytest.mark.parametrize("kind", [None, "", "mouse", 1, True]) +def test_kind_enum_is_closed_and_string_typed(kind: Any) -> None: + post_bad(InputApiModel(), {"events": [{"kind": kind}]}) + + +@pytest.mark.parametrize("input_name", KEYBOARD_INPUTS) +def test_keyboard_input_enum_accepts_every_documented_value(input_name: str) -> None: + transition = "tap" if input_name == RESTORE else "press" + assert InputApiModel().post({"events": [{"kind": "keyboard", "inputs": [input_name], "transition": transition}]})[0] == 200 + + +@pytest.mark.parametrize("transition", ["press", "release", "tap"]) +def test_keyboard_transition_enum_accepts_all_values(transition: str) -> None: + assert InputApiModel().post({"events": [{"kind": "keyboard", "inputs": ["a"], "transition": transition}]})[0] == 200 + + +@pytest.mark.parametrize( + "event", + [ + {"kind": "keyboard", "transition": "tap"}, + {"kind": "keyboard", "inputs": None, "transition": "tap"}, + {"kind": "keyboard", "inputs": "a", "transition": "tap"}, + {"kind": "keyboard", "inputs": [], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["a"] * 9, "transition": "tap"}, + {"kind": "keyboard", "inputs": ["a", "a"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["escape"], "transition": "tap"}, + {"kind": "keyboard", "inputs": [1], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["a"], "transition": None}, + {"kind": "keyboard", "inputs": ["a"], "transition": "hold"}, + {"kind": "keyboard", "inputs": ["a"], "transition": 1}, + {"kind": "keyboard", "port": 1, "inputs": ["a"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["a"], "transition": "tap", "duration_ms": 20}, + ], +) +def test_keyboard_validation_rejects_invalid_shape_values_and_unknown_fields(event: dict[str, Any]) -> None: + post_bad(InputApiModel(), {"events": [event]}) + + +@pytest.mark.parametrize( + "event", + [ + {"kind": "keyboard", "inputs": [RESTORE], "transition": "press"}, + {"kind": "keyboard", "inputs": [RESTORE], "transition": "release"}, + {"kind": "keyboard", "inputs": [RESTORE, "run_stop"], "transition": "tap"}, + {"kind": "keyboard", "inputs": [RESTORE, "a"], "transition": "tap"}, + ], +) +def test_restore_is_tap_only_and_alone(event: dict[str, Any]) -> None: + body = post_bad(InputApiModel(), {"events": [event]}) + assert "restore" in body["errors"][0] + + +@pytest.mark.parametrize("input_name", JOYSTICK_INPUTS) +def test_joystick_input_enum_accepts_every_documented_value(input_name: str) -> None: + assert InputApiModel().post( + {"events": [{"kind": "joystick", "port": 1, "inputs": [input_name], "transition": "press"}]} + )[0] == 200 + + +@pytest.mark.parametrize("transition", ["press", "release", "tap"]) +def test_joystick_transition_enum_accepts_all_values(transition: str) -> None: + assert InputApiModel().post( + {"events": [{"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": transition}]} + )[0] == 200 + + +@pytest.mark.parametrize( + "inputs", + [ + ["up", "right", "fire"], + ["up", "down"], + ["left", "right"], + ["up", "down", "left", "right", "fire"], + ], +) +def test_joystick_accepts_diagonals_and_unusual_combinations(inputs: list[str]) -> None: + assert InputApiModel().post( + {"events": [{"kind": "joystick", "port": 2, "inputs": inputs, "transition": "press"}]} + )[0] == 200 + + +@pytest.mark.parametrize( + "event", + [ + {"kind": "joystick", "inputs": ["up"], "transition": "tap"}, + {"kind": "joystick", "port": None, "inputs": ["up"], "transition": "tap"}, + {"kind": "joystick", "port": "1", "inputs": ["up"], "transition": "tap"}, + {"kind": "joystick", "port": True, "inputs": ["up"], "transition": "tap"}, + {"kind": "joystick", "port": 0, "inputs": ["up"], "transition": "tap"}, + {"kind": "joystick", "port": 3, "inputs": ["up"], "transition": "tap"}, + {"kind": "joystick", "port": 1, "transition": "tap"}, + {"kind": "joystick", "port": 1, "inputs": [], "transition": "tap"}, + {"kind": "joystick", "port": 1, "inputs": ["up"] * 6, "transition": "tap"}, + {"kind": "joystick", "port": 1, "inputs": ["jump"], "transition": "tap"}, + {"kind": "joystick", "port": 1, "inputs": ["up", "up"], "transition": "tap"}, + {"kind": "joystick", "port": 1, "inputs": [1], "transition": "tap"}, + {"kind": "joystick", "port": 1, "inputs": ["up"], "transition": "hold"}, + {"kind": "joystick", "port": 1, "inputs": ["up"], "transition": 1}, + {"kind": "joystick", "port": 1, "inputs": ["up"], "transition": "tap", "duration_ms": 20}, + ], +) +def test_joystick_validation_rejects_invalid_shape_values_and_unknown_fields(event: dict[str, Any]) -> None: + post_bad(InputApiModel(), {"events": [event]}) + + +def test_optional_port2_capability_gate_rejects_port2_before_mutation() -> None: + api = InputApiModel(port2_supported=False) + + post_ok(api, [{"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}]) + body = post_bad(api, {"events": [{"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "press"}]}) + + assert "port 2" in body["errors"][0] + assert api.get()[1]["joysticks"] == [{"port": 1, "inputs": ["fire"]}, {"port": 2, "inputs": []}] + + +@pytest.mark.parametrize( + "event", + [ + {"kind": "release_all", "inputs": []}, + {"kind": "release_all", "port": 1}, + {"kind": "release_all", "transition": "tap"}, + {"kind": "release_all", "extra": True}, + ], +) +def test_release_all_rejects_extra_fields(event: dict[str, Any]) -> None: + post_bad(InputApiModel(), {"events": [event]}) + + +def test_capability_failure_returns_501_without_snapshot_fields() -> None: + api = InputApiModel(supported=False) + + for method in (api.get, lambda: api.post({"events": [{"kind": "release_all"}]})): + status, body = method() + assert status == 501 + assert body == {"errors": [UNSUPPORTED_MESSAGE]} + + +def test_invalid_batch_is_atomic_even_when_error_is_late() -> None: + api = InputApiModel() + post_ok( + api, + [ + {"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, + ], + ) + + post_bad( + api, + { + "events": [ + {"kind": "release_all"}, + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "joystick", "port": 3, "inputs": ["up"], "transition": "press"}, + ] + }, + ) + + assert api.get()[1] == { + "errors": [], + "keyboard": {"inputs": ["ctrl"]}, + "joysticks": [{"port": 1, "inputs": ["fire"]}, {"port": 2, "inputs": []}], + } + + +def test_batch_events_are_applied_in_order_and_can_recover_after_release_all() -> None: + api = InputApiModel() + body = post_ok( + api, + [ + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "joystick", "port": 1, "inputs": ["up", "fire"], "transition": "press"}, + {"kind": "release_all"}, + {"kind": "keyboard", "inputs": ["commodore", "ctrl"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["commodore"], "transition": "release"}, + {"kind": "joystick", "port": 2, "inputs": ["down"], "transition": "press"}, + ], + ) + + assert body["keyboard"]["inputs"] == ["ctrl"] + assert body["joysticks"] == [{"port": 1, "inputs": []}, {"port": 2, "inputs": ["down"]}] + + +def test_press_release_and_release_all_are_idempotent() -> None: + api = InputApiModel() + + body = post_ok( + api, + [ + {"kind": "keyboard", "inputs": ["a"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["a"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["space"], "transition": "release"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "release"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, + ], + ) + + assert body["keyboard"]["inputs"] == ["a"] + assert body["joysticks"][0]["inputs"] == ["fire"] + assert post_ok(api, [{"kind": "release_all"}, {"kind": "release_all"}]) == { + "errors": [], + "keyboard": {"inputs": []}, + "joysticks": [{"port": 1, "inputs": []}, {"port": 2, "inputs": []}], + } + + +def test_keyboard_tap_overlay_auto_releases_without_clearing_persistent_key() -> None: + api = InputApiModel() + + body = post_ok( + api, + [ + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, + ], + ) + assert body["keyboard"]["inputs"] == ["a", "left_shift"] + + api.tick() + assert api.get()[1]["keyboard"]["inputs"] == ["left_shift"] + + +def test_release_does_not_cancel_active_keyboard_or_joystick_tap_overlay() -> None: + api = InputApiModel() + + body = post_ok( + api, + [ + {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["a"], "transition": "release"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "tap"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "release"}, + ], + ) + assert body["keyboard"]["inputs"] == ["a"] + assert body["joysticks"][0]["inputs"] == ["fire"] + + api.tick() + assert api.get()[1]["keyboard"]["inputs"] == [] + assert api.get()[1]["joysticks"][0]["inputs"] == [] + + +def test_joystick_tap_overlay_auto_releases_without_clearing_persistent_inputs() -> None: + api = InputApiModel() + + body = post_ok( + api, + [ + {"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "press"}, + {"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "tap"}, + ], + ) + assert body["joysticks"][1]["inputs"] == ["up", "fire"] + + api.tick() + assert api.get()[1]["joysticks"][1]["inputs"] == ["up"] + + +def test_restore_tap_is_reported_temporarily_but_not_persisted() -> None: + api = InputApiModel() + + assert post_ok(api, [{"kind": "keyboard", "inputs": [RESTORE], "transition": "tap"}])["keyboard"]["inputs"] == [ + RESTORE + ] + api.tick() + assert api.get()[1]["keyboard"]["inputs"] == [] From fccdccc5f0faa78a1d7fb0319c639f81187ee9ff Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Tue, 19 May 2026 09:30:44 +0100 Subject: [PATCH 2/7] Improve tests for keyboard and joystick event handling --- software/api/input_api.h | 493 ++++++++++++++ software/api/route_input.cc | 539 +-------------- software/api/tests/Makefile | 11 + software/api/tests/input_api_state_test.cpp | 151 +++++ .../api/tests/input_api_validation_test.cpp | 276 ++++++++ software/io/c64/joystick_output.cc | 5 - software/io/c64/joystick_output.h | 1 - software/io/usb/tests/Makefile | 2 +- tools/api/input_test.py | 126 +--- tools/api/input_tool.py | 404 ++++++++---- tools/api/test_input_tool_gamepad.py | 72 -- tools/api/test_input_tool_interactive.py | 132 ---- tools/api/test_unit_validation.py | 624 ------------------ 13 files changed, 1253 insertions(+), 1583 deletions(-) create mode 100644 software/api/input_api.h create mode 100644 software/api/tests/Makefile create mode 100644 software/api/tests/input_api_state_test.cpp create mode 100644 software/api/tests/input_api_validation_test.cpp delete mode 100644 tools/api/test_input_tool_gamepad.py delete mode 100644 tools/api/test_input_tool_interactive.py delete mode 100644 tools/api/test_unit_validation.py diff --git a/software/api/input_api.h b/software/api/input_api.h new file mode 100644 index 000000000..73800b61e --- /dev/null +++ b/software/api/input_api.h @@ -0,0 +1,493 @@ +#ifndef INPUT_API_H +#define INPUT_API_H + +#include "json.h" +#include "integer.h" + +#include +#include + +static const int INPUT_API_MAX_EVENTS = 64; +static const int INPUT_API_MAX_KEYBOARD_INPUTS = 8; +static const int INPUT_API_MAX_JOYSTICK_INPUTS = 5; +static const int INPUT_API_ERROR_SIZE = 160; + +enum InputParsedKind { + INPUT_PARSED_KEYBOARD, + INPUT_PARSED_JOYSTICK, + INPUT_PARSED_RELEASE_ALL +}; + +enum InputParsedTransition { + INPUT_PARSED_PRESS, + INPUT_PARSED_RELEASE, + INPUT_PARSED_TAP +}; + +struct InputKeyboardMapEntry { + const char *name; + uint8_t row; + uint8_t col; + bool restore; +}; + +struct InputJoystickMapEntry { + const char *name; + uint8_t bit; +}; + +struct InputParsedEvent { + InputParsedKind kind; + InputParsedTransition transition; + uint8_t port; + uint8_t keyboard_count; + uint8_t keyboard_index[INPUT_API_MAX_KEYBOARD_INPUTS]; + uint8_t joystick_mask; +}; + +static const InputKeyboardMapEntry INPUT_API_KEYBOARD_MAP[] = { + { "inst_del", 0, 0, false }, + { "return", 0, 1, false }, + { "cursor_left_right", 0, 2, false }, + { "f7", 0, 3, false }, + { "f1", 0, 4, false }, + { "f3", 0, 5, false }, + { "f5", 0, 6, false }, + { "cursor_up_down", 0, 7, false }, + { "3", 1, 0, false }, + { "w", 1, 1, false }, + { "a", 1, 2, false }, + { "4", 1, 3, false }, + { "z", 1, 4, false }, + { "s", 1, 5, false }, + { "e", 1, 6, false }, + { "left_shift", 1, 7, false }, + { "5", 2, 0, false }, + { "r", 2, 1, false }, + { "d", 2, 2, false }, + { "6", 2, 3, false }, + { "c", 2, 4, false }, + { "f", 2, 5, false }, + { "t", 2, 6, false }, + { "x", 2, 7, false }, + { "7", 3, 0, false }, + { "y", 3, 1, false }, + { "g", 3, 2, false }, + { "8", 3, 3, false }, + { "b", 3, 4, false }, + { "h", 3, 5, false }, + { "u", 3, 6, false }, + { "v", 3, 7, false }, + { "9", 4, 0, false }, + { "i", 4, 1, false }, + { "j", 4, 2, false }, + { "0", 4, 3, false }, + { "m", 4, 4, false }, + { "k", 4, 5, false }, + { "o", 4, 6, false }, + { "n", 4, 7, false }, + { "plus", 5, 0, false }, + { "p", 5, 1, false }, + { "l", 5, 2, false }, + { "minus", 5, 3, false }, + { "period", 5, 4, false }, + { "colon", 5, 5, false }, + { "at", 5, 6, false }, + { "comma", 5, 7, false }, + { "pound", 6, 0, false }, + { "star", 6, 1, false }, + { "semicolon", 6, 2, false }, + { "clr_home", 6, 3, false }, + { "right_shift", 6, 4, false }, + { "equals", 6, 5, false }, + { "arrow_up", 6, 6, false }, + { "slash", 6, 7, false }, + { "1", 7, 0, false }, + { "arrow_left", 7, 1, false }, + { "ctrl", 7, 2, false }, + { "2", 7, 3, false }, + { "space", 7, 4, false }, + { "commodore", 7, 5, false }, + { "q", 7, 6, false }, + { "run_stop", 7, 7, false }, + { "restore", 0, 0, true }, +}; + +static const InputJoystickMapEntry INPUT_API_JOYSTICK_MAP[] = { + { "up", 0 }, + { "down", 1 }, + { "left", 2 }, + { "right", 3 }, + { "fire", 4 }, +}; + +static const int INPUT_API_KEYBOARD_MAP_COUNT = sizeof(INPUT_API_KEYBOARD_MAP) / sizeof(INPUT_API_KEYBOARD_MAP[0]); +static const int INPUT_API_JOYSTICK_MAP_COUNT = sizeof(INPUT_API_JOYSTICK_MAP) / sizeof(INPUT_API_JOYSTICK_MAP[0]); + +static inline const InputKeyboardMapEntry *input_api_keyboard_map(void) +{ + return INPUT_API_KEYBOARD_MAP; +} + +static inline int input_api_keyboard_map_count(void) +{ + return INPUT_API_KEYBOARD_MAP_COUNT; +} + +static inline const InputJoystickMapEntry *input_api_joystick_map(void) +{ + return INPUT_API_JOYSTICK_MAP; +} + +static inline int input_api_joystick_map_count(void) +{ + return INPUT_API_JOYSTICK_MAP_COUNT; +} + +static inline void input_api_copy_preview(char *dst, size_t dst_size, const char *src) +{ + if (!dst || (dst_size == 0)) { + return; + } + if (!src) { + dst[0] = 0; + return; + } + size_t i = 0; + while (src[i] && ((i + 1) < dst_size)) { + dst[i] = src[i]; + i++; + } + if (src[i] && (dst_size >= 4)) { + dst[dst_size - 4] = '.'; + dst[dst_size - 3] = '.'; + dst[dst_size - 2] = '.'; + dst[dst_size - 1] = 0; + return; + } + dst[i] = 0; +} + +static inline void input_api_set_error(char *err, size_t err_size, const char *msg) +{ + input_api_copy_preview(err, err_size, msg); +} + +static inline bool input_api_has_key(JSON_Object *obj, const char *name) +{ + return obj->get(name) != NULL; +} + +static inline bool input_api_key_allowed(const char *key, const char *const *allowed, int allowed_count) +{ + for (int i = 0; i < allowed_count; i++) { + if (strcmp(key, allowed[i]) == 0) { + return true; + } + } + return false; +} + +static inline bool input_api_reject_unknown_keys(JSON_Object *obj, const char *const *allowed, int allowed_count, + char *err, size_t err_size) +{ + IndexedList *keys = obj->get_keys(); + for (int i = 0; i < keys->get_elements(); i++) { + const char *key = (*keys)[i]; + if (!input_api_key_allowed(key, allowed, allowed_count)) { + char preview[32]; + input_api_copy_preview(preview, sizeof(preview), key); + sprintf(err, "Unknown field `%s`.", preview); + return false; + } + } + return true; +} + +static inline bool input_api_get_string_field(JSON_Object *obj, const char *name, const char **out, + char *err, size_t err_size) +{ + JSON *value = obj->get(name); + if (!value) { + sprintf(err, "`%s` is required.", name); + return false; + } + if (value->type() != eString) { + sprintf(err, "`%s` must be a string.", name); + return false; + } + *out = ((JSON_String *)value)->get_string(); + return true; +} + +static inline bool input_api_parse_transition(JSON_Object *obj, InputParsedTransition &transition, + char *err, size_t err_size) +{ + const char *name; + if (!input_api_get_string_field(obj, "transition", &name, err, err_size)) { + return false; + } + if (strcmp(name, "press") == 0) { + transition = INPUT_PARSED_PRESS; + return true; + } + if (strcmp(name, "release") == 0) { + transition = INPUT_PARSED_RELEASE; + return true; + } + if (strcmp(name, "tap") == 0) { + transition = INPUT_PARSED_TAP; + return true; + } + input_api_set_error(err, err_size, "`transition` must be one of `press`, `release`, or `tap`."); + return false; +} + +static inline int input_api_find_keyboard_input(const char *name) +{ + for (int i = 0; i < INPUT_API_KEYBOARD_MAP_COUNT; i++) { + if (strcmp(name, INPUT_API_KEYBOARD_MAP[i].name) == 0) { + return i; + } + } + return -1; +} + +static inline int input_api_find_joystick_input(const char *name) +{ + for (int i = 0; i < INPUT_API_JOYSTICK_MAP_COUNT; i++) { + if (strcmp(name, INPUT_API_JOYSTICK_MAP[i].name) == 0) { + return i; + } + } + return -1; +} + +static inline bool input_api_parse_keyboard_event(JSON_Object *obj, InputParsedEvent &out, char *err, size_t err_size) +{ + static const char *const allowed[] = { "kind", "inputs", "transition" }; + if (!input_api_reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err, err_size)) { + return false; + } + if (input_api_has_key(obj, "port")) { + input_api_set_error(err, err_size, "`port` is not allowed for keyboard events."); + return false; + } + if (!input_api_parse_transition(obj, out.transition, err, err_size)) { + return false; + } + JSON *inputs = obj->get("inputs"); + if (!inputs) { + input_api_set_error(err, err_size, "`inputs` is required."); + return false; + } + if (inputs->type() != eList) { + input_api_set_error(err, err_size, "`inputs` must be an array."); + return false; + } + JSON_List *list = (JSON_List *)inputs; + int count = list->get_num_elements(); + if ((count < 1) || (count > INPUT_API_MAX_KEYBOARD_INPUTS)) { + input_api_set_error(err, err_size, "`inputs` must contain 1..8 entries."); + return false; + } + + bool seen[INPUT_API_KEYBOARD_MAP_COUNT] = { false }; + bool has_restore = false; + out.kind = INPUT_PARSED_KEYBOARD; + out.keyboard_count = count; + for (int i = 0; i < count; i++) { + JSON *entry = (*list)[i]; + if (entry->type() != eString) { + input_api_set_error(err, err_size, "Keyboard inputs must be strings."); + return false; + } + const char *name = ((JSON_String *)entry)->get_string(); + int index = input_api_find_keyboard_input(name); + if (index < 0) { + char preview[32]; + input_api_copy_preview(preview, sizeof(preview), name); + sprintf(err, "`%s` is not a valid keyboard input.", preview); + return false; + } + if (seen[index]) { + char preview[32]; + input_api_copy_preview(preview, sizeof(preview), name); + sprintf(err, "`%s` appears more than once in `inputs`.", preview); + return false; + } + seen[index] = true; + out.keyboard_index[i] = index; + if (INPUT_API_KEYBOARD_MAP[index].restore) { + has_restore = true; + } + } + + if (has_restore && ((count != 1) || (out.transition != INPUT_PARSED_TAP))) { + input_api_set_error(err, err_size, "`restore` must appear alone in `inputs` and only with transition `tap`."); + return false; + } + return true; +} + +static inline bool input_api_parse_joystick_event(JSON_Object *obj, InputParsedEvent &out, char *err, size_t err_size) +{ + static const char *const allowed[] = { "kind", "port", "inputs", "transition" }; + if (!input_api_reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err, err_size)) { + return false; + } + if (!input_api_parse_transition(obj, out.transition, err, err_size)) { + return false; + } + JSON *port = obj->get("port"); + if (!port) { + input_api_set_error(err, err_size, "`port` is required."); + return false; + } + if (port->type() != eInteger) { + input_api_set_error(err, err_size, "`port` must be an integer."); + return false; + } + int port_value = ((JSON_Integer *)port)->get_value(); + if ((port_value != 1) && (port_value != 2)) { + input_api_set_error(err, err_size, "`port` must be 1 or 2."); + return false; + } + + JSON *inputs = obj->get("inputs"); + if (!inputs) { + input_api_set_error(err, err_size, "`inputs` is required."); + return false; + } + if (inputs->type() != eList) { + input_api_set_error(err, err_size, "`inputs` must be an array."); + return false; + } + JSON_List *list = (JSON_List *)inputs; + int count = list->get_num_elements(); + if ((count < 1) || (count > INPUT_API_MAX_JOYSTICK_INPUTS)) { + input_api_set_error(err, err_size, "`inputs` must contain 1..5 entries."); + return false; + } + + bool seen[INPUT_API_JOYSTICK_MAP_COUNT] = { false }; + out.kind = INPUT_PARSED_JOYSTICK; + out.port = port_value; + out.joystick_mask = 0; + for (int i = 0; i < count; i++) { + JSON *entry = (*list)[i]; + if (entry->type() != eString) { + input_api_set_error(err, err_size, "Joystick inputs must be strings."); + return false; + } + const char *name = ((JSON_String *)entry)->get_string(); + int index = input_api_find_joystick_input(name); + if (index < 0) { + char preview[32]; + input_api_copy_preview(preview, sizeof(preview), name); + sprintf(err, "`%s` is not a valid joystick input.", preview); + return false; + } + if (seen[index]) { + char preview[32]; + input_api_copy_preview(preview, sizeof(preview), name); + sprintf(err, "`%s` appears more than once in `inputs`.", preview); + return false; + } + seen[index] = true; + out.joystick_mask |= (1 << INPUT_API_JOYSTICK_MAP[index].bit); + } + return true; +} + +static inline bool input_api_parse_release_all_event(JSON_Object *obj, InputParsedEvent &out, + char *err, size_t err_size) +{ + static const char *const allowed[] = { "kind" }; + if (!input_api_reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err, err_size)) { + return false; + } + if (input_api_has_key(obj, "port") || input_api_has_key(obj, "inputs") || input_api_has_key(obj, "transition")) { + input_api_set_error(err, err_size, "`release_all` events may only contain `kind`."); + return false; + } + out.kind = INPUT_PARSED_RELEASE_ALL; + return true; +} + +static inline bool input_api_parse_event(JSON *json, InputParsedEvent &out, char *err, size_t err_size) +{ + memset(&out, 0, sizeof(out)); + if (!json || (json->type() != eObject)) { + input_api_set_error(err, err_size, "Each event must be an object."); + return false; + } + JSON_Object *obj = (JSON_Object *)json; + const char *kind; + if (!input_api_get_string_field(obj, "kind", &kind, err, err_size)) { + return false; + } + if (strcmp(kind, "keyboard") == 0) { + return input_api_parse_keyboard_event(obj, out, err, err_size); + } + if (strcmp(kind, "joystick") == 0) { + return input_api_parse_joystick_event(obj, out, err, err_size); + } + if (strcmp(kind, "release_all") == 0) { + return input_api_parse_release_all_event(obj, out, err, err_size); + } + input_api_set_error(err, err_size, "`kind` must be one of `keyboard`, `joystick`, or `release_all`."); + return false; +} + +static inline bool input_api_validate_batch(JSON *root, InputParsedEvent events[INPUT_API_MAX_EVENTS], int &event_count, + int &pace_ms, int &error_index, char *err, size_t err_size) +{ + error_index = -1; + if (!root || (root->type() != eObject)) { + input_api_set_error(err, err_size, "Root must be an object."); + return false; + } + JSON_Object *obj = (JSON_Object *)root; + static const char *const allowed[] = { "events", "pace_ms" }; + if (!input_api_reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err, err_size)) { + return false; + } + pace_ms = 0; + JSON *pace = obj->get("pace_ms"); + if (pace) { + if (pace->type() != eInteger) { + input_api_set_error(err, err_size, "`pace_ms` must be an integer."); + return false; + } + pace_ms = ((JSON_Integer *)pace)->get_value(); + if ((pace_ms < 0) || (pace_ms > 1000)) { + input_api_set_error(err, err_size, "`pace_ms` must be between 0 and 1000."); + return false; + } + } + JSON *events_json = obj->get("events"); + if (!events_json) { + input_api_set_error(err, err_size, "`events` is required."); + return false; + } + if (events_json->type() != eList) { + input_api_set_error(err, err_size, "`events` must be an array."); + return false; + } + JSON_List *list = (JSON_List *)events_json; + event_count = list->get_num_elements(); + if ((event_count < 1) || (event_count > INPUT_API_MAX_EVENTS)) { + input_api_set_error(err, err_size, "`events` must contain 1..64 entries."); + return false; + } + for (int i = 0; i < event_count; i++) { + if (!input_api_parse_event((*list)[i], events[i], err, err_size)) { + error_index = i; + return false; + } + } + return true; +} + +#endif diff --git a/software/api/route_input.cc b/software/api/route_input.cc index 26789a0a2..a9bc59cb1 100644 --- a/software/api/route_input.cc +++ b/software/api/route_input.cc @@ -1,5 +1,6 @@ #include "routes.h" #include "attachment_writer.h" +#include "input_api.h" #include "itu.h" #include "keyboard_usb.h" #include "joystick_output.h" @@ -8,128 +9,16 @@ #include "semphr.h" #include -#include #include #include static const char *INPUT_CAPABILITY_ERROR = "Keyboard and joystick injection require Ultimate 64-class hardware."; -static const char *INPUT_JOYSTICK_PORT2_ERROR = "Joystick port 2 injection is not supported by this FPGA build."; -static const int INPUT_ERROR_SIZE = 160; #if U64 static const uint8_t REST_TAP_HOLD_TICKS = 1; static SemaphoreHandle_t rest_input_mutex = NULL; -enum ParsedKind { - PARSED_KEYBOARD, - PARSED_JOYSTICK, - PARSED_RELEASE_ALL -}; - -enum ParsedTransition { - PARSED_PRESS, - PARSED_RELEASE, - PARSED_TAP -}; - -struct KeyboardMapEntry { - const char *name; - uint8_t row; - uint8_t col; - bool restore; -}; - -struct JoystickMapEntry { - const char *name; - uint8_t bit; -}; - -struct ParsedEvent { - ParsedKind kind; - ParsedTransition transition; - uint8_t port; - uint8_t keyboard_count; - uint8_t keyboard_index[8]; - uint8_t joystick_mask; -}; - -static const KeyboardMapEntry keyboard_map[] = { - { "inst_del", 0, 0, false }, - { "return", 0, 1, false }, - { "cursor_left_right", 0, 2, false }, - { "f7", 0, 3, false }, - { "f1", 0, 4, false }, - { "f3", 0, 5, false }, - { "f5", 0, 6, false }, - { "cursor_up_down", 0, 7, false }, - { "3", 1, 0, false }, - { "w", 1, 1, false }, - { "a", 1, 2, false }, - { "4", 1, 3, false }, - { "z", 1, 4, false }, - { "s", 1, 5, false }, - { "e", 1, 6, false }, - { "left_shift", 1, 7, false }, - { "5", 2, 0, false }, - { "r", 2, 1, false }, - { "d", 2, 2, false }, - { "6", 2, 3, false }, - { "c", 2, 4, false }, - { "f", 2, 5, false }, - { "t", 2, 6, false }, - { "x", 2, 7, false }, - { "7", 3, 0, false }, - { "y", 3, 1, false }, - { "g", 3, 2, false }, - { "8", 3, 3, false }, - { "b", 3, 4, false }, - { "h", 3, 5, false }, - { "u", 3, 6, false }, - { "v", 3, 7, false }, - { "9", 4, 0, false }, - { "i", 4, 1, false }, - { "j", 4, 2, false }, - { "0", 4, 3, false }, - { "m", 4, 4, false }, - { "k", 4, 5, false }, - { "o", 4, 6, false }, - { "n", 4, 7, false }, - { "plus", 5, 0, false }, - { "p", 5, 1, false }, - { "l", 5, 2, false }, - { "minus", 5, 3, false }, - { "period", 5, 4, false }, - { "colon", 5, 5, false }, - { "at", 5, 6, false }, - { "comma", 5, 7, false }, - { "pound", 6, 0, false }, - { "star", 6, 1, false }, - { "semicolon", 6, 2, false }, - { "clr_home", 6, 3, false }, - { "right_shift", 6, 4, false }, - { "equals", 6, 5, false }, - { "arrow_up", 6, 6, false }, - { "slash", 6, 7, false }, - { "1", 7, 0, false }, - { "arrow_left", 7, 1, false }, - { "ctrl", 7, 2, false }, - { "2", 7, 3, false }, - { "space", 7, 4, false }, - { "commodore", 7, 5, false }, - { "q", 7, 6, false }, - { "run_stop", 7, 7, false }, - { "restore", 0, 0, true }, -}; - -static const JoystickMapEntry joystick_map[] = { - { "up", 0 }, - { "down", 1 }, - { "left", 2 }, - { "right", 3 }, - { "fire", 4 }, -}; - static SemaphoreHandle_t input_mutex(void) { if (!rest_input_mutex) { @@ -138,382 +27,17 @@ static SemaphoreHandle_t input_mutex(void) return rest_input_mutex; } -static int keyboard_map_count(void) -{ - return sizeof(keyboard_map) / sizeof(keyboard_map[0]); -} - -static int joystick_map_count(void) -{ - return sizeof(joystick_map) / sizeof(joystick_map[0]); -} - -static void copy_preview(char *dst, size_t dst_size, const char *src) -{ - if (!dst || (dst_size == 0)) { - return; - } - if (!src) { - dst[0] = 0; - return; - } - size_t i = 0; - while (src[i] && ((i + 1) < dst_size)) { - dst[i] = src[i]; - i++; - } - if (src[i] && (dst_size >= 4)) { - dst[dst_size - 4] = '.'; - dst[dst_size - 3] = '.'; - dst[dst_size - 2] = '.'; - dst[dst_size - 1] = 0; - return; - } - dst[i] = 0; -} - -static void set_error(char *err, const char *msg) -{ - copy_preview(err, INPUT_ERROR_SIZE, msg); -} - -static bool has_key(JSON_Object *obj, const char *name) -{ - return obj->get(name) != NULL; -} - -static bool key_allowed(const char *key, const char *const *allowed, int allowed_count) -{ - for (int i = 0; i < allowed_count; i++) { - if (strcmp(key, allowed[i]) == 0) { - return true; - } - } - return false; -} - -static bool reject_unknown_keys(JSON_Object *obj, const char *const *allowed, int allowed_count, char *err) -{ - IndexedList *keys = obj->get_keys(); - for (int i = 0; i < keys->get_elements(); i++) { - const char *key = (*keys)[i]; - if (!key_allowed(key, allowed, allowed_count)) { - char preview[32]; - copy_preview(preview, sizeof(preview), key); - sprintf(err, "Unknown field `%s`.", preview); - return false; - } - } - return true; -} - -static bool get_string_field(JSON_Object *obj, const char *name, const char **out, char *err) -{ - JSON *value = obj->get(name); - if (!value) { - sprintf(err, "`%s` is required.", name); - return false; - } - if (value->type() != eString) { - sprintf(err, "`%s` must be a string.", name); - return false; - } - *out = ((JSON_String *)value)->get_string(); - return true; -} - -static bool parse_transition(JSON_Object *obj, ParsedTransition &transition, char *err) -{ - const char *name; - if (!get_string_field(obj, "transition", &name, err)) { - return false; - } - if (strcmp(name, "press") == 0) { - transition = PARSED_PRESS; - return true; - } - if (strcmp(name, "release") == 0) { - transition = PARSED_RELEASE; - return true; - } - if (strcmp(name, "tap") == 0) { - transition = PARSED_TAP; - return true; - } - set_error(err, "`transition` must be one of `press`, `release`, or `tap`."); - return false; -} - -static int find_keyboard_input(const char *name) -{ - for (int i = 0; i < keyboard_map_count(); i++) { - if (strcmp(name, keyboard_map[i].name) == 0) { - return i; - } - } - return -1; -} - -static int find_joystick_input(const char *name) -{ - for (int i = 0; i < joystick_map_count(); i++) { - if (strcmp(name, joystick_map[i].name) == 0) { - return i; - } - } - return -1; -} - -static bool parse_keyboard_event(JSON_Object *obj, ParsedEvent &out, char *err) -{ - static const char *const allowed[] = { "kind", "inputs", "transition" }; - if (!reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err)) { - return false; - } - if (has_key(obj, "port")) { - set_error(err, "`port` is not allowed for keyboard events."); - return false; - } - if (!parse_transition(obj, out.transition, err)) { - return false; - } - JSON *inputs = obj->get("inputs"); - if (!inputs) { - set_error(err, "`inputs` is required."); - return false; - } - if (inputs->type() != eList) { - set_error(err, "`inputs` must be an array."); - return false; - } - JSON_List *list = (JSON_List *)inputs; - int count = list->get_num_elements(); - if ((count < 1) || (count > 8)) { - set_error(err, "`inputs` must contain 1..8 entries."); - return false; - } - - bool seen[sizeof(keyboard_map) / sizeof(keyboard_map[0])] = { false }; - bool has_restore = false; - out.kind = PARSED_KEYBOARD; - out.keyboard_count = count; - for (int i = 0; i < count; i++) { - JSON *entry = (*list)[i]; - if (entry->type() != eString) { - set_error(err, "Keyboard inputs must be strings."); - return false; - } - const char *name = ((JSON_String *)entry)->get_string(); - int index = find_keyboard_input(name); - if (index < 0) { - char preview[32]; - copy_preview(preview, sizeof(preview), name); - sprintf(err, "`%s` is not a valid keyboard input.", preview); - return false; - } - if (seen[index]) { - char preview[32]; - copy_preview(preview, sizeof(preview), name); - sprintf(err, "`%s` appears more than once in `inputs`.", preview); - return false; - } - seen[index] = true; - out.keyboard_index[i] = index; - if (keyboard_map[index].restore) { - has_restore = true; - } - } - - if (has_restore && ((count != 1) || (out.transition != PARSED_TAP))) { - set_error(err, "`restore` must appear alone in `inputs` and only with transition `tap`."); - return false; - } - return true; -} - -static bool parse_joystick_event(JSON_Object *obj, ParsedEvent &out, char *err) -{ - static const char *const allowed[] = { "kind", "port", "inputs", "transition" }; - if (!reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err)) { - return false; - } - if (!parse_transition(obj, out.transition, err)) { - return false; - } - JSON *port = obj->get("port"); - if (!port) { - set_error(err, "`port` is required."); - return false; - } - if (port->type() != eInteger) { - set_error(err, "`port` must be an integer."); - return false; - } - int port_value = ((JSON_Integer *)port)->get_value(); - if ((port_value != 1) && (port_value != 2)) { - set_error(err, "`port` must be 1 or 2."); - return false; - } - - JSON *inputs = obj->get("inputs"); - if (!inputs) { - set_error(err, "`inputs` is required."); - return false; - } - if (inputs->type() != eList) { - set_error(err, "`inputs` must be an array."); - return false; - } - JSON_List *list = (JSON_List *)inputs; - int count = list->get_num_elements(); - if ((count < 1) || (count > 5)) { - set_error(err, "`inputs` must contain 1..5 entries."); - return false; - } - - bool seen[sizeof(joystick_map) / sizeof(joystick_map[0])] = { false }; - out.kind = PARSED_JOYSTICK; - out.port = port_value; - out.joystick_mask = 0; - for (int i = 0; i < count; i++) { - JSON *entry = (*list)[i]; - if (entry->type() != eString) { - set_error(err, "Joystick inputs must be strings."); - return false; - } - const char *name = ((JSON_String *)entry)->get_string(); - int index = find_joystick_input(name); - if (index < 0) { - char preview[32]; - copy_preview(preview, sizeof(preview), name); - sprintf(err, "`%s` is not a valid joystick input.", preview); - return false; - } - if (seen[index]) { - char preview[32]; - copy_preview(preview, sizeof(preview), name); - sprintf(err, "`%s` appears more than once in `inputs`.", preview); - return false; - } - seen[index] = true; - out.joystick_mask |= (1 << joystick_map[index].bit); - } - return true; -} - -static bool parse_release_all_event(JSON_Object *obj, ParsedEvent &out, char *err) -{ - static const char *const allowed[] = { "kind" }; - if (!reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err)) { - return false; - } - if (has_key(obj, "port") || has_key(obj, "inputs") || has_key(obj, "transition")) { - set_error(err, "`release_all` events may only contain `kind`."); - return false; - } - out.kind = PARSED_RELEASE_ALL; - return true; -} - -static bool parse_event(JSON *json, ParsedEvent &out, char *err) -{ - if (json->type() != eObject) { - set_error(err, "Each event must be an object."); - return false; - } - JSON_Object *obj = (JSON_Object *)json; - const char *kind; - if (!get_string_field(obj, "kind", &kind, err)) { - return false; - } - if (strcmp(kind, "keyboard") == 0) { - return parse_keyboard_event(obj, out, err); - } - if (strcmp(kind, "joystick") == 0) { - return parse_joystick_event(obj, out, err); - } - if (strcmp(kind, "release_all") == 0) { - return parse_release_all_event(obj, out, err); - } - set_error(err, "`kind` must be one of `keyboard`, `joystick`, or `release_all`."); - return false; -} - -static bool validate_batch(ResponseWrapper *resp, JSON *root, ParsedEvent events[64], int &event_count, int &pace_ms) -{ - if (root->type() != eObject) { - resp->error("Root must be an object."); - return false; - } - JSON_Object *obj = (JSON_Object *)root; - static const char *const allowed[] = { "events", "pace_ms" }; - char err[INPUT_ERROR_SIZE]; - if (!reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err)) { - resp->error("%s", err); - return false; - } - pace_ms = 0; - JSON *pace = obj->get("pace_ms"); - if (pace) { - if (pace->type() != eInteger) { - resp->error("`pace_ms` must be an integer."); - return false; - } - pace_ms = ((JSON_Integer *)pace)->get_value(); - if ((pace_ms < 0) || (pace_ms > 1000)) { - resp->error("`pace_ms` must be between 0 and 1000."); - return false; - } - } - JSON *events_json = obj->get("events"); - if (!events_json) { - resp->error("`events` is required."); - return false; - } - if (events_json->type() != eList) { - resp->error("`events` must be an array."); - return false; - } - JSON_List *list = (JSON_List *)events_json; - event_count = list->get_num_elements(); - if ((event_count < 1) || (event_count > 64)) { - resp->error("`events` must contain 1..64 entries."); - return false; - } - for (int i = 0; i < event_count; i++) { - err[0] = 0; - if (!parse_event((*list)[i], events[i], err)) { - resp->error("events[%d]: %s", i, err); - return false; - } - } - return true; -} - -static bool batch_uses_unsupported_port2(const ParsedEvent *events, int event_count) -{ - if (JoystickOutput::port2Supported()) { - return false; - } - for (int i = 0; i < event_count; i++) { - if ((events[i].kind == PARSED_JOYSTICK) && (events[i].port == 2)) { - return true; - } - } - return false; -} - -static void apply_joystick_event(const ParsedEvent &event) +static void apply_joystick_event(const InputParsedEvent &event) { uint8_t p1, p2; uint8_t active_low; - uint8_t hold[5] = { 0, 0, 0, 0, 0 }; + uint8_t hold[INPUT_API_MAX_JOYSTICK_INPUTS] = { 0, 0, 0, 0, 0 }; JoystickOutput::instance().restPersistentSnapshot(p1, p2); active_low = (event.port == 1) ? p1 : p2; switch (event.transition) { - case PARSED_PRESS: + case INPUT_PARSED_PRESS: active_low &= ~event.joystick_mask; if (event.port == 1) { JoystickOutput::instance().setRestPort1Persistent(active_low); @@ -521,7 +45,7 @@ static void apply_joystick_event(const ParsedEvent &event) JoystickOutput::instance().setRestPort2Persistent(active_low); } break; - case PARSED_RELEASE: + case INPUT_PARSED_RELEASE: active_low |= event.joystick_mask; if (event.port == 1) { JoystickOutput::instance().setRestPort1Persistent(active_low); @@ -529,8 +53,8 @@ static void apply_joystick_event(const ParsedEvent &event) JoystickOutput::instance().setRestPort2Persistent(active_low); } break; - case PARSED_TAP: - for (int i = 0; i < 5; i++) { + case INPUT_PARSED_TAP: + for (int i = 0; i < INPUT_API_MAX_JOYSTICK_INPUTS; i++) { if (event.joystick_mask & (1 << i)) { hold[i] = REST_TAP_HOLD_TICKS; } @@ -545,29 +69,31 @@ static void apply_joystick_event(const ParsedEvent &event) } } -static void apply_keyboard_event(const ParsedEvent &event) +static void apply_keyboard_event(const InputParsedEvent &event) { + const InputKeyboardMapEntry *keyboard_map = input_api_keyboard_map(); + for (int i = 0; i < event.keyboard_count; i++) { - const KeyboardMapEntry &entry = keyboard_map[event.keyboard_index[i]]; + const InputKeyboardMapEntry &entry = keyboard_map[event.keyboard_index[i]]; if (entry.restore) { system_usb_keyboard.restTapRestore(REST_TAP_HOLD_TICKS); continue; } switch (event.transition) { - case PARSED_PRESS: + case INPUT_PARSED_PRESS: system_usb_keyboard.restPress(entry.row, entry.col); break; - case PARSED_RELEASE: + case INPUT_PARSED_RELEASE: system_usb_keyboard.restRelease(entry.row, entry.col); break; - case PARSED_TAP: + case INPUT_PARSED_TAP: system_usb_keyboard.restTap(entry.row, entry.col, REST_TAP_HOLD_TICKS); break; } } } -static void apply_batch(const ParsedEvent *events, int event_count, int pace_ms) +static void apply_batch(const InputParsedEvent *events, int event_count, int pace_ms) { TickType_t delay_ticks = 0; if (pace_ms > 0) { @@ -578,13 +104,13 @@ static void apply_batch(const ParsedEvent *events, int event_count, int pace_ms) } for (int i = 0; i < event_count; i++) { switch (events[i].kind) { - case PARSED_KEYBOARD: + case INPUT_PARSED_KEYBOARD: apply_keyboard_event(events[i]); break; - case PARSED_JOYSTICK: + case INPUT_PARSED_JOYSTICK: apply_joystick_event(events[i]); break; - case PARSED_RELEASE_ALL: + case INPUT_PARSED_RELEASE_ALL: system_usb_keyboard.restReleaseAll(); JoystickOutput::instance().releaseAllRest(); break; @@ -598,7 +124,9 @@ static void apply_batch(const ParsedEvent *events, int event_count, int pace_ms) static void emit_joystick_inputs(JSON_Object *obj, uint8_t active_low) { JSON_List *inputs = JSON::List(); - for (int i = 0; i < joystick_map_count(); i++) { + const InputJoystickMapEntry *joystick_map = input_api_joystick_map(); + + for (int i = 0; i < input_api_joystick_map_count(); i++) { uint8_t bit = (1 << joystick_map[i].bit); if (!(active_low & bit)) { inputs->add(joystick_map[i].name); @@ -617,9 +145,10 @@ static void emit_state_snapshot(ResponseWrapper *resp) system_usb_keyboard.restSnapshot(matrix, restore); JoystickOutput::instance().snapshot(joy1, joy2); + const InputKeyboardMapEntry *keyboard_map = input_api_keyboard_map(); JSON_List *keyboard_inputs = JSON::List(); - for (int i = 0; i < keyboard_map_count(); i++) { - const KeyboardMapEntry &entry = keyboard_map[i]; + for (int i = 0; i < input_api_keyboard_map_count(); i++) { + const InputKeyboardMapEntry &entry = keyboard_map[i]; if (entry.restore) { if (restore) { keyboard_inputs->add(entry.name); @@ -730,27 +259,25 @@ API_CALL(POST, machine, input, &attachment_writer, ARRAY( { })) return; } - static ParsedEvent events[64]; + static InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; int pace_ms = 0; + int error_index = -1; + char err[INPUT_API_ERROR_SIZE]; SemaphoreHandle_t mutex = input_mutex(); xSemaphoreTake(mutex, portMAX_DELAY); - if (!validate_batch(resp, obj, events, event_count, pace_ms)) { + if (!input_api_validate_batch(obj, events, event_count, pace_ms, error_index, err, sizeof(err))) { xSemaphoreGive(mutex); delete obj; free(text); + if (error_index >= 0) { + resp->error("events[%d]: %s", error_index, err); + } else { + resp->error("%s", err); + } resp->json_response(HTTP_BAD_REQUEST); return; } - if (batch_uses_unsupported_port2(events, event_count)) { - xSemaphoreGive(mutex); - delete obj; - free(text); - resp->error(INPUT_JOYSTICK_PORT2_ERROR); - resp->json_response(HTTP_NOT_IMPLEMENTED); - return; - } - apply_batch(events, event_count, pace_ms); emit_state_snapshot(resp); xSemaphoreGive(mutex); diff --git a/software/api/tests/Makefile b/software/api/tests/Makefile new file mode 100644 index 000000000..1ad5973e9 --- /dev/null +++ b/software/api/tests/Makefile @@ -0,0 +1,11 @@ +CXX ?= g++ +CXXFLAGS := -std=c++14 -g -I.. -I../../components -I../../system -I../../io/stream -I../../io/usb/tests +STATE_TEST_FLAGS := $(CXXFLAGS) -DNO_FILE_ACCESS -I../../io/c64 + +all: input-api input-api-state + +input-api: + $(CXX) $(CXXFLAGS) input_api_validation_test.cpp ../json.cc ../../components/mystring.cc ../../system/small_printf.cc ../../io/usb/tests/host_test_main.cpp -lpthread -o inputApiValidationTest && ./inputApiValidationTest + +input-api-state: + $(CXX) $(STATE_TEST_FLAGS) input_api_state_test.cpp ../../io/usb/keyboard_usb.cc ../../io/c64/joystick_output.cc ../../io/usb/tests/host_test_main.cpp -lpthread -o inputApiStateTest && ./inputApiStateTest diff --git a/software/api/tests/input_api_state_test.cpp b/software/api/tests/input_api_state_test.cpp new file mode 100644 index 000000000..006deab13 --- /dev/null +++ b/software/api/tests/input_api_state_test.cpp @@ -0,0 +1,151 @@ +#include "../../io/usb/tests/host_test/host_test.h" +#include "../../io/usb/keyboard_usb.h" +#include "../../io/c64/joystick_output.h" +#include "../input_api.h" + +namespace { + +const InputKeyboardMapEntry *find_keyboard_entry(const char *name) +{ + const InputKeyboardMapEntry *keyboard_map = input_api_keyboard_map(); + for (int i = 0; i < input_api_keyboard_map_count(); i++) { + if (strcmp(keyboard_map[i].name, name) == 0) { + return &keyboard_map[i]; + } + } + return 0; +} + +bool key_active(const uint8_t matrix[8], const char *name) +{ + const InputKeyboardMapEntry *entry = find_keyboard_entry(name); + if (!entry) { + return false; + } + return (matrix[entry->row] & (1 << entry->col)) != 0; +} + +void reset_joystick_output(void) +{ + JoystickOutput::instance().setUsbPort1(0x1F); + JoystickOutput::instance().releaseAllRest(); +} + +} // namespace + +TEST(RestKeyboardStateTest, TapOverlayAutoReleasesWithoutClearingPersistentKey) +{ + Keyboard_USB keyboard; + uint8_t matrix[8]; + bool restore = false; + + const InputKeyboardMapEntry *shift = find_keyboard_entry("left_shift"); + const InputKeyboardMapEntry *a = find_keyboard_entry("a"); + ASSERT_TRUE(shift != 0); + ASSERT_TRUE(a != 0); + + keyboard.restPress(shift->row, shift->col); + keyboard.restTap(a->row, a->col, 1); + keyboard.restSnapshot(matrix, restore); + EXPECT_TRUE(key_active(matrix, "left_shift")); + EXPECT_TRUE(key_active(matrix, "a")); + EXPECT_FALSE(restore); + + keyboard.tickRestOverlays(); + keyboard.restSnapshot(matrix, restore); + EXPECT_TRUE(key_active(matrix, "left_shift")); + EXPECT_FALSE(key_active(matrix, "a")); + EXPECT_FALSE(restore); +} + +TEST(RestKeyboardStateTest, ReleaseDoesNotCancelTapOverlay) +{ + Keyboard_USB keyboard; + uint8_t matrix[8]; + bool restore = false; + + const InputKeyboardMapEntry *a = find_keyboard_entry("a"); + ASSERT_TRUE(a != 0); + + keyboard.restTap(a->row, a->col, 1); + keyboard.restRelease(a->row, a->col); + keyboard.restSnapshot(matrix, restore); + EXPECT_TRUE(key_active(matrix, "a")); + + keyboard.tickRestOverlays(); + keyboard.restSnapshot(matrix, restore); + EXPECT_FALSE(key_active(matrix, "a")); +} + +TEST(RestKeyboardStateTest, RestoreTapIsTemporaryAndNotPersistent) +{ + Keyboard_USB keyboard; + uint8_t matrix[8]; + bool restore = false; + bool persistent_restore = true; + + keyboard.restTapRestore(1); + keyboard.restSnapshot(matrix, restore); + EXPECT_TRUE(restore); + keyboard.restPersistentSnapshot(matrix, persistent_restore); + EXPECT_FALSE(persistent_restore); + + keyboard.tickRestOverlays(); + keyboard.restSnapshot(matrix, restore); + EXPECT_FALSE(restore); +} + +TEST(RestKeyboardStateTest, ReleaseAllClearsPersistentAndOverlayState) +{ + Keyboard_USB keyboard; + uint8_t matrix[8]; + bool restore = false; + + const InputKeyboardMapEntry *shift = find_keyboard_entry("left_shift"); + const InputKeyboardMapEntry *a = find_keyboard_entry("a"); + ASSERT_TRUE(shift != 0); + ASSERT_TRUE(a != 0); + + keyboard.restPress(shift->row, shift->col); + keyboard.restTap(a->row, a->col, 2); + keyboard.restPressRestore(); + keyboard.restReleaseAll(); + keyboard.restSnapshot(matrix, restore); + EXPECT_FALSE(key_active(matrix, "left_shift")); + EXPECT_FALSE(key_active(matrix, "a")); + EXPECT_FALSE(restore); +} + +TEST(RestJoystickStateTest, TapOverlayAutoReleasesWithoutClearingPersistentInput) +{ + reset_joystick_output(); + uint8_t hold[5] = { 0, 0, 0, 0, 1 }; + uint8_t port1 = 0; + uint8_t port2 = 0; + + JoystickOutput::instance().setRestPort2Persistent(0x1E); + JoystickOutput::instance().armRestPort2Overlay(0x0F, hold); + JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x1F, port1); + EXPECT_EQ(0x0E, port2); + + JoystickOutput::instance().tickOverlays(); + JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x1E, port2); +} + +TEST(RestJoystickStateTest, ReleaseAllClearsBothPorts) +{ + reset_joystick_output(); + uint8_t hold[5] = { 1, 0, 0, 0, 0 }; + uint8_t port1 = 0; + uint8_t port2 = 0; + + JoystickOutput::instance().setRestPort1Persistent(0x0F); + JoystickOutput::instance().setRestPort2Persistent(0x1E); + JoystickOutput::instance().armRestPort1Overlay(0x1E, hold); + JoystickOutput::instance().releaseAllRest(); + JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x1F, port1); + EXPECT_EQ(0x1F, port2); +} diff --git a/software/api/tests/input_api_validation_test.cpp b/software/api/tests/input_api_validation_test.cpp new file mode 100644 index 000000000..7c09f53c9 --- /dev/null +++ b/software/api/tests/input_api_validation_test.cpp @@ -0,0 +1,276 @@ +#include "../../io/usb/tests/host_test/host_test.h" +#include "../input_api.h" + +#include +#include + +extern "C" void outbyte(int) +{ +} + +namespace { + +JSON_List *make_string_list(std::initializer_list values) +{ + JSON_List *list = JSON::List(); + for (std::initializer_list::const_iterator it = values.begin(); it != values.end(); ++it) { + list->add(*it); + } + return list; +} + +JSON_Object *make_keyboard_event(const char *transition, std::initializer_list inputs) +{ + return JSON::Obj() + ->add("kind", "keyboard") + ->add("inputs", make_string_list(inputs)) + ->add("transition", transition); +} + +JSON_Object *make_joystick_event(int port, const char *transition, std::initializer_list inputs) +{ + return JSON::Obj() + ->add("kind", "joystick") + ->add("port", port) + ->add("inputs", make_string_list(inputs)) + ->add("transition", transition); +} + +JSON_Object *make_release_all_event(void) +{ + return JSON::Obj()->add("kind", "release_all"); +} + +JSON_Object *make_root(JSON_List *events, int pace_ms = 0, bool include_pace = false) +{ + JSON_Object *root = JSON::Obj()->add("events", events); + if (include_pace) { + root->add("pace_ms", pace_ms); + } + return root; +} + +bool validate(JSON *root, InputParsedEvent events[INPUT_API_MAX_EVENTS], int &event_count, int &pace_ms, + int &error_index, std::string &err) +{ + char buffer[INPUT_API_ERROR_SIZE] = { 0 }; + bool ok = input_api_validate_batch(root, events, event_count, pace_ms, error_index, buffer, sizeof(buffer)); + err = buffer; + return ok; +} + +JSON *parse_json_text(const char *text, int &tokens) +{ + size_t length = strlen(text); + char *copy = new char[length + 1]; + memcpy(copy, text, length + 1); + JSON *root = NULL; + tokens = convert_text_to_json_objects(copy, length, 1024, &root); + delete[] copy; + return root; +} + +} // namespace + +TEST(InputApiValidationTest, ParsesValidBatchAndPreservesEventDetails) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *root = make_root( + JSON::List() + ->add(make_keyboard_event("press", { "a", "left_shift" })) + ->add(make_joystick_event(2, "tap", { "up", "fire" })) + ->add(make_release_all_event()), + 25, true); + + ASSERT_TRUE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(3, event_count); + EXPECT_EQ(25, pace_ms); + EXPECT_EQ(-1, error_index); + EXPECT_EQ(INPUT_PARSED_KEYBOARD, events[0].kind); + EXPECT_EQ(INPUT_PARSED_PRESS, events[0].transition); + EXPECT_EQ(2, events[0].keyboard_count); + EXPECT_EQ(input_api_find_keyboard_input("a"), events[0].keyboard_index[0]); + EXPECT_EQ(input_api_find_keyboard_input("left_shift"), events[0].keyboard_index[1]); + EXPECT_EQ(INPUT_PARSED_JOYSTICK, events[1].kind); + EXPECT_EQ(2, events[1].port); + EXPECT_EQ(INPUT_PARSED_TAP, events[1].transition); + EXPECT_EQ((1 << 0) | (1 << 4), events[1].joystick_mask); + EXPECT_EQ(INPUT_PARSED_RELEASE_ALL, events[2].kind); + + delete root; +} + +TEST(InputApiValidationTest, RejectsUnknownRootField) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *root = make_root(JSON::List()->add(make_release_all_event())); + ((JSON_Object *)root)->add("extra", true); + + EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(-1, error_index); + EXPECT_EQ(std::string("Unknown field `extra`."), err); + + delete root; +} + +TEST(InputApiValidationTest, RejectsInvalidPaceValue) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *root = make_root(JSON::List()->add(make_release_all_event())); + ((JSON_Object *)root)->add("pace_ms", 1001); + + EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(std::string("`pace_ms` must be between 0 and 1000."), err); + + delete root; +} + +TEST(InputApiValidationTest, RejectsLateInvalidEventAndReportsIndex) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *root = make_root( + JSON::List() + ->add(make_release_all_event()) + ->add(make_keyboard_event("press", { "left_shift" })) + ->add(make_joystick_event(3, "press", { "up" }))); + + EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(2, error_index); + EXPECT_EQ(std::string("`port` must be 1 or 2."), err); + + delete root; +} + +TEST(InputApiValidationTest, AcceptsEveryDocumentedKeyboardInput) +{ + const InputKeyboardMapEntry *keyboard_map = input_api_keyboard_map(); + for (int i = 0; i < input_api_keyboard_map_count(); i++) { + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + const char *transition = keyboard_map[i].restore ? "tap" : "press"; + + JSON *root = make_root(JSON::List()->add(make_keyboard_event(transition, { keyboard_map[i].name }))); + + EXPECT_TRUE(validate(root, events, event_count, pace_ms, error_index, err)); + delete root; + } +} + +TEST(InputApiValidationTest, RejectsRestoreOutsideTapAloneRule) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *root = make_root(JSON::List()->add(make_keyboard_event("press", { "restore" }))); + + EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(0, error_index); + EXPECT_TRUE(err.find("`restore` must appear alone") != std::string::npos); + + delete root; +} + +TEST(InputApiValidationTest, RejectsKeyboardDuplicateInputs) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *root = make_root(JSON::List()->add(make_keyboard_event("tap", { "a", "a" }))); + + EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(std::string("`a` appears more than once in `inputs`."), err); + + delete root; +} + +TEST(InputApiValidationTest, AcceptsEveryDocumentedJoystickInput) +{ + const InputJoystickMapEntry *joystick_map = input_api_joystick_map(); + for (int i = 0; i < input_api_joystick_map_count(); i++) { + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *root = make_root(JSON::List()->add(make_joystick_event(1, "press", { joystick_map[i].name }))); + + EXPECT_TRUE(validate(root, events, event_count, pace_ms, error_index, err)); + delete root; + } +} + +TEST(InputApiValidationTest, RejectsJoystickDuplicateAndUnknownInputs) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *duplicate = make_root(JSON::List()->add(make_joystick_event(1, "tap", { "up", "up" }))); + EXPECT_FALSE(validate(duplicate, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(std::string("`up` appears more than once in `inputs`."), err); + delete duplicate; + + JSON *unknown = make_root(JSON::List()->add(make_joystick_event(1, "tap", { "jump" }))); + EXPECT_FALSE(validate(unknown, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(std::string("`jump` is not a valid joystick input."), err); + delete unknown; +} + +TEST(InputApiValidationTest, RejectsReleaseAllExtraFields) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON_Object *event = make_release_all_event(); + event->add("port", 1); + JSON *root = make_root(JSON::List()->add(event)); + + EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_EQ(std::string("Unknown field `port`."), err); + + delete root; +} + +TEST(InputApiValidationTest, RejectsMalformedJsonBeforeValidation) +{ + int tokens = 0; + JSON *root = parse_json_text("{\"events\":[", tokens); + + EXPECT_TRUE(tokens < 0); + EXPECT_EQ((JSON *)0, root); +} diff --git a/software/io/c64/joystick_output.cc b/software/io/c64/joystick_output.cc index 82ccdb59b..4e9956527 100644 --- a/software/io/c64/joystick_output.cc +++ b/software/io/c64/joystick_output.cc @@ -47,11 +47,6 @@ JoystickOutput &JoystickOutput :: instance() return output; } -bool JoystickOutput :: port2Supported(void) -{ - return true; -} - void JoystickOutput :: apply(void) { #if U64 diff --git a/software/io/c64/joystick_output.h b/software/io/c64/joystick_output.h index 7ef8f7996..aa1efd7bb 100644 --- a/software/io/c64/joystick_output.h +++ b/software/io/c64/joystick_output.h @@ -34,7 +34,6 @@ class JoystickOutput void snapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const; - static bool port2Supported(void); }; #endif /* JOYSTICK_OUTPUT_H */ diff --git a/software/io/usb/tests/Makefile b/software/io/usb/tests/Makefile index 868dfb8d6..71928bd90 100644 --- a/software/io/usb/tests/Makefile +++ b/software/io/usb/tests/Makefile @@ -1,5 +1,5 @@ CXX ?= g++ -CXXFLAGS := -std=c++14 -g -I. -I../../../components -I../../../system +CXXFLAGS := -std=c++14 -g -I. -I../../../components -I../../../system -I../../../io/stream KEYBOARD_TEST_FLAGS := $(CXXFLAGS) -I../../c64 -DNO_FILE_ACCESS all: mouse keyboard keyboard-queue selection diff --git a/tools/api/input_test.py b/tools/api/input_test.py index c7ce5cf6a..ba761378e 100755 --- a/tools/api/input_test.py +++ b/tools/api/input_test.py @@ -13,29 +13,6 @@ CHECK_COUNT = 0 READY_SCREEN_CODES = bytes((0x12, 0x05, 0x01, 0x04, 0x19, 0x2E)) -KEY_HOLD_SECONDS = 0.12 -KEY_GAP_SECONDS = 0.03 -JOYSTICK_PROBE_ADDR = 0xC000 -JOYSTICK_PROBE_SCREEN_ADDR = 0x0400 -JOYSTICK_PROBE_CODE = bytes( - ( - 0xAD, - 0x00, - 0xDC, - 0x8D, - 0x00, - 0x04, - 0xAD, - 0x01, - 0xDC, - 0x8D, - 0x01, - 0x04, - 0x4C, - 0x00, - 0xC0, - ) -) KEYBOARD_MATRIX: Dict[str, Tuple[int, int]] = { "inst_del": (0, 0), "return": (0, 1), @@ -275,101 +252,12 @@ def wait_for_basic_ready(session: RestInputSession) -> None: raise Failure("BASIC READY prompt not visible; device may be running a cartridge") -def screen_code(text: str) -> bytes: - result = bytearray() - for ch in text: - if "A" <= ch <= "Z": - result.append(ord(ch) - 64) - else: - result.append(ord(ch)) - return bytes(result) - - -def cursor_address(session: RestInputSession) -> int: - pointer = session.read_memory(0x00D1, 3) - return pointer[0] + (pointer[1] << 8) + pointer[2] - - def reset_to_basic(session: RestInputSession) -> None: session.reset() wait_for_basic_ready(session) session.post_events([{"kind": "release_all"}]) -def text_to_key_inputs(text: str) -> List[List[str]]: - result: List[List[str]] = [] - for ch in text: - if "a" <= ch <= "z": - result.append([ch]) - elif "A" <= ch <= "Z": - result.append([ch.lower()]) - elif "0" <= ch <= "9": - result.append([ch]) - elif ch == " ": - result.append(["space"]) - elif ch == ",": - result.append(["comma"]) - elif ch == ":": - result.append(["colon"]) - elif ch == "(": - result.append(["left_shift", "8"]) - elif ch == ")": - result.append(["left_shift", "9"]) - elif ch in ("\r", "\n"): - result.append(["return"]) - else: - raise Failure(f"No C64 key mapping for {ch!r}") - return result - - -def send_c64_keys(session: RestInputSession, sequence: List[List[str]]) -> None: - for inputs in sequence: - session.post_events([{"kind": "keyboard", "inputs": inputs, "transition": "press"}]) - time.sleep(KEY_HOLD_SECONDS) - session.post_events([{"kind": "keyboard", "inputs": inputs, "transition": "release"}]) - time.sleep(KEY_GAP_SECONDS) - - -def type_c64_text(session: RestInputSession, text: str) -> None: - send_c64_keys(session, text_to_key_inputs(text)) - - -def basic_input_line_start(screen: bytes) -> int: - ready = screen.find(READY_SCREEN_CODES) - if ready < 0: - raise Failure("BASIC READY prompt not visible.") - return ((ready // 40) + 1) * 40 - - -def wait_for_screen_bytes(session: RestInputSession, address: int, expected: bytes, timeout: float = 5.0) -> None: - deadline = time.time() + timeout - actual = b"" - while time.time() < deadline: - actual = session.read_memory(address, len(expected)) - if actual == expected: - return - time.sleep(0.1) - raise Failure(f"Expected {expected.hex().upper()} at ${address:04X}, got {actual.hex().upper()}") - - -def assert_c64_typed_text(session: RestInputSession, text: str) -> None: - screen = session.read_memory(0x0400, 1000) - address = 0x0400 + basic_input_line_start(screen) - type_c64_text(session, text) - wait_for_screen_bytes(session, address, screen_code(text.upper())) - - -def start_joystick_probe(session: RestInputSession) -> None: - reset_to_basic(session) - session.write_memory(JOYSTICK_PROBE_ADDR, JOYSTICK_PROBE_CODE) - type_c64_text(session, "SYS49152\n") - wait_for_screen_bytes(session, JOYSTICK_PROBE_SCREEN_ADDR, b"\xFF\xFF") - - -def assert_c64_joystick_probe(session: RestInputSession, port1: int, port2: int) -> None: - wait_for_screen_bytes(session, JOYSTICK_PROBE_SCREEN_ADDR, bytes(((port2 & 0x1F) | 0xE0, (port1 & 0x1F) | 0xE0))) - - def assert_joystick_ports(session: RestInputSession, port1: int, port2: int) -> None: actual_port1, actual_port2 = read_joystick_cia(session) if (actual_port1 & 0x1F) != port1 or (actual_port2 & 0x1F) != port2: @@ -528,22 +416,22 @@ def run_keyboard_tests(session: RestInputSession) -> None: session.post_events([{"kind": "release_all"}]) assert_state_empty(session) - with check("paced keyboard tap batch types consecutive characters"): + with check("paced keyboard batch applies multiple presses atomically"): reset_to_basic(session) - screen = session.read_memory(0x0400, 1000) - address = 0x0400 + basic_input_line_start(screen) - session.json_request( + body = session.json_request( "POST", "/v1/machine:input", payload={ "events": [ - {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["b"], "transition": "tap"}, + {"kind": "keyboard", "inputs": ["a"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, ], "pace_ms": 25, }, ) - wait_for_screen_bytes(session, address, screen_code("AB")) + if body.get("keyboard", {}).get("inputs") != ["a", "left_shift"]: + raise Failure(f"Unexpected paced batch keyboard state: {body}") + assert_keyboard_matrix_inputs(session, ["a", "left_shift"]) session.post_events([{"kind": "release_all"}]) assert_state_empty(session) diff --git a/tools/api/input_tool.py b/tools/api/input_tool.py index d8e5e6605..3a3aff878 100755 --- a/tools/api/input_tool.py +++ b/tools/api/input_tool.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import argparse -from collections import deque import glob +import http.client +import json +import math import os import select import struct @@ -53,8 +55,13 @@ "D": "left", } MAX_BATCH_EVENTS = 64 -BATCH_IDLE_SECONDS = float(os.environ.get("U64_INPUT_BATCH_IDLE", "0.02")) -BATCH_PACE_MS = int(os.environ.get("U64_INPUT_PACE_MS", "20")) +BATCH_PACE_MS = int(os.environ.get("U64_INPUT_PACE_MS", "10")) +KEY_REPEAT_HZ = float(os.environ.get("U64_INPUT_REPEAT_HZ", "50.0")) +KEY_REPEAT_INTERVAL = 1.0 / KEY_REPEAT_HZ if KEY_REPEAT_HZ > 0 else 0.05 +KEY_REPEAT_PACE_MS = int(os.environ.get("U64_INPUT_REPEAT_PACE_MS", "20")) +KEY_REPEAT_MAX_BURST = max(1, int(os.environ.get("U64_INPUT_REPEAT_MAX_BURST", "6"))) +KEY_REPEAT_STALE_SECONDS = float(os.environ.get("U64_INPUT_REPEAT_STALE", "0.25")) +KEY_REPEAT_CONFIRM_COUNT = max(2, int(os.environ.get("U64_INPUT_REPEAT_CONFIRM", "3"))) GAMEPAD_AXIS_THRESHOLD = int(os.environ.get("U64_INPUT_GAMEPAD_AXIS_THRESHOLD", "12000")) GAMEPAD_FIRE_REPEAT_HZ = float(os.environ.get("U64_INPUT_GAMEPAD_FIRE_REPEAT_HZ", "10.0")) QUIT_SEQUENCES = ("\x03", "\x04") @@ -97,12 +104,6 @@ def read_key_sequence(timeout: float = 0.05) -> str: return seq -def read_ready_key_sequence(timeout: float = BATCH_IDLE_SECONDS) -> Optional[str]: - if not select.select([sys.stdin], [], [], timeout)[0]: - return None - return read_key_sequence() - - def keyboard_event(inputs: List[str]) -> Dict[str, object]: return {"kind": "keyboard", "inputs": inputs, "transition": "tap"} @@ -115,6 +116,25 @@ def release_all_event() -> Dict[str, str]: return {"kind": "release_all"} +def event_signature(event: Dict[str, object]) -> Tuple[object, object, Tuple[object, ...], object]: + return ( + event.get("kind"), + event.get("port"), + tuple(event.get("inputs", [])), # type: ignore[arg-type] + event.get("transition"), + ) + + +def cursor_keyboard_event(direction: str) -> Dict[str, object]: + if direction == "up": + return keyboard_event(["left_shift", "cursor_up_down"]) + if direction == "down": + return keyboard_event(["cursor_up_down"]) + if direction == "left": + return keyboard_event(["left_shift", "cursor_left_right"]) + return keyboard_event(["cursor_left_right"]) + + class GamepadState: def __init__( self, @@ -266,28 +286,166 @@ def read_events(self, now: float) -> List[Dict[str, object]]: return events -class BufferedSequenceReader: - def __init__( - self, - read_sequence: Callable[[], str], - read_ready_sequence: Callable[[], Optional[str]], - ) -> None: - self.read_sequence = read_sequence - self.read_ready_sequence = read_ready_sequence - self.buffer: deque[str] = deque() +class InteractiveRestClient: + def __init__(self, session: RestInputSession) -> None: + self.host = session.host + self.password = session.password + self.timeout = session.timeout + self._connection: Optional[http.client.HTTPConnection] = None + self.last_request_duration = max(KEY_REPEAT_INTERVAL, 0.08) + + def close(self) -> None: + if self._connection is None: + return + try: + self._connection.close() + except Exception: + pass + self._connection = None + + def _connect(self) -> http.client.HTTPConnection: + if self._connection is None: + self._connection = http.client.HTTPConnection(self.host, timeout=self.timeout) + return self._connection + + def post_events(self, events: List[Dict[str, object]], pace_ms: Optional[int] = None) -> Dict[str, object]: + payload: Dict[str, object] = {"events": events} + effective_pace_ms = pace_ms + if effective_pace_ms is None and len(events) > 1 and BATCH_PACE_MS > 0: + effective_pace_ms = BATCH_PACE_MS + if effective_pace_ms is not None: + payload["pace_ms"] = effective_pace_ms + body = json.dumps(payload).encode("utf-8") + headers: Dict[str, str] = {"Content-Type": "application/json"} + if self.password: + headers["X-Password"] = self.password + for attempt in range(2): + connection = self._connect() + started_at = time.monotonic() + try: + connection.request("POST", "/v1/machine:input", body=body, headers=headers) + response = connection.getresponse() + data = response.read() + self.last_request_duration = max(time.monotonic() - started_at, KEY_REPEAT_INTERVAL) + if response.status >= 400: + self.close() + text = data.decode("utf-8", errors="replace").strip() + raise Failure(f"REST input POST failed with HTTP {response.status}: {text}") + if not data: + return {} + return json.loads(data.decode("utf-8")) + except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc: + self.close() + if attempt == 0: + continue + raise Failure(f"Interactive REST request failed: {format_exception(exc)}") from exc + raise Failure("Interactive REST request failed.") + + +class RepeatState: + def __init__(self) -> None: + self.sequence: Optional[str] = None + self.signature: Optional[Tuple[object, object, Tuple[object, ...], object]] = None + self.event: Optional[Dict[str, object]] = None + self.count = 0 + self.confirmed = False + self.last_seen_at = 0.0 + self.next_emit_at = 0.0 + + def clear(self) -> None: + self.sequence = None + self.signature = None + self.event = None + self.count = 0 + self.confirmed = False + self.last_seen_at = 0.0 + self.next_emit_at = 0.0 + + def _active(self) -> bool: + return self.event is not None and self.sequence is not None and self.signature is not None + + def timeout(self, now: float) -> Optional[float]: + if not self._active(): + return None + stale_deadline = self.last_seen_at + KEY_REPEAT_STALE_SECONDS + if self.confirmed: + deadline = min(stale_deadline, self.next_emit_at) + else: + deadline = stale_deadline + return max(0.0, deadline - now) + + def observe(self, sequence: str, event: Dict[str, object], now: float) -> bool: + repeatable = event.get("transition") == "tap" and event.get("kind") in ("keyboard", "joystick") + if not repeatable: + self.clear() + return False + + signature = event_signature(event) + if self.sequence == sequence and self.signature == signature: + self.count += 1 + self.last_seen_at = now + if self.confirmed: + return True + if self.count >= KEY_REPEAT_CONFIRM_COUNT: + self.confirmed = True + self.next_emit_at = now + KEY_REPEAT_INTERVAL + return True + return False + + self.sequence = sequence + self.signature = signature + self.event = event + self.count = 1 + self.confirmed = False + self.last_seen_at = now + self.next_emit_at = 0.0 + return False + + def poll(self, now: float) -> Optional[Dict[str, object]]: + if not self._active(): + return None + if (now - self.last_seen_at) > KEY_REPEAT_STALE_SECONDS: + self.clear() + return None + if not self.confirmed or now < self.next_emit_at or self.event is None: + return None + while self.next_emit_at <= now: + self.next_emit_at += KEY_REPEAT_INTERVAL + return self.event + + +def combine_timeout(*timeouts: Optional[float]) -> Optional[float]: + values = [timeout for timeout in timeouts if timeout is not None] + if not values: + return None + return min(values) + + +def drain_stdin_sequences(stdin_fd: int) -> List[str]: + sequences = [read_key_sequence()] + while select.select([stdin_fd], [], [], 0)[0]: + sequences.append(read_key_sequence()) + return sequences + - def get(self) -> str: - if self.buffer: - return self.buffer.popleft() - return self.read_sequence() +def flush_event_batch(client: InteractiveRestClient, events: List[Dict[str, object]]) -> None: + while events: + chunk = events[:MAX_BATCH_EVENTS] + client.post_events(chunk) + del events[:MAX_BATCH_EVENTS] - def get_ready(self) -> Optional[str]: - if self.buffer: - return self.buffer.popleft() - return self.read_ready_sequence() - def push(self, seq: str) -> None: - self.buffer.appendleft(seq) +def flush_repeat_event(client: InteractiveRestClient, event: Dict[str, object]) -> None: + if event.get("kind") != "keyboard" or event.get("transition") != "tap": + client.post_events([event]) + return + + burst = max(1, min(KEY_REPEAT_MAX_BURST, int(math.ceil(client.last_request_duration * KEY_REPEAT_HZ)))) + if burst == 1: + client.post_events([event]) + return + + client.post_events([event] * burst, pace_ms=KEY_REPEAT_PACE_MS) def translate_sequence(seq: str, joystick_port: int) -> Optional[Dict[str, object]]: @@ -295,12 +453,16 @@ def translate_sequence(seq: str, joystick_port: int) -> Optional[Dict[str, objec return release_all_event() if seq == "\n": return joystick_event(joystick_port, ["fire"]) + if seq == "\x1b[3~": + return keyboard_event(["inst_del"]) if seq.startswith("\x1b["): if seq in ("\x1b[13;5u", "\x1b[27;5;13~"): return joystick_event(joystick_port, ["fire"]) - if seq[-1:] in ARROW_KEYS and (";5" in seq or seq.startswith("\x1b[5")): - return joystick_event(joystick_port, [ARROW_KEYS[seq[-1]]]) - return None + if (seq.startswith("\x1b[") or seq.startswith("\x1bO")) and seq[-1:] in ARROW_KEYS: + direction = ARROW_KEYS[seq[-1]] + if ";5" in seq: + return joystick_event(joystick_port, [direction]) + return cursor_keyboard_event(direction) if len(seq) != 1: return None if "a" <= seq <= "z" or "0" <= seq <= "9": @@ -367,8 +529,9 @@ def open_gamepad(device_path: Optional[str], joystick_port: int) -> Optional[Gam def print_mapping_overview(host: str, joystick_port: int, gamepad: Optional[GamepadDevice]) -> None: print(f"REST input tool -> {host}") - print("keys: text=C64 keys; Ctrl+arrows=joy; Ctrl+Enter/Ctrl+J=fire; Esc=release_all; Ctrl+C/Ctrl+D=quit") - print(f"paced batch mode: {BATCH_PACE_MS} ms between queued events") + print("keys: text=C64 keys; arrows=C64 cursor; Ctrl+arrows=joy; Ctrl+Enter/Ctrl+J=fire; Esc=release_all; Ctrl+C/Ctrl+D=quit") + print(f"interactive repeat: {KEY_REPEAT_HZ:.0f}/s without client backlog") + print(f"paced multi-key batches: {BATCH_PACE_MS} ms between batched events") print(f"joystick port: {joystick_port}") if gamepad: print(f"gamepad: {gamepad.path} (left stick, right stick, d-pad -> movement; A=fire; B=repeat fire)") @@ -376,13 +539,6 @@ def print_mapping_overview(host: str, joystick_port: int, gamepad: Optional[Game print("gamepad: not detected") -def post_interactive_events(session: RestInputSession, events: List[Dict[str, object]]) -> None: - payload: Dict[str, object] = {"events": events} - if len(events) > 1 and BATCH_PACE_MS > 0: - payload["pace_ms"] = BATCH_PACE_MS - session.json_request("POST", "/v1/machine:input", payload=payload) - - def classify_sequence(seq: str, joystick_port: int) -> Tuple[str, Optional[Dict[str, object]]]: if seq in QUIT_SEQUENCES: return "quit", None @@ -394,110 +550,112 @@ def classify_sequence(seq: str, joystick_port: int) -> Tuple[str, Optional[Dict[ return "event", event -def collect_event_batch( - first_event: Dict[str, object], - joystick_port: int, - reader: BufferedSequenceReader, -) -> Tuple[List[Dict[str, object]], Optional[str]]: - events = [first_event] - first_signature = ( - first_event.get("kind"), - tuple(first_event.get("inputs", [])), # type: ignore[arg-type] - first_event.get("transition"), - ) - while len(events) < MAX_BATCH_EVENTS: - seq = reader.get_ready() - if seq is None: - break - action, event = classify_sequence(seq, joystick_port) - if action == "ignore": - continue - if action == "event" and event is not None: - event_signature = ( - event.get("kind"), - tuple(event.get("inputs", [])), # type: ignore[arg-type] - event.get("transition"), - ) - if event_signature == first_signature and event.get("kind") == "keyboard": - reader.push(seq) - break - events.append(event) - continue - reader.push(seq) - return events, None - return events, None - - def handle_interactive_sequence( - session: RestInputSession, joystick_port: int, seq: str, - reader: BufferedSequenceReader, -) -> bool: + repeat_state: RepeatState, + now: float, +) -> Tuple[Optional[str], List[Dict[str, object]]]: action, event = classify_sequence(seq, joystick_port) if action == "ignore": - return False + return None, [] if action == "quit": - session.post_events([release_all_event()]) - return True + repeat_state.clear() + return "quit", [] if action == "release_all": - session.post_events([release_all_event()]) - return False + repeat_state.clear() + return "release_all", [release_all_event()] if event is None: - return False - events, pending_action = collect_event_batch(event, joystick_port, reader) - post_interactive_events(session, events) - if pending_action == "release_all": - session.post_events([release_all_event()]) - elif pending_action == "quit": - session.post_events([release_all_event()]) - return True - return False + return None, [] + if repeat_state.observe(seq, event, now): + return None, [] + return None, [event] def run_interactive_loop( session: RestInputSession, joystick_port: int, - read_sequence: Callable[[], str], - read_ready_sequence: Callable[[], Optional[str]], ) -> None: - reader = BufferedSequenceReader(read_sequence, read_ready_sequence) - while True: - if handle_interactive_sequence(session, joystick_port, reader.get(), reader): - break + stdin_fd = sys.stdin.fileno() + client = InteractiveRestClient(session) + repeat_state = RepeatState() + try: + while True: + now = time.monotonic() + ready, _, _ = select.select([stdin_fd], [], [], repeat_state.timeout(now)) + now = time.monotonic() + pending_events: List[Dict[str, object]] = [] + if ready: + for seq in drain_stdin_sequences(stdin_fd): + control, events = handle_interactive_sequence(joystick_port, seq, repeat_state, time.monotonic()) + if events: + pending_events.extend(events) + if control == "release_all": + if pending_events: + flush_event_batch(client, pending_events) + pending_events = [] + elif control == "quit": + if pending_events: + flush_event_batch(client, pending_events) + client.post_events([release_all_event()]) + return + repeat_event = repeat_state.poll(time.monotonic()) + if pending_events: + flush_event_batch(client, pending_events) + if repeat_event: + flush_repeat_event(client, repeat_event) + finally: + client.close() def run_interactive_with_gamepad( session: RestInputSession, joystick_port: int, gamepad: GamepadDevice, - read_sequence: Callable[[], str], - read_ready_sequence: Callable[[], Optional[str]], ) -> None: stdin_fd = sys.stdin.fileno() gamepad_fd = gamepad.fileno() - reader = BufferedSequenceReader(read_sequence, read_ready_sequence) - - while True: - now = time.monotonic() - timeout = gamepad.state.repeat_timeout(now) - ready, _, _ = select.select([stdin_fd, gamepad_fd], [], [], timeout) - now = time.monotonic() - if not ready: - repeat_event = gamepad.state.poll_repeat_fire(now) - if repeat_event: - session.post_events([repeat_event]) - continue - if gamepad_fd in ready: - events = gamepad.read_events(now) - repeat_event = gamepad.state.poll_repeat_fire(now) - if repeat_event: - events.append(repeat_event) - if events: - session.post_events(events) - if stdin_fd in ready: - if handle_interactive_sequence(session, joystick_port, reader.get(), reader): - break + client = InteractiveRestClient(session) + repeat_state = RepeatState() + + try: + while True: + now = time.monotonic() + timeout = combine_timeout(repeat_state.timeout(now), gamepad.state.repeat_timeout(now)) + ready, _, _ = select.select([stdin_fd, gamepad_fd], [], [], timeout) + now = time.monotonic() + pending_events: List[Dict[str, object]] = [] + if not ready: + repeat_event = gamepad.state.poll_repeat_fire(now) + if repeat_event: + pending_events.append(repeat_event) + else: + if gamepad_fd in ready: + pending_events.extend(gamepad.read_events(now)) + repeat_event = gamepad.state.poll_repeat_fire(now) + if repeat_event: + pending_events.append(repeat_event) + if stdin_fd in ready: + for seq in drain_stdin_sequences(stdin_fd): + control, events = handle_interactive_sequence(joystick_port, seq, repeat_state, time.monotonic()) + if events: + pending_events.extend(events) + if control == "release_all": + if pending_events: + flush_event_batch(client, pending_events) + pending_events = [] + elif control == "quit": + if pending_events: + flush_event_batch(client, pending_events) + client.post_events([release_all_event()]) + return + repeat_keyboard_event = repeat_state.poll(time.monotonic()) + if pending_events: + flush_event_batch(client, pending_events) + if repeat_keyboard_event: + flush_repeat_event(client, repeat_keyboard_event) + finally: + client.close() def run_interactive(session: RestInputSession, joystick_port: int, gamepad: Optional[GamepadDevice]) -> None: @@ -506,9 +664,9 @@ def run_interactive(session: RestInputSession, joystick_port: int, gamepad: Opti session.post_events([release_all_event()]) with raw_terminal(): if gamepad: - run_interactive_with_gamepad(session, joystick_port, gamepad, read_key_sequence, read_ready_key_sequence) + run_interactive_with_gamepad(session, joystick_port, gamepad) else: - run_interactive_loop(session, joystick_port, read_key_sequence, read_ready_key_sequence) + run_interactive_loop(session, joystick_port) def assert_input_state(session: RestInputSession, keyboard: List[str], joystick1: List[str], joystick2: List[str]) -> None: diff --git a/tools/api/test_input_tool_gamepad.py b/tools/api/test_input_tool_gamepad.py deleted file mode 100644 index 0b20cbbd6..000000000 --- a/tools/api/test_input_tool_gamepad.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import sys -from unittest.mock import patch - - -TOOLS_API = Path(__file__).resolve().parent -if str(TOOLS_API) not in sys.path: - sys.path.insert(0, str(TOOLS_API)) - -import input_tool # noqa: E402 - - -def test_gamepad_state_combines_left_right_sticks_and_dpad_into_one_joystick() -> None: - state = input_tool.GamepadState(port=2, axis_threshold=10000) - - press_left = state.apply_input_event(input_tool.EV_ABS, input_tool.ABS_X, -20000, now=0.0) - press_right = state.apply_input_event(input_tool.EV_ABS, input_tool.ABS_RX, 20000, now=0.0) - press_dpad = state.apply_input_event(input_tool.EV_ABS, input_tool.ABS_HAT0Y, -1, now=0.0) - release_left = state.apply_input_event(input_tool.EV_ABS, input_tool.ABS_X, 0, now=0.0) - - assert press_left == [ - {"kind": "joystick", "port": 2, "inputs": ["left"], "transition": "press"} - ] - assert press_right == [ - {"kind": "joystick", "port": 2, "inputs": ["right"], "transition": "press"} - ] - assert press_dpad == [ - {"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "press"} - ] - assert release_left == [ - {"kind": "joystick", "port": 2, "inputs": ["left"], "transition": "release"} - ] - - -def test_gamepad_a_maps_to_fire_and_b_repeats_fire() -> None: - state = input_tool.GamepadState(port=1, axis_threshold=10000, fire_repeat_hz=10.0) - - fire_press = state.apply_input_event(input_tool.EV_KEY, input_tool.BTN_SOUTH, 1, now=0.0) - fire_release = state.apply_input_event(input_tool.EV_KEY, input_tool.BTN_SOUTH, 0, now=0.0) - b_initial = state.apply_input_event(input_tool.EV_KEY, input_tool.BTN_EAST, 1, now=1.0) - b_repeat = state.poll_repeat_fire(now=1.11) - b_release = state.apply_input_event(input_tool.EV_KEY, input_tool.BTN_EAST, 0, now=1.11) - b_repeat_after_release = state.poll_repeat_fire(now=1.25) - - assert fire_press == [ - {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"} - ] - assert fire_release == [ - {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "release"} - ] - assert b_initial == [{"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "tap"}] - assert b_repeat == {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "tap"} - assert b_release == [] - assert b_repeat_after_release is None - - -def test_find_default_gamepad_device_prefers_real_controller_over_system_control() -> None: - candidates = [ - "/dev/input/by-id/usb-Keychron_Keychron_C3_Pro-if02-event-joystick", - "/dev/input/by-id/usb-Microsoft_Controller-event-joystick", - ] - names = { - "/dev/input/by-id/usb-Keychron_Keychron_C3_Pro-if02-event-joystick": "Keychron Keychron C3 Pro System Control", - "/dev/input/by-id/usb-Microsoft_Controller-event-joystick": "Microsoft X-Box 360 pad", - } - - with patch.object(input_tool.glob, "glob", side_effect=[candidates]), patch.object( - input_tool, "input_event_name", side_effect=lambda path: names[path] - ): - assert input_tool.find_default_gamepad_device() == candidates[1] diff --git a/tools/api/test_input_tool_interactive.py b/tools/api/test_input_tool_interactive.py deleted file mode 100644 index e437da98a..000000000 --- a/tools/api/test_input_tool_interactive.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -from collections import deque -from pathlib import Path -import sys - - -TOOLS_API = Path(__file__).resolve().parent -if str(TOOLS_API) not in sys.path: - sys.path.insert(0, str(TOOLS_API)) - -import input_tool # noqa: E402 - - -class FakeSession: - def __init__(self) -> None: - self.host = "u64" - self.calls: list[list[dict[str, object]]] = [] - self.payloads: list[dict[str, object]] = [] - - def post_events(self, events: list[dict[str, object]]) -> None: - self.calls.append(events) - - def json_request(self, method: str, path: str, payload: dict[str, object]) -> dict[str, object]: - assert method == "POST" - assert path == "/v1/machine:input" - self.payloads.append(payload) - self.calls.append(payload["events"]) # type: ignore[arg-type] - return {"errors": []} - - -def scripted_reader(*sequences: str): - queue = deque(sequences) - - def read() -> str: - if not queue: - raise AssertionError("interactive loop read past scripted input") - return queue.popleft() - - return read - - -def scripted_ready_reader(*sequences: str): - queue = deque(sequences) - - def read(): - if not queue: - return None - return queue.popleft() - - return read - - -def test_translate_sequence_uses_keyboard_taps_for_text_input() -> None: - assert input_tool.translate_sequence("a", 2) == { - "kind": "keyboard", - "inputs": ["a"], - "transition": "tap", - } - assert input_tool.translate_sequence("A", 2) == { - "kind": "keyboard", - "inputs": ["left_shift", "a"], - "transition": "tap", - } - - -def test_run_interactive_loop_posts_single_tap_for_keyboard_input() -> None: - session = FakeSession() - - input_tool.run_interactive_loop( - session, - 2, - scripted_reader("a", "\x03"), - scripted_ready_reader(), - ) - - assert session.calls == [ - [{"kind": "keyboard", "inputs": ["a"], "transition": "tap"}], - [{"kind": "release_all"}], - ] - assert session.payloads == [ - { - "events": [{"kind": "keyboard", "inputs": ["a"], "transition": "tap"}], - }, - ] - - -def test_run_interactive_loop_batches_ready_keys_with_pacing() -> None: - session = FakeSession() - - input_tool.run_interactive_loop( - session, - 2, - scripted_reader("a", "\x03"), - scripted_ready_reader("b", "C"), - ) - - assert session.calls == [ - [ - {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["b"], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["left_shift", "c"], "transition": "tap"}, - ], - [{"kind": "release_all"}], - ] - assert session.payloads == [ - { - "events": [ - {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["b"], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["left_shift", "c"], "transition": "tap"}, - ], - "pace_ms": input_tool.BATCH_PACE_MS, - }, - ] - - -def test_run_interactive_loop_preserves_release_all_and_joystick_events() -> None: - session = FakeSession() - - input_tool.run_interactive_loop( - session, - 2, - scripted_reader("\x1b[1;5A", "\x03"), - scripted_ready_reader("\x1b"), - ) - - assert session.calls == [ - [{"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "tap"}], - [{"kind": "release_all"}], - [{"kind": "release_all"}], - ] diff --git a/tools/api/test_unit_validation.py b/tools/api/test_unit_validation.py deleted file mode 100644 index 1d2bac608..000000000 --- a/tools/api/test_unit_validation.py +++ /dev/null @@ -1,624 +0,0 @@ -"""Unit-level contract tests for `/v1/machine:input`.""" - -from __future__ import annotations - -from copy import deepcopy -from typing import Any - -import pytest - -KEYBOARD_INPUTS = [ - "inst_del", - "return", - "cursor_left_right", - "f7", - "f1", - "f3", - "f5", - "cursor_up_down", - "3", - "w", - "a", - "4", - "z", - "s", - "e", - "left_shift", - "5", - "r", - "d", - "6", - "c", - "f", - "t", - "x", - "7", - "y", - "g", - "8", - "b", - "h", - "u", - "v", - "9", - "i", - "j", - "0", - "m", - "k", - "o", - "n", - "plus", - "p", - "l", - "minus", - "period", - "colon", - "at", - "comma", - "pound", - "star", - "semicolon", - "clr_home", - "right_shift", - "equals", - "arrow_up", - "slash", - "1", - "arrow_left", - "ctrl", - "2", - "space", - "commodore", - "q", - "run_stop", - "restore", -] -JOYSTICK_INPUTS = ["up", "down", "left", "right", "fire"] -TRANSITIONS = {"press", "release", "tap"} -RESTORE = "restore" -UNSUPPORTED_MESSAGE = "Keyboard and joystick injection require Ultimate 64-class hardware." - - -class InputApiModel: - def __init__(self, supported: bool = True, port2_supported: bool = True) -> None: - self.supported = supported - self.port2_supported = port2_supported - self.keyboard_pressed: set[str] = set() - self.keyboard_overlay: set[str] = set() - self.joystick_pressed = {1: set[str](), 2: set[str]()} - self.joystick_overlay = {1: set[str](), 2: set[str]()} - - def get(self) -> tuple[int, dict[str, Any]]: - if not self.supported: - return self._unsupported() - return 200, self._snapshot() - - def post(self, payload: Any) -> tuple[int, dict[str, Any]]: - if not self.supported: - return self._unsupported() - errors = self._validate_payload(payload) - if errors: - return 400, {"errors": errors} - for event in payload["events"]: - self._apply(event) - return 200, self._snapshot() - - def post_raw( - self, body: Any, content_type: str | None = "application/json" - ) -> tuple[int, dict[str, Any]]: - if content_type != "application/json": - return 400, {"errors": ["Unsupported Content-Type; expected application/json."]} - if body is None: - return 412, {"errors": ["Expected Body, but got none."]} - return self.post(body) - - def tick(self) -> None: - self.keyboard_overlay.clear() - self.joystick_overlay[1].clear() - self.joystick_overlay[2].clear() - - def _unsupported(self) -> tuple[int, dict[str, Any]]: - return 501, {"errors": [UNSUPPORTED_MESSAGE]} - - def _snapshot(self) -> dict[str, Any]: - return { - "errors": [], - "keyboard": {"inputs": self._ordered(KEYBOARD_INPUTS, self.keyboard_pressed | self.keyboard_overlay)}, - "joysticks": [ - {"port": 1, "inputs": self._ordered(JOYSTICK_INPUTS, self.joystick_pressed[1] | self.joystick_overlay[1])}, - {"port": 2, "inputs": self._ordered(JOYSTICK_INPUTS, self.joystick_pressed[2] | self.joystick_overlay[2])}, - ], - } - - @staticmethod - def _ordered(order: list[str], values: set[str]) -> list[str]: - return [item for item in order if item in values] - - def _validate_payload(self, payload: Any) -> list[str]: - if not isinstance(payload, dict): - return ["Root must be an object."] - if set(payload) - {"events", "pace_ms"}: - return ["Root must contain only `events`."] - events = payload.get("events") - if not isinstance(events, list): - return ["`events` must be an array."] - pace_ms = payload.get("pace_ms") - if pace_ms is not None: - if type(pace_ms) is not int: - return ["`pace_ms` must be an integer."] - if not 0 <= pace_ms <= 1000: - return ["`pace_ms` must be between 0 and 1000."] - if not 1 <= len(events) <= 64: - return ["`events` must contain 1..64 entries."] - for event in events: - error = self._validate_event(event) - if error: - return [error] - return [] - - def _validate_event(self, event: Any) -> str | None: - if not isinstance(event, dict): - return "Each event must be an object." - kind = event.get("kind") - if not isinstance(kind, str) or kind not in {"keyboard", "joystick", "release_all"}: - return "`kind` must be one of `keyboard`, `joystick`, or `release_all`." - if kind == "keyboard": - return self._validate_keyboard(event) - if kind == "joystick": - return self._validate_joystick(event) - return self._validate_release_all(event) - - def _validate_transition(self, event: dict[str, Any]) -> str | None: - transition = event.get("transition") - if not isinstance(transition, str) or transition not in TRANSITIONS: - return "`transition` must be one of `press`, `release`, or `tap`." - return None - - def _validate_inputs(self, event: dict[str, Any], allowed: list[str], limit: int, label: str) -> str | None: - inputs = event.get("inputs") - if not isinstance(inputs, list): - return "`inputs` must be an array." - if not 1 <= len(inputs) <= limit: - return f"`inputs` must contain 1..{limit} entries." - seen: set[str] = set() - for input_name in inputs: - if not isinstance(input_name, str): - return f"{label} inputs must be strings." - if input_name not in allowed: - return f"`{input_name}` is not a valid {label.lower()} input." - if input_name in seen: - return f"`{input_name}` appears more than once in `inputs`." - seen.add(input_name) - return None - - def _validate_keyboard(self, event: dict[str, Any]) -> str | None: - if set(event) - {"kind", "inputs", "transition"}: - return "Unknown property in keyboard event." - error = self._validate_transition(event) - if error: - return error - error = self._validate_inputs(event, KEYBOARD_INPUTS, 8, "Keyboard") - if error: - return error - inputs = event["inputs"] - if RESTORE in inputs and (inputs != [RESTORE] or event["transition"] != "tap"): - return "`restore` must appear alone in `inputs` and only with transition `tap`." - return None - - def _validate_joystick(self, event: dict[str, Any]) -> str | None: - if set(event) - {"kind", "port", "inputs", "transition"}: - return "Unknown property in joystick event." - error = self._validate_transition(event) - if error: - return error - port = event.get("port") - if type(port) is not int: - return "`port` must be an integer." - if port not in {1, 2}: - return "`port` must be 1 or 2." - if port == 2 and not self.port2_supported: - return "Joystick port 2 is not supported by this device." - return self._validate_inputs(event, JOYSTICK_INPUTS, 5, "Joystick") - - @staticmethod - def _validate_release_all(event: dict[str, Any]) -> str | None: - if set(event) != {"kind"}: - return "`release_all` events may only contain `kind`." - return None - - def _apply(self, event: dict[str, Any]) -> None: - kind = event["kind"] - if kind == "release_all": - self.keyboard_pressed.clear() - self.keyboard_overlay.clear() - self.joystick_pressed[1].clear() - self.joystick_pressed[2].clear() - self.joystick_overlay[1].clear() - self.joystick_overlay[2].clear() - return - if kind == "keyboard": - target = self.keyboard_pressed - overlay = self.keyboard_overlay - else: - target = self.joystick_pressed[event["port"]] - overlay = self.joystick_overlay[event["port"]] - inputs = set(event["inputs"]) - transition = event["transition"] - if transition == "press": - target.update(inputs) - elif transition == "release": - target.difference_update(inputs) - else: - overlay.update(inputs) - - -def post_ok(api: InputApiModel, events: list[dict[str, Any]]) -> dict[str, Any]: - status, body = api.post({"events": events}) - assert status == 200 - assert body["errors"] == [] - return body - - -def post_bad(api: InputApiModel, payload: Any) -> dict[str, Any]: - before = deepcopy(api._snapshot()) - status, body = api.post(payload) - assert status == 400 - assert body["errors"] - assert "keyboard" not in body - assert "joysticks" not in body - assert api._snapshot() == before - return body - - -def test_get_success_response_shape_and_ordering() -> None: - api = InputApiModel() - body = post_ok( - api, - [ - {"kind": "joystick", "port": 2, "inputs": ["fire", "up"], "transition": "press"}, - {"kind": "keyboard", "inputs": ["a", "left_shift"], "transition": "press"}, - ], - ) - - assert body == { - "errors": [], - "keyboard": {"inputs": ["a", "left_shift"]}, - "joysticks": [{"port": 1, "inputs": []}, {"port": 2, "inputs": ["up", "fire"]}], - } - assert api.get() == (200, body) - - -def test_content_type_is_required_for_post_body() -> None: - api = InputApiModel() - valid_body = {"events": [{"kind": "release_all"}]} - - assert api.post_raw(valid_body, "application/json")[0] == 200 - for content_type in (None, "", "text/plain", "application/json; charset=utf-8"): - status, body = api.post_raw(valid_body, content_type) - assert status == 400 - assert body["errors"] - - -def test_missing_json_body_is_rejected_without_mutation() -> None: - api = InputApiModel() - post_ok(api, [{"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}]) - - status, body = api.post_raw(None, "application/json") - - assert status == 412 - assert body["errors"] - assert api.get()[1]["keyboard"]["inputs"] == ["ctrl"] - - -@pytest.mark.parametrize("payload", [None, [], "{}", 1]) -def test_root_payload_must_be_object(payload: Any) -> None: - post_bad(InputApiModel(), payload) - - -@pytest.mark.parametrize("payload", [{}, {"events": [], "extra": True}, {"extra": []}]) -def test_root_must_contain_only_events(payload: Any) -> None: - post_bad(InputApiModel(), payload) - - -def test_root_accepts_optional_pace_ms() -> None: - api = InputApiModel() - - status, body = api.post({"events": [{"kind": "release_all"}], "pace_ms": 20}) - - assert status == 200 - assert body["errors"] == [] - - -@pytest.mark.parametrize("pace_ms", ["20", True, -1, 1001]) -def test_pace_ms_must_be_integer_in_range_when_present(pace_ms: Any) -> None: - post_bad(InputApiModel(), {"events": [{"kind": "release_all"}], "pace_ms": pace_ms}) - - -@pytest.mark.parametrize("events", [None, {}, "event", 1]) -def test_events_must_be_array(events: Any) -> None: - post_bad(InputApiModel(), {"events": events}) - - -def test_events_accepts_one_to_64_entries() -> None: - api = InputApiModel() - event = {"kind": "release_all"} - - assert api.post({"events": [event]})[0] == 200 - assert api.post({"events": [event] * 64})[0] == 200 - post_bad(api, {"events": []}) - post_bad(api, {"events": [event] * 65}) - - -@pytest.mark.parametrize("event", [None, [], "event", 1]) -def test_each_event_must_be_object(event: Any) -> None: - post_bad(InputApiModel(), {"events": [event]}) - - -@pytest.mark.parametrize("kind", [None, "", "mouse", 1, True]) -def test_kind_enum_is_closed_and_string_typed(kind: Any) -> None: - post_bad(InputApiModel(), {"events": [{"kind": kind}]}) - - -@pytest.mark.parametrize("input_name", KEYBOARD_INPUTS) -def test_keyboard_input_enum_accepts_every_documented_value(input_name: str) -> None: - transition = "tap" if input_name == RESTORE else "press" - assert InputApiModel().post({"events": [{"kind": "keyboard", "inputs": [input_name], "transition": transition}]})[0] == 200 - - -@pytest.mark.parametrize("transition", ["press", "release", "tap"]) -def test_keyboard_transition_enum_accepts_all_values(transition: str) -> None: - assert InputApiModel().post({"events": [{"kind": "keyboard", "inputs": ["a"], "transition": transition}]})[0] == 200 - - -@pytest.mark.parametrize( - "event", - [ - {"kind": "keyboard", "transition": "tap"}, - {"kind": "keyboard", "inputs": None, "transition": "tap"}, - {"kind": "keyboard", "inputs": "a", "transition": "tap"}, - {"kind": "keyboard", "inputs": [], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["a"] * 9, "transition": "tap"}, - {"kind": "keyboard", "inputs": ["a", "a"], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["escape"], "transition": "tap"}, - {"kind": "keyboard", "inputs": [1], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["a"], "transition": None}, - {"kind": "keyboard", "inputs": ["a"], "transition": "hold"}, - {"kind": "keyboard", "inputs": ["a"], "transition": 1}, - {"kind": "keyboard", "port": 1, "inputs": ["a"], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["a"], "transition": "tap", "duration_ms": 20}, - ], -) -def test_keyboard_validation_rejects_invalid_shape_values_and_unknown_fields(event: dict[str, Any]) -> None: - post_bad(InputApiModel(), {"events": [event]}) - - -@pytest.mark.parametrize( - "event", - [ - {"kind": "keyboard", "inputs": [RESTORE], "transition": "press"}, - {"kind": "keyboard", "inputs": [RESTORE], "transition": "release"}, - {"kind": "keyboard", "inputs": [RESTORE, "run_stop"], "transition": "tap"}, - {"kind": "keyboard", "inputs": [RESTORE, "a"], "transition": "tap"}, - ], -) -def test_restore_is_tap_only_and_alone(event: dict[str, Any]) -> None: - body = post_bad(InputApiModel(), {"events": [event]}) - assert "restore" in body["errors"][0] - - -@pytest.mark.parametrize("input_name", JOYSTICK_INPUTS) -def test_joystick_input_enum_accepts_every_documented_value(input_name: str) -> None: - assert InputApiModel().post( - {"events": [{"kind": "joystick", "port": 1, "inputs": [input_name], "transition": "press"}]} - )[0] == 200 - - -@pytest.mark.parametrize("transition", ["press", "release", "tap"]) -def test_joystick_transition_enum_accepts_all_values(transition: str) -> None: - assert InputApiModel().post( - {"events": [{"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": transition}]} - )[0] == 200 - - -@pytest.mark.parametrize( - "inputs", - [ - ["up", "right", "fire"], - ["up", "down"], - ["left", "right"], - ["up", "down", "left", "right", "fire"], - ], -) -def test_joystick_accepts_diagonals_and_unusual_combinations(inputs: list[str]) -> None: - assert InputApiModel().post( - {"events": [{"kind": "joystick", "port": 2, "inputs": inputs, "transition": "press"}]} - )[0] == 200 - - -@pytest.mark.parametrize( - "event", - [ - {"kind": "joystick", "inputs": ["up"], "transition": "tap"}, - {"kind": "joystick", "port": None, "inputs": ["up"], "transition": "tap"}, - {"kind": "joystick", "port": "1", "inputs": ["up"], "transition": "tap"}, - {"kind": "joystick", "port": True, "inputs": ["up"], "transition": "tap"}, - {"kind": "joystick", "port": 0, "inputs": ["up"], "transition": "tap"}, - {"kind": "joystick", "port": 3, "inputs": ["up"], "transition": "tap"}, - {"kind": "joystick", "port": 1, "transition": "tap"}, - {"kind": "joystick", "port": 1, "inputs": [], "transition": "tap"}, - {"kind": "joystick", "port": 1, "inputs": ["up"] * 6, "transition": "tap"}, - {"kind": "joystick", "port": 1, "inputs": ["jump"], "transition": "tap"}, - {"kind": "joystick", "port": 1, "inputs": ["up", "up"], "transition": "tap"}, - {"kind": "joystick", "port": 1, "inputs": [1], "transition": "tap"}, - {"kind": "joystick", "port": 1, "inputs": ["up"], "transition": "hold"}, - {"kind": "joystick", "port": 1, "inputs": ["up"], "transition": 1}, - {"kind": "joystick", "port": 1, "inputs": ["up"], "transition": "tap", "duration_ms": 20}, - ], -) -def test_joystick_validation_rejects_invalid_shape_values_and_unknown_fields(event: dict[str, Any]) -> None: - post_bad(InputApiModel(), {"events": [event]}) - - -def test_optional_port2_capability_gate_rejects_port2_before_mutation() -> None: - api = InputApiModel(port2_supported=False) - - post_ok(api, [{"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}]) - body = post_bad(api, {"events": [{"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "press"}]}) - - assert "port 2" in body["errors"][0] - assert api.get()[1]["joysticks"] == [{"port": 1, "inputs": ["fire"]}, {"port": 2, "inputs": []}] - - -@pytest.mark.parametrize( - "event", - [ - {"kind": "release_all", "inputs": []}, - {"kind": "release_all", "port": 1}, - {"kind": "release_all", "transition": "tap"}, - {"kind": "release_all", "extra": True}, - ], -) -def test_release_all_rejects_extra_fields(event: dict[str, Any]) -> None: - post_bad(InputApiModel(), {"events": [event]}) - - -def test_capability_failure_returns_501_without_snapshot_fields() -> None: - api = InputApiModel(supported=False) - - for method in (api.get, lambda: api.post({"events": [{"kind": "release_all"}]})): - status, body = method() - assert status == 501 - assert body == {"errors": [UNSUPPORTED_MESSAGE]} - - -def test_invalid_batch_is_atomic_even_when_error_is_late() -> None: - api = InputApiModel() - post_ok( - api, - [ - {"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}, - {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, - ], - ) - - post_bad( - api, - { - "events": [ - {"kind": "release_all"}, - {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, - {"kind": "joystick", "port": 3, "inputs": ["up"], "transition": "press"}, - ] - }, - ) - - assert api.get()[1] == { - "errors": [], - "keyboard": {"inputs": ["ctrl"]}, - "joysticks": [{"port": 1, "inputs": ["fire"]}, {"port": 2, "inputs": []}], - } - - -def test_batch_events_are_applied_in_order_and_can_recover_after_release_all() -> None: - api = InputApiModel() - body = post_ok( - api, - [ - {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, - {"kind": "joystick", "port": 1, "inputs": ["up", "fire"], "transition": "press"}, - {"kind": "release_all"}, - {"kind": "keyboard", "inputs": ["commodore", "ctrl"], "transition": "press"}, - {"kind": "keyboard", "inputs": ["commodore"], "transition": "release"}, - {"kind": "joystick", "port": 2, "inputs": ["down"], "transition": "press"}, - ], - ) - - assert body["keyboard"]["inputs"] == ["ctrl"] - assert body["joysticks"] == [{"port": 1, "inputs": []}, {"port": 2, "inputs": ["down"]}] - - -def test_press_release_and_release_all_are_idempotent() -> None: - api = InputApiModel() - - body = post_ok( - api, - [ - {"kind": "keyboard", "inputs": ["a"], "transition": "press"}, - {"kind": "keyboard", "inputs": ["a"], "transition": "press"}, - {"kind": "keyboard", "inputs": ["space"], "transition": "release"}, - {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "release"}, - {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, - {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, - ], - ) - - assert body["keyboard"]["inputs"] == ["a"] - assert body["joysticks"][0]["inputs"] == ["fire"] - assert post_ok(api, [{"kind": "release_all"}, {"kind": "release_all"}]) == { - "errors": [], - "keyboard": {"inputs": []}, - "joysticks": [{"port": 1, "inputs": []}, {"port": 2, "inputs": []}], - } - - -def test_keyboard_tap_overlay_auto_releases_without_clearing_persistent_key() -> None: - api = InputApiModel() - - body = post_ok( - api, - [ - {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, - {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, - ], - ) - assert body["keyboard"]["inputs"] == ["a", "left_shift"] - - api.tick() - assert api.get()[1]["keyboard"]["inputs"] == ["left_shift"] - - -def test_release_does_not_cancel_active_keyboard_or_joystick_tap_overlay() -> None: - api = InputApiModel() - - body = post_ok( - api, - [ - {"kind": "keyboard", "inputs": ["a"], "transition": "tap"}, - {"kind": "keyboard", "inputs": ["a"], "transition": "release"}, - {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "tap"}, - {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "release"}, - ], - ) - assert body["keyboard"]["inputs"] == ["a"] - assert body["joysticks"][0]["inputs"] == ["fire"] - - api.tick() - assert api.get()[1]["keyboard"]["inputs"] == [] - assert api.get()[1]["joysticks"][0]["inputs"] == [] - - -def test_joystick_tap_overlay_auto_releases_without_clearing_persistent_inputs() -> None: - api = InputApiModel() - - body = post_ok( - api, - [ - {"kind": "joystick", "port": 2, "inputs": ["up"], "transition": "press"}, - {"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "tap"}, - ], - ) - assert body["joysticks"][1]["inputs"] == ["up", "fire"] - - api.tick() - assert api.get()[1]["joysticks"][1]["inputs"] == ["up"] - - -def test_restore_tap_is_reported_temporarily_but_not_persisted() -> None: - api = InputApiModel() - - assert post_ok(api, [{"kind": "keyboard", "inputs": [RESTORE], "transition": "tap"}])["keyboard"]["inputs"] == [ - RESTORE - ] - api.tick() - assert api.get()[1]["keyboard"]["inputs"] == [] From db44fffaeab12dd4f5b6e5a7160bd3bfa0aa6534 Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Tue, 19 May 2026 18:19:59 +0100 Subject: [PATCH 3/7] Improve REST keyboard tap batching. --- .gitignore | 3 +- software/api/input_api.h | 6 +- software/api/route_input.cc | 180 +- software/api/tests/input_api_state_test.cpp | 106 +- .../api/tests/input_api_validation_test.cpp | 22 +- software/io/c64/joystick_output.cc | 88 +- software/io/c64/joystick_output.h | 10 +- software/io/usb/keyboard_usb.cc | 126 +- software/io/usb/keyboard_usb.h | 33 +- tools/api/input_test.py | 496 ++++- tools/api/input_tool.py | 1779 +++++++++++++++-- 11 files changed, 2598 insertions(+), 251 deletions(-) diff --git a/.gitignore b/.gitignore index 58f259f30..1c3e82dee 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ fpga/sid_old /old_flash_images /releases roms/JiffyDOS_1541_6.00_(rebadged_5.0).bin +software/api/tests/inputApiValidationTest +software/api/tests/inputApiStateTest software/Debug software/Lean software/PathTest @@ -287,4 +289,3 @@ tools/64tass/*.o python/grab* fpga/cpu_unit/rvlite/vhdl_source/bootrom_pkg_u2.vhd fpga/cpu_unit/rvlite/vhdl_source/bootrom_pkg_u2_loader.vhd - diff --git a/software/api/input_api.h b/software/api/input_api.h index 73800b61e..e53d8bd35 100644 --- a/software/api/input_api.h +++ b/software/api/input_api.h @@ -9,7 +9,7 @@ static const int INPUT_API_MAX_EVENTS = 64; static const int INPUT_API_MAX_KEYBOARD_INPUTS = 8; -static const int INPUT_API_MAX_JOYSTICK_INPUTS = 5; +static const int INPUT_API_MAX_JOYSTICK_INPUTS = 7; static const int INPUT_API_ERROR_SIZE = 160; enum InputParsedKind { @@ -119,6 +119,8 @@ static const InputJoystickMapEntry INPUT_API_JOYSTICK_MAP[] = { { "left", 2 }, { "right", 3 }, { "fire", 4 }, + { "fire2", 5 }, + { "fire3", 6 }, }; static const int INPUT_API_KEYBOARD_MAP_COUNT = sizeof(INPUT_API_KEYBOARD_MAP) / sizeof(INPUT_API_KEYBOARD_MAP[0]); @@ -366,7 +368,7 @@ static inline bool input_api_parse_joystick_event(JSON_Object *obj, InputParsedE JSON_List *list = (JSON_List *)inputs; int count = list->get_num_elements(); if ((count < 1) || (count > INPUT_API_MAX_JOYSTICK_INPUTS)) { - input_api_set_error(err, err_size, "`inputs` must contain 1..5 entries."); + input_api_set_error(err, err_size, "`inputs` must contain 1..7 entries."); return false; } diff --git a/software/api/route_input.cc b/software/api/route_input.cc index a9bc59cb1..216b3bc83 100644 --- a/software/api/route_input.cc +++ b/software/api/route_input.cc @@ -7,6 +7,7 @@ #include "FreeRTOS.h" #include "semphr.h" +#include "task.h" #include #include @@ -14,9 +15,132 @@ static const char *INPUT_CAPABILITY_ERROR = "Keyboard and joystick injection require Ultimate 64-class hardware."; +namespace { + +static const size_t INPUT_JSON_BODY_MAX_SIZE = 4096; + +class InputJsonWriter +{ + HTTPReqMessage *req; + HTTPRespMessage *resp; + const ApiCall_t *func; + ArgsURI *args; + char *buffer; + size_t size; + size_t capacity; + int status; + + bool reserve(size_t required) + { + if (required > INPUT_JSON_BODY_MAX_SIZE) { + status = -2; + return false; + } + if (required <= capacity) { + return true; + } + char *grown = (char *)realloc(buffer, required + 1); + if (!grown) { + status = -3; + return false; + } + buffer = grown; + capacity = required; + return true; + } + +public: + InputJsonWriter(HTTPReqMessage *req, HTTPRespMessage *resp, const ApiCall_t *func, ArgsURI *args) + : req(req), resp(resp), func(func), args(args), buffer(NULL), size(0), capacity(0), status(0) + { + if ((req->bodyType == eTotalSize) && (req->bodySize > 0)) { + reserve(req->bodySize); + } + } + + ~InputJsonWriter() + { + free(buffer); + } + + static void collect_wrapper(BodyDataBlock_t *block) + { + InputJsonWriter *writer = (InputJsonWriter *)block->context; + writer->collect(block); + } + + void collect(BodyDataBlock_t *block) + { + switch (block->type) { + case eStart: + size = 0; + break; + case eDataBlock: + if (status != 0) { + break; + } + if (!reserve(size + block->length)) { + break; + } + memcpy(buffer + size, block->data, block->length); + size += block->length; + buffer[size] = 0; + break; + case eTerminate: + { + ResponseWrapper respw(resp); + if (args->Validate(*func, &respw) != 0) { + respw.json_response(HTTP_BAD_REQUEST); + } else { + func->proc(*args, req, &respw, this); + } + delete args; + delete this; + break; + } + default: + break; + } + } + + int error(void) const + { + return status; + } + + char *data(void) + { + return buffer; + } + + size_t length(void) const + { + return size; + } +}; + +void *input_json_writer(HTTPReqMessage *req, HTTPRespMessage *resp, const ApiCall_t *func, ArgsURI *args) +{ + if (req->bodyType != eNoBody) { + if ((req->bodyType == eTotalSize) && (req->bodySize == 0)) { + req->bodyType = eNoBody; + return NULL; + } + InputJsonWriter *writer = new InputJsonWriter(req, resp, func, args); + setup_multipart(req, &InputJsonWriter::collect_wrapper, writer); + return writer; + } + return NULL; +} + +} + #if U64 -static const uint8_t REST_TAP_HOLD_TICKS = 1; +// The REST tap timer ticks every 10 ms in Keyboard_USB. Hold taps for 60 ms so +// BASIC's asynchronous keyboard scan sees them reliably without regressing into +// the old long 200 ms experimental hold. +static const uint8_t REST_TAP_HOLD_TICKS = 6; static SemaphoreHandle_t rest_input_mutex = NULL; static SemaphoreHandle_t input_mutex(void) @@ -31,7 +155,7 @@ static void apply_joystick_event(const InputParsedEvent &event) { uint8_t p1, p2; uint8_t active_low; - uint8_t hold[INPUT_API_MAX_JOYSTICK_INPUTS] = { 0, 0, 0, 0, 0 }; + uint8_t hold[INPUT_API_MAX_JOYSTICK_INPUTS] = { 0, 0, 0, 0, 0, 0, 0 }; JoystickOutput::instance().restPersistentSnapshot(p1, p2); active_low = (event.port == 1) ? p1 : p2; @@ -59,7 +183,7 @@ static void apply_joystick_event(const InputParsedEvent &event) hold[i] = REST_TAP_HOLD_TICKS; } } - active_low = 0x1F & ~event.joystick_mask; + active_low = ((1 << INPUT_API_MAX_JOYSTICK_INPUTS) - 1) & ~event.joystick_mask; if (event.port == 1) { JoystickOutput::instance().armRestPort1Overlay(active_low, hold); } else { @@ -72,6 +196,23 @@ static void apply_joystick_event(const InputParsedEvent &event) static void apply_keyboard_event(const InputParsedEvent &event) { const InputKeyboardMapEntry *keyboard_map = input_api_keyboard_map(); + uint8_t tap_matrix[8] = { 0, 0, 0, 0, 0, 0, 0, 0 }; + bool tap_restore = false; + + if (event.transition == INPUT_PARSED_TAP) { + for (int i = 0; i < event.keyboard_count; i++) { + const InputKeyboardMapEntry &entry = keyboard_map[event.keyboard_index[i]]; + if (entry.restore) { + tap_restore = true; + } else { + tap_matrix[entry.row] |= (1 << entry.col); + } + } + while (!system_usb_keyboard.restQueueTap(tap_matrix, tap_restore, REST_TAP_HOLD_TICKS)) { + vTaskDelay(1); + } + return; + } for (int i = 0; i < event.keyboard_count; i++) { const InputKeyboardMapEntry &entry = keyboard_map[event.keyboard_index[i]]; @@ -87,7 +228,6 @@ static void apply_keyboard_event(const InputParsedEvent &event) system_usb_keyboard.restRelease(entry.row, entry.col); break; case INPUT_PARSED_TAP: - system_usb_keyboard.restTap(entry.row, entry.col, REST_TAP_HOLD_TICKS); break; } } @@ -206,7 +346,7 @@ API_CALL(GET, machine, input, NULL, ARRAY( { })) #endif } -API_CALL(POST, machine, input, &attachment_writer, ARRAY( { })) +API_CALL(POST, machine, input, &input_json_writer, ARRAY( { })) { #if U64 if (!ensure_input_capability(resp)) { @@ -222,39 +362,37 @@ API_CALL(POST, machine, input, &attachment_writer, ARRAY( { })) resp->json_response(HTTP_BAD_REQUEST); return; } - TempfileWriter *handler = (TempfileWriter *)body; + InputJsonWriter *handler = (InputJsonWriter *)body; if (!handler) { resp->error("Request body is required."); resp->json_response(HTTP_BAD_REQUEST); return; } - int buffered = handler->buffer_file(0, 4096); - if (buffered != 0) { - if (buffered == -1) { - resp->error("Request body is required."); - } else if (buffered == -2) { + if (handler->error() != 0) { + if (handler->error() == -2) { resp->error("JSON body is too large."); + } else if (handler->error() == -3) { + resp->error("Could not allocate JSON buffer."); + resp->json_response(HTTP_INTERNAL_SERVER_ERROR); + return; } else { - resp->error("Could not buffer attachment."); + resp->error("Request body is required."); } resp->json_response(HTTP_BAD_REQUEST); return; } JSON *obj = NULL; - size_t text_size = handler->get_filesize(0); - char *text = (char *)malloc(text_size + 1); - if (!text) { - resp->error("Could not allocate JSON buffer."); - resp->json_response(HTTP_INTERNAL_SERVER_ERROR); + size_t text_size = handler->length(); + char *text = handler->data(); + if (!text || (text_size == 0)) { + resp->error("Request body is required."); + resp->json_response(HTTP_BAD_REQUEST); return; } - memcpy(text, handler->get_buffer(0), text_size); - text[text_size] = 0; int tokens = convert_text_to_json_objects(text, text_size, 1024, &obj); if (tokens < 0) { resp->error("JSON could not be parsed. Error: %d", tokens); - free(text); resp->json_response(HTTP_BAD_REQUEST); return; } @@ -269,7 +407,6 @@ API_CALL(POST, machine, input, &attachment_writer, ARRAY( { })) if (!input_api_validate_batch(obj, events, event_count, pace_ms, error_index, err, sizeof(err))) { xSemaphoreGive(mutex); delete obj; - free(text); if (error_index >= 0) { resp->error("events[%d]: %s", error_index, err); } else { @@ -283,7 +420,6 @@ API_CALL(POST, machine, input, &attachment_writer, ARRAY( { })) xSemaphoreGive(mutex); resp->json_response(HTTP_OK); delete obj; - free(text); #else resp->error(INPUT_CAPABILITY_ERROR); resp->json_response(HTTP_NOT_IMPLEMENTED); diff --git a/software/api/tests/input_api_state_test.cpp b/software/api/tests/input_api_state_test.cpp index 006deab13..081c45ceb 100644 --- a/software/api/tests/input_api_state_test.cpp +++ b/software/api/tests/input_api_state_test.cpp @@ -25,6 +25,13 @@ bool key_active(const uint8_t matrix[8], const char *name) return (matrix[entry->row] & (1 << entry->col)) != 0; } +void add_key_to_matrix(uint8_t matrix[8], const char *name) +{ + const InputKeyboardMapEntry *entry = find_keyboard_entry(name); + ASSERT_TRUE(entry != 0); + matrix[entry->row] |= (1 << entry->col); +} + void reset_joystick_output(void) { JoystickOutput::instance().setUsbPort1(0x1F); @@ -95,6 +102,49 @@ TEST(RestKeyboardStateTest, RestoreTapIsTemporaryAndNotPersistent) EXPECT_FALSE(restore); } +TEST(RestKeyboardStateTest, QueuedTapPreservesChordAndOrder) +{ + Keyboard_USB keyboard; + uint8_t matrix[8]; + bool restore = false; + uint8_t shift_a[8] = { 0, 0, 0, 0, 0, 0, 0, 0 }; + uint8_t b_only[8] = { 0, 0, 0, 0, 0, 0, 0, 0 }; + + add_key_to_matrix(shift_a, "left_shift"); + add_key_to_matrix(shift_a, "a"); + add_key_to_matrix(b_only, "b"); + + ASSERT_TRUE(keyboard.restQueueTap(shift_a, false, 1)); + ASSERT_TRUE(keyboard.restQueueTap(b_only, false, 1)); + + keyboard.restSnapshot(matrix, restore); + EXPECT_TRUE(key_active(matrix, "left_shift")); + EXPECT_TRUE(key_active(matrix, "a")); + EXPECT_FALSE(key_active(matrix, "b")); + + keyboard.tickRestOverlays(); + keyboard.restSnapshot(matrix, restore); + EXPECT_FALSE(key_active(matrix, "left_shift")); + EXPECT_FALSE(key_active(matrix, "a")); + EXPECT_FALSE(key_active(matrix, "b")); + + keyboard.tickRestOverlays(); + keyboard.restSnapshot(matrix, restore); + EXPECT_FALSE(key_active(matrix, "b")); + EXPECT_FALSE(key_active(matrix, "left_shift")); + EXPECT_FALSE(key_active(matrix, "a")); + + keyboard.tickRestOverlays(); + keyboard.restSnapshot(matrix, restore); + EXPECT_TRUE(key_active(matrix, "b")); + EXPECT_FALSE(key_active(matrix, "left_shift")); + EXPECT_FALSE(key_active(matrix, "a")); + + keyboard.tickRestOverlays(); + keyboard.restSnapshot(matrix, restore); + EXPECT_FALSE(key_active(matrix, "b")); +} + TEST(RestKeyboardStateTest, ReleaseAllClearsPersistentAndOverlayState) { Keyboard_USB keyboard; @@ -119,33 +169,75 @@ TEST(RestKeyboardStateTest, ReleaseAllClearsPersistentAndOverlayState) TEST(RestJoystickStateTest, TapOverlayAutoReleasesWithoutClearingPersistentInput) { reset_joystick_output(); - uint8_t hold[5] = { 0, 0, 0, 0, 1 }; + uint8_t hold[7] = { 0, 0, 0, 0, 1, 1, 1 }; uint8_t port1 = 0; uint8_t port2 = 0; - JoystickOutput::instance().setRestPort2Persistent(0x1E); + JoystickOutput::instance().setRestPort2Persistent(0x5E); JoystickOutput::instance().armRestPort2Overlay(0x0F, hold); JoystickOutput::instance().snapshot(port1, port2); - EXPECT_EQ(0x1F, port1); + EXPECT_EQ(0x7F, port1); EXPECT_EQ(0x0E, port2); JoystickOutput::instance().tickOverlays(); JoystickOutput::instance().snapshot(port1, port2); - EXPECT_EQ(0x1E, port2); + EXPECT_EQ(0x5E, port2); } TEST(RestJoystickStateTest, ReleaseAllClearsBothPorts) { reset_joystick_output(); - uint8_t hold[5] = { 1, 0, 0, 0, 0 }; + uint8_t hold[7] = { 1, 0, 0, 0, 0, 1, 1 }; uint8_t port1 = 0; uint8_t port2 = 0; - JoystickOutput::instance().setRestPort1Persistent(0x0F); - JoystickOutput::instance().setRestPort2Persistent(0x1E); + JoystickOutput::instance().setRestPort1Persistent(0x2F); + JoystickOutput::instance().setRestPort2Persistent(0x5E); JoystickOutput::instance().armRestPort1Overlay(0x1E, hold); JoystickOutput::instance().releaseAllRest(); JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x7F, port1); + EXPECT_EQ(0x7F, port2); +} + +TEST(RestJoystickStateTest, Fire2AndFire3PersistAndReleaseIndependently) +{ + reset_joystick_output(); + uint8_t port1 = 0; + uint8_t port2 = 0; + + JoystickOutput::instance().setRestPort1Persistent(0x1F); + JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x1F, port1); + + JoystickOutput::instance().setRestPort1Persistent(0x3F); + JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x3F, port1); + + JoystickOutput::instance().setRestPort1Persistent(0x5F); + JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x5F, port1); +} + +TEST(RestJoystickStateTest, Fire2MapsToPotXAndFire3MapsToPotY) +{ + reset_joystick_output(); + uint8_t port1 = 0; + uint8_t port2 = 0; + uint8_t pot1x = 0; + uint8_t pot1y = 0; + uint8_t pot2x = 0; + uint8_t pot2y = 0; + + JoystickOutput::instance().setRestPort2Persistent(0x5F); + JoystickOutput::instance().outputSnapshot(port1, port2, pot1x, pot1y, pot2x, pot2y); EXPECT_EQ(0x1F, port1); EXPECT_EQ(0x1F, port2); + EXPECT_EQ(0x00, pot2x); + EXPECT_EQ(0x7F, pot2y); + + JoystickOutput::instance().setRestPort2Persistent(0x3F); + JoystickOutput::instance().outputSnapshot(port1, port2, pot1x, pot1y, pot2x, pot2y); + EXPECT_EQ(0x7F, pot2x); + EXPECT_EQ(0x00, pot2y); } diff --git a/software/api/tests/input_api_validation_test.cpp b/software/api/tests/input_api_validation_test.cpp index 7c09f53c9..eb7ed89cb 100644 --- a/software/api/tests/input_api_validation_test.cpp +++ b/software/api/tests/input_api_validation_test.cpp @@ -83,7 +83,7 @@ TEST(InputApiValidationTest, ParsesValidBatchAndPreservesEventDetails) JSON *root = make_root( JSON::List() ->add(make_keyboard_event("press", { "a", "left_shift" })) - ->add(make_joystick_event(2, "tap", { "up", "fire" })) + ->add(make_joystick_event(2, "tap", { "up", "fire", "fire2", "fire3" })) ->add(make_release_all_event()), 25, true); @@ -99,7 +99,7 @@ TEST(InputApiValidationTest, ParsesValidBatchAndPreservesEventDetails) EXPECT_EQ(INPUT_PARSED_JOYSTICK, events[1].kind); EXPECT_EQ(2, events[1].port); EXPECT_EQ(INPUT_PARSED_TAP, events[1].transition); - EXPECT_EQ((1 << 0) | (1 << 4), events[1].joystick_mask); + EXPECT_EQ((1 << 0) | (1 << 4) | (1 << 5) | (1 << 6), events[1].joystick_mask); EXPECT_EQ(INPUT_PARSED_RELEASE_ALL, events[2].kind); delete root; @@ -248,6 +248,24 @@ TEST(InputApiValidationTest, RejectsJoystickDuplicateAndUnknownInputs) delete unknown; } +TEST(InputApiValidationTest, AcceptsAllSevenJoystickInputsInOneEvent) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 0; + int pace_ms = 0; + int error_index = -1; + std::string err; + + JSON *root = make_root(JSON::List()->add( + make_joystick_event(2, "press", { "up", "down", "left", "right", "fire", "fire2", "fire3" }))); + + ASSERT_TRUE(validate(root, events, event_count, pace_ms, error_index, err)); + ASSERT_EQ(1, event_count); + EXPECT_EQ(0x7F, events[0].joystick_mask); + + delete root; +} + TEST(InputApiValidationTest, RejectsReleaseAllExtraFields) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; diff --git a/software/io/c64/joystick_output.cc b/software/io/c64/joystick_output.cc index 4e9956527..0f4fc168f 100644 --- a/software/io/c64/joystick_output.cc +++ b/software/io/c64/joystick_output.cc @@ -16,7 +16,26 @@ #if U64 && !RECOVERYAPP static const uint32_t JOYSTICK_REST_TIMER_TICKS = (pdMS_TO_TICKS(20) > 0) ? pdMS_TO_TICKS(20) : 1; +#endif + +static const uint8_t JOYSTICK_DIGITAL_MASK = 0x1F; +static const uint8_t JOYSTICK_FIRE2_BIT = (1 << 5); +static const uint8_t JOYSTICK_FIRE3_BIT = (1 << 6); +static const uint8_t JOYSTICK_INPUT_MASK = JOYSTICK_DIGITAL_MASK | JOYSTICK_FIRE2_BIT | JOYSTICK_FIRE3_BIT; +static const uint8_t JOYSTICK_POT_RELEASED = 0x7F; +static const uint8_t JOYSTICK_POT_PRESSED = 0x00; + +static uint8_t joystick_potx_value(uint8_t active_low_mask) +{ + return (active_low_mask & JOYSTICK_FIRE2_BIT) ? JOYSTICK_POT_RELEASED : JOYSTICK_POT_PRESSED; +} + +static uint8_t joystick_poty_value(uint8_t active_low_mask) +{ + return (active_low_mask & JOYSTICK_FIRE3_BIT) ? JOYSTICK_POT_RELEASED : JOYSTICK_POT_PRESSED; +} +#if U64 && !RECOVERYAPP static void joystick_overlay_timer(TimerHandle_t timer) { (void)timer; @@ -26,11 +45,11 @@ static void joystick_overlay_timer(TimerHandle_t timer) JoystickOutput :: JoystickOutput() { - usb_p1 = 0x1F; - rest_p1_persistent = 0x1F; - rest_p2_persistent = 0x1F; - rest_p1_overlay = 0x1F; - rest_p2_overlay = 0x1F; + usb_p1 = JOYSTICK_INPUT_MASK; + rest_p1_persistent = JOYSTICK_INPUT_MASK; + rest_p2_persistent = JOYSTICK_INPUT_MASK; + rest_p1_overlay = JOYSTICK_INPUT_MASK; + rest_p2_overlay = JOYSTICK_INPUT_MASK; memset(rest_p1_hold, 0, sizeof(rest_p1_hold)); memset(rest_p2_hold, 0, sizeof(rest_p2_hold)); #if U64 && !RECOVERYAPP @@ -50,8 +69,14 @@ JoystickOutput &JoystickOutput :: instance() void JoystickOutput :: apply(void) { #if U64 - C64_JOY1_SWOUT = ((usb_p1 & rest_p1_persistent & rest_p1_overlay) & 0x1F) | 0xE0; - C64_JOY2_SWOUT = ((0x1F & rest_p2_persistent & rest_p2_overlay) & 0x1F) | 0xE0; + uint8_t port1, port2, pot1x, pot1y, pot2x, pot2y; + outputSnapshot(port1, port2, pot1x, pot1y, pot2x, pot2y); + C64_JOY1_SWOUT = port1 | 0xE0; + C64_JOY2_SWOUT = port2 | 0xE0; + C64_PADDLE_1_X = pot1x; + C64_PADDLE_1_Y = pot1y; + C64_PADDLE_2_X = pot2x; + C64_PADDLE_2_Y = pot2y; #endif } @@ -60,7 +85,7 @@ void JoystickOutput :: setUsbPort1(uint8_t active_low_mask) #if U64 portENTER_CRITICAL(); #endif - usb_p1 = active_low_mask & 0x1F; + usb_p1 = (active_low_mask & JOYSTICK_DIGITAL_MASK) | (JOYSTICK_INPUT_MASK & ~JOYSTICK_DIGITAL_MASK); apply(); #if U64 portEXIT_CRITICAL(); @@ -72,7 +97,7 @@ void JoystickOutput :: setRestPort1Persistent(uint8_t active_low_mask) #if U64 portENTER_CRITICAL(); #endif - rest_p1_persistent = active_low_mask & 0x1F; + rest_p1_persistent = active_low_mask & JOYSTICK_INPUT_MASK; apply(); #if U64 portEXIT_CRITICAL(); @@ -84,7 +109,7 @@ void JoystickOutput :: setRestPort2Persistent(uint8_t active_low_mask) #if U64 portENTER_CRITICAL(); #endif - rest_p2_persistent = active_low_mask & 0x1F; + rest_p2_persistent = active_low_mask & JOYSTICK_INPUT_MASK; apply(); #if U64 portEXIT_CRITICAL(); @@ -93,14 +118,14 @@ void JoystickOutput :: setRestPort2Persistent(uint8_t active_low_mask) void JoystickOutput :: restPersistentSnapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const { - port1_active_low = rest_p1_persistent & 0x1F; - port2_active_low = rest_p2_persistent & 0x1F; + port1_active_low = rest_p1_persistent & JOYSTICK_INPUT_MASK; + port2_active_low = rest_p2_persistent & JOYSTICK_INPUT_MASK; } -static void arm_overlay_bits(uint8_t &overlay, uint8_t hold_state[5], uint8_t active_low_mask, const uint8_t hold[5]) +static void arm_overlay_bits(uint8_t &overlay, uint8_t hold_state[7], uint8_t active_low_mask, const uint8_t hold[7]) { - active_low_mask &= 0x1F; - for (int i = 0; i < 5; i++) { + active_low_mask &= JOYSTICK_INPUT_MASK; + for (int i = 0; i < 7; i++) { uint8_t bit = (1 << i); if (hold[i] != 0) { hold_state[i] = hold[i]; @@ -113,7 +138,7 @@ static void arm_overlay_bits(uint8_t &overlay, uint8_t hold_state[5], uint8_t ac } } -void JoystickOutput :: armRestPort1Overlay(uint8_t active_low_mask, const uint8_t hold[5]) +void JoystickOutput :: armRestPort1Overlay(uint8_t active_low_mask, const uint8_t hold[7]) { #if U64 portENTER_CRITICAL(); @@ -125,7 +150,7 @@ void JoystickOutput :: armRestPort1Overlay(uint8_t active_low_mask, const uint8_ #endif } -void JoystickOutput :: armRestPort2Overlay(uint8_t active_low_mask, const uint8_t hold[5]) +void JoystickOutput :: armRestPort2Overlay(uint8_t active_low_mask, const uint8_t hold[7]) { #if U64 portENTER_CRITICAL(); @@ -137,10 +162,10 @@ void JoystickOutput :: armRestPort2Overlay(uint8_t active_low_mask, const uint8_ #endif } -static bool tick_overlay_bits(uint8_t &overlay, uint8_t hold_state[5]) +static bool tick_overlay_bits(uint8_t &overlay, uint8_t hold_state[7]) { bool changed = false; - for (int i = 0; i < 5; i++) { + for (int i = 0; i < 7; i++) { if (hold_state[i] == 0) { continue; } @@ -173,10 +198,10 @@ void JoystickOutput :: releaseAllRest(void) #if U64 portENTER_CRITICAL(); #endif - rest_p1_persistent = 0x1F; - rest_p2_persistent = 0x1F; - rest_p1_overlay = 0x1F; - rest_p2_overlay = 0x1F; + rest_p1_persistent = JOYSTICK_INPUT_MASK; + rest_p2_persistent = JOYSTICK_INPUT_MASK; + rest_p1_overlay = JOYSTICK_INPUT_MASK; + rest_p2_overlay = JOYSTICK_INPUT_MASK; memset(rest_p1_hold, 0, sizeof(rest_p1_hold)); memset(rest_p2_hold, 0, sizeof(rest_p2_hold)); apply(); @@ -187,6 +212,19 @@ void JoystickOutput :: releaseAllRest(void) void JoystickOutput :: snapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const { - port1_active_low = (rest_p1_persistent & rest_p1_overlay) & 0x1F; - port2_active_low = (rest_p2_persistent & rest_p2_overlay) & 0x1F; + port1_active_low = (rest_p1_persistent & rest_p1_overlay) & JOYSTICK_INPUT_MASK; + port2_active_low = (rest_p2_persistent & rest_p2_overlay) & JOYSTICK_INPUT_MASK; +} + +void JoystickOutput :: outputSnapshot(uint8_t &port1_active_low, uint8_t &port2_active_low, + uint8_t &port1_potx, uint8_t &port1_poty, uint8_t &port2_potx, uint8_t &port2_poty) const +{ + uint8_t port1 = usb_p1 & rest_p1_persistent & rest_p1_overlay; + uint8_t port2 = rest_p2_persistent & rest_p2_overlay; + port1_active_low = port1 & JOYSTICK_DIGITAL_MASK; + port2_active_low = port2 & JOYSTICK_DIGITAL_MASK; + port1_potx = joystick_potx_value(port1); + port1_poty = joystick_poty_value(port1); + port2_potx = joystick_potx_value(port2); + port2_poty = joystick_poty_value(port2); } diff --git a/software/io/c64/joystick_output.h b/software/io/c64/joystick_output.h index aa1efd7bb..b19f33812 100644 --- a/software/io/c64/joystick_output.h +++ b/software/io/c64/joystick_output.h @@ -10,8 +10,8 @@ class JoystickOutput uint8_t rest_p2_persistent; uint8_t rest_p1_overlay; uint8_t rest_p2_overlay; - uint8_t rest_p1_hold[5]; - uint8_t rest_p2_hold[5]; + uint8_t rest_p1_hold[7]; + uint8_t rest_p2_hold[7]; JoystickOutput(); void apply(void); @@ -25,14 +25,16 @@ class JoystickOutput void setRestPort2Persistent(uint8_t active_low_mask); void restPersistentSnapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const; - void armRestPort1Overlay(uint8_t active_low_mask, const uint8_t hold[5]); - void armRestPort2Overlay(uint8_t active_low_mask, const uint8_t hold[5]); + void armRestPort1Overlay(uint8_t active_low_mask, const uint8_t hold[7]); + void armRestPort2Overlay(uint8_t active_low_mask, const uint8_t hold[7]); void tickOverlays(void); void releaseAllRest(void); void snapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const; + void outputSnapshot(uint8_t &port1_active_low, uint8_t &port2_active_low, + uint8_t &port1_potx, uint8_t &port1_poty, uint8_t &port2_potx, uint8_t &port2_poty) const; }; diff --git a/software/io/usb/keyboard_usb.cc b/software/io/usb/keyboard_usb.cc index b5b21586d..a62de7b47 100644 --- a/software/io/usb/keyboard_usb.cc +++ b/software/io/usb/keyboard_usb.cc @@ -36,8 +36,9 @@ uint8_t usb_matrix_lookup(const uint8_t *map, size_t map_size, uint8_t key) } #if U64 && !RECOVERYAPP -static const uint32_t REST_INPUT_TIMER_TICKS = (pdMS_TO_TICKS(20) > 0) ? pdMS_TO_TICKS(20) : 1; +static const uint32_t REST_INPUT_TIMER_TICKS = (pdMS_TO_TICKS(10) > 0) ? pdMS_TO_TICKS(10) : 1; #endif +static const uint8_t REST_TAP_GAP_TICKS = 2; } @@ -127,6 +128,10 @@ Keyboard_USB :: Keyboard_USB() memset(rest_matrix_state, 0, sizeof(rest_matrix_state)); memset(rest_matrix_overlay, 0, sizeof(rest_matrix_overlay)); memset(rest_overlay_hold, 0, sizeof(rest_overlay_hold)); + memset(rest_tap_queue, 0, sizeof(rest_tap_queue)); + rest_tap_head = 0; + rest_tap_tail = 0; + rest_tap_gap_ticks = 0; usb_restore = 0; usb_freeze = 0; rest_restore = false; @@ -205,6 +210,46 @@ void Keyboard_USB :: clearInjectedMatrixState(void) applyMatrixState(); } +bool Keyboard_USB :: restTapQueueEmpty(void) const +{ + return rest_tap_head == rest_tap_tail; +} + +bool Keyboard_USB :: restTapOverlayActive(void) const +{ + if (rest_restore_hold != 0) { + return true; + } + for (int row = 0; row < 8; row++) { + for (int col = 0; col < 8; col++) { + if (rest_overlay_hold[row][col] != 0) { + return true; + } + } + } + return false; +} + +void Keyboard_USB :: startRestTap(const RestTapEntry& entry) +{ + for (int row = 0; row < 8; row++) { + for (int col = 0; col < 8; col++) { + uint8_t bit = (1 << col); + if (entry.matrix[row] & bit) { + if ((rest_matrix_state[row] & bit) == 0) { + rest_matrix_overlay[row] |= bit; + } + rest_matrix_state[row] |= bit; + rest_overlay_hold[row][col] = entry.hold_ticks; + } + } + } + if (entry.restore) { + rest_restore_overlay = 1; + rest_restore_hold = entry.hold_ticks; + } +} + void Keyboard_USB :: setInjectedMatrixKey(int key) { memset(injected_matrix_state, 0, sizeof(injected_matrix_state)); @@ -547,6 +592,8 @@ void Keyboard_USB :: restPress(uint8_t row, uint8_t col_bit) } portENTER_CRITICAL(); rest_matrix_state[row] |= (1 << col_bit); + rest_matrix_overlay[row] &= ~(1 << col_bit); + rest_overlay_hold[row][col_bit] = 0; applyMatrixState(); portEXIT_CRITICAL(); } @@ -568,12 +615,62 @@ void Keyboard_USB :: restTap(uint8_t row, uint8_t col_bit, uint8_t hold_ticks) return; } portENTER_CRITICAL(); - rest_matrix_overlay[row] |= (1 << col_bit); + if ((rest_matrix_state[row] & (1 << col_bit)) == 0) { + rest_matrix_overlay[row] |= (1 << col_bit); + } + rest_matrix_state[row] |= (1 << col_bit); rest_overlay_hold[row][col_bit] = hold_ticks; applyMatrixState(); portEXIT_CRITICAL(); } +bool Keyboard_USB :: restQueueTap(const uint8_t matrix[8], bool restore, uint8_t hold_ticks) +{ + if (hold_ticks == 0) { + return true; + } + portENTER_CRITICAL(); + + bool has_any_key = restore; + for (int row = 0; row < 8; row++) { + if (matrix[row] != 0) { + has_any_key = true; + break; + } + } + if (!has_any_key) { + portEXIT_CRITICAL(); + return true; + } + + if (!restTapOverlayActive() && (rest_tap_gap_ticks == 0) && restTapQueueEmpty()) { + RestTapEntry entry; + memcpy(entry.matrix, matrix, sizeof(entry.matrix)); + entry.hold_ticks = hold_ticks; + entry.restore = restore; + startRestTap(entry); + applyMatrixState(); + portEXIT_CRITICAL(); + return true; + } + + uint8_t next_head = rest_tap_head + 1; + if (next_head >= REST_TAP_QUEUE_SIZE) { + next_head = 0; + } + if (next_head == rest_tap_tail) { + portEXIT_CRITICAL(); + return false; + } + + memcpy(rest_tap_queue[rest_tap_head].matrix, matrix, sizeof(rest_tap_queue[rest_tap_head].matrix)); + rest_tap_queue[rest_tap_head].hold_ticks = hold_ticks; + rest_tap_queue[rest_tap_head].restore = restore; + rest_tap_head = next_head; + portEXIT_CRITICAL(); + return true; +} + void Keyboard_USB :: restPressRestore(void) { portENTER_CRITICAL(); @@ -608,6 +705,9 @@ void Keyboard_USB :: restReleaseAll(void) memset(rest_matrix_state, 0, sizeof(rest_matrix_state)); memset(rest_matrix_overlay, 0, sizeof(rest_matrix_overlay)); memset(rest_overlay_hold, 0, sizeof(rest_overlay_hold)); + rest_tap_head = 0; + rest_tap_tail = 0; + rest_tap_gap_ticks = 0; rest_restore = false; rest_restore_overlay = 0; rest_restore_hold = 0; @@ -626,7 +726,7 @@ void Keyboard_USB :: restSnapshot(uint8_t out_matrix[8], bool &out_restore) cons void Keyboard_USB :: restPersistentSnapshot(uint8_t out_matrix[8], bool &out_restore) const { for (int i = 0; i < 8; i++) { - out_matrix[i] = rest_matrix_state[i]; + out_matrix[i] = rest_matrix_state[i] & ~rest_matrix_overlay[i]; } out_restore = rest_restore; } @@ -642,7 +742,12 @@ void Keyboard_USB :: tickRestOverlays(void) } rest_overlay_hold[row][col]--; if (rest_overlay_hold[row][col] == 0) { - rest_matrix_overlay[row] &= ~(1 << col); + uint8_t bit = (1 << col); + if (rest_matrix_overlay[row] & bit) { + rest_matrix_state[row] &= ~bit; + rest_matrix_overlay[row] &= ~bit; + } + rest_tap_gap_ticks = REST_TAP_GAP_TICKS; changed = true; } } @@ -651,6 +756,19 @@ void Keyboard_USB :: tickRestOverlays(void) rest_restore_hold--; if (rest_restore_hold == 0) { rest_restore_overlay = 0; + rest_tap_gap_ticks = REST_TAP_GAP_TICKS; + changed = true; + } + } + if (!restTapOverlayActive()) { + if (rest_tap_gap_ticks > 0) { + rest_tap_gap_ticks--; + } else if (!restTapQueueEmpty()) { + startRestTap(rest_tap_queue[rest_tap_tail]); + rest_tap_tail++; + if (rest_tap_tail >= REST_TAP_QUEUE_SIZE) { + rest_tap_tail = 0; + } changed = true; } } diff --git a/software/io/usb/keyboard_usb.h b/software/io/usb/keyboard_usb.h index 9db95a774..281825224 100644 --- a/software/io/usb/keyboard_usb.h +++ b/software/io/usb/keyboard_usb.h @@ -16,12 +16,19 @@ typedef struct tmrTimerControl * TimerHandle_t; #endif static const int USB_KEY_BUFFER_SIZE = 64; +static const int REST_TAP_QUEUE_SIZE = 128; #define USB_DATA_SIZE 8 class GenericHost; class Keyboard_USB : public Keyboard { + struct RestTapEntry { + uint8_t matrix[8]; + uint8_t hold_ticks; + bool restore; + }; + volatile uint8_t *matrix; bool matrixEnabled; uint8_t matrix_state[8]; @@ -29,6 +36,10 @@ class Keyboard_USB : public Keyboard uint8_t rest_matrix_state[8]; uint8_t rest_matrix_overlay[8]; uint8_t rest_overlay_hold[8][8]; + RestTapEntry rest_tap_queue[REST_TAP_QUEUE_SIZE]; + uint8_t rest_tap_head; + uint8_t rest_tap_tail; + uint8_t rest_tap_gap_ticks; uint8_t key_buffer[USB_KEY_BUFFER_SIZE]; uint8_t injected_buffer[USB_KEY_BUFFER_SIZE]; uint8_t last_data[USB_DATA_SIZE]; @@ -54,11 +65,14 @@ class Keyboard_USB : public Keyboard int delay_count; int injected_matrix_hold; void applyMatrixState(void); - void clearInjectedMatrixState(void); - void setInjectedMatrixKey(int key); - uint8_t effectiveRestoreBit(void) const; + void clearInjectedMatrixState(void); + void setInjectedMatrixKey(int key); + uint8_t effectiveRestoreBit(void) const; + bool restTapQueueEmpty(void) const; + bool restTapOverlayActive(void) const; + void startRestTap(const RestTapEntry& entry); #if U64 && !RECOVERYAPP - static void S_rest_timer(TimerHandle_t a); + static void S_rest_timer(TimerHandle_t a); #endif public: Keyboard_USB(); @@ -77,11 +91,12 @@ class Keyboard_USB : public Keyboard void wait_free(void); void clear_buffer(void); - void restPress(uint8_t row, uint8_t col_bit); - void restRelease(uint8_t row, uint8_t col_bit); - void restTap(uint8_t row, uint8_t col_bit, uint8_t hold_ticks); - void restPressRestore(void); - void restReleaseRestore(void); + void restPress(uint8_t row, uint8_t col_bit); + void restRelease(uint8_t row, uint8_t col_bit); + void restTap(uint8_t row, uint8_t col_bit, uint8_t hold_ticks); + bool restQueueTap(const uint8_t matrix[8], bool restore, uint8_t hold_ticks); + void restPressRestore(void); + void restReleaseRestore(void); void restTapRestore(uint8_t hold_ticks); void restReleaseAll(void); void restSnapshot(uint8_t out_matrix[8], bool &out_restore) const; diff --git a/tools/api/input_test.py b/tools/api/input_test.py index ba761378e..98afe83e6 100755 --- a/tools/api/input_test.py +++ b/tools/api/input_test.py @@ -3,6 +3,7 @@ import http.client import json import os +import re import sys import time import urllib.error @@ -13,6 +14,8 @@ CHECK_COUNT = 0 READY_SCREEN_CODES = bytes((0x12, 0x05, 0x01, 0x04, 0x19, 0x2E)) +LETTER_SCREEN_CODES = {chr(ord("A") + index): index + 1 for index in range(26)} +BASIC_INPUT_SETTLE_SECONDS = float(os.environ.get("U64_INPUT_BASIC_SETTLE", "3.0")) KEYBOARD_MATRIX: Dict[str, Tuple[int, int]] = { "inst_del": (0, 0), "return": (0, 1), @@ -258,6 +261,54 @@ def reset_to_basic(session: RestInputSession) -> None: session.post_events([{"kind": "release_all"}]) +def reset_to_basic_for_keyboard_input(session: RestInputSession) -> None: + reset_to_basic(session) + time.sleep(BASIC_INPUT_SETTLE_SECONDS) + + +def try_clear_basic_screen(session: RestInputSession) -> bool: + session.post_events([{"kind": "release_all"}]) + time.sleep(0.35) + for _ in range(2): + session.post_events([{"kind": "keyboard", "inputs": ["left_shift", "clr_home"], "transition": "press"}]) + time.sleep(0.12) + session.post_events([{"kind": "keyboard", "inputs": ["left_shift", "clr_home"], "transition": "release"}]) + deadline = time.monotonic() + 1.5 + while time.monotonic() < deadline: + row0 = session.read_memory(0x0400, 8) + if row0[0] in (0x20, 0xA0) and all(byte == 0x20 for byte in row0[1:]): + return True + time.sleep(0.05) + return False + + +def find_cursor_row(session: RestInputSession) -> Optional[int]: + screen = session.read_memory(0x0400, 1000) + for index, value in enumerate(screen): + if value == 0xA0: + return index // 40 + return None + + +def last_non_empty_screen_row(session: RestInputSession) -> int: + screen = session.read_memory(0x0400, 1000) + last = 0 + for row in range(25): + line = screen[row * 40:(row + 1) * 40] + if any(byte not in (0x20, 0xA0) for byte in line): + last = row + return last + + +def prepare_basic_entry_row(session: RestInputSession) -> int: + if try_clear_basic_screen(session): + return 0 + target_row = min(last_non_empty_screen_row(session) + 1, 24) + session.post_events([{"kind": "keyboard", "inputs": ["return"], "transition": "tap"}]) + time.sleep(0.4) + return target_row + + def assert_joystick_ports(session: RestInputSession, port1: int, port2: int) -> None: actual_port1, actual_port2 = read_joystick_cia(session) if (actual_port1 & 0x1F) != port1 or (actual_port2 & 0x1F) != port2: @@ -342,6 +393,351 @@ def assert_keyboard_matrix_inputs(session: RestInputSession, inputs: List[str]) assert_keyboard_matrix(session, input_name, True) +def text_to_screen_codes(text: str) -> bytes: + out = bytearray() + for ch in text.upper(): + if ch in LETTER_SCREEN_CODES: + out.append(LETTER_SCREEN_CODES[ch]) + elif "0" <= ch <= "9": + out.append(ord(ch)) + elif ch == " ": + out.append(0x20) + else: + raise Failure(f"Unsupported screen-code text {text!r}") + return bytes(out) + + +def wait_for_screen_sequence(session: RestInputSession, expected: bytes, timeout: float) -> float: + started = time.monotonic() + deadline = time.monotonic() + timeout + while True: + screen = session.read_memory(0x0400, 1000) + if expected in screen: + return time.monotonic() - started + if time.monotonic() >= deadline: + raise Failure(f"Expected screen sequence {expected!r} not found before timeout") + time.sleep(0.02) + + +def read_basic_input_line(session: RestInputSession) -> bytes: + screen = session.read_memory(0x0400, 1000) + ready_offset = screen.find(READY_SCREEN_CODES) + if ready_offset < 0: + raise Failure("BASIC READY prompt not found on screen") + line_start = ((ready_offset // 40) + 1) * 40 + return screen[line_start:line_start + 40] + + +def wait_for_basic_input_prefix(session: RestInputSession, expected_text: str, timeout: float) -> float: + expected = text_to_screen_codes(expected_text) + started = time.monotonic() + deadline = started + timeout + while True: + line = read_basic_input_line(session) + if line[:len(expected)] == expected: + return time.monotonic() - started + if time.monotonic() >= deadline: + raise Failure(f"Expected BASIC input prefix {expected_text!r}, got {line[:len(expected)]!r}") + time.sleep(0.02) + + +def wait_for_screen_row_prefix(session: RestInputSession, row: int, expected_text: str, timeout: float) -> float: + expected = text_to_screen_codes(expected_text) + started = time.monotonic() + deadline = started + timeout + while True: + line = session.read_memory(0x0400 + (row * 40), 40) + if line[:len(expected)] == expected: + return time.monotonic() - started + if time.monotonic() >= deadline: + raise Failure(f"Expected screen row {row} prefix {expected_text!r}, got {line[:len(expected)]!r}") + time.sleep(0.02) + + +def keyboard_tap_events_for_text(text: str) -> List[Dict[str, Any]]: + events: List[Dict[str, Any]] = [] + for ch in text: + if "a" <= ch <= "z" or "0" <= ch <= "9": + events.append({"kind": "keyboard", "inputs": [ch], "transition": "tap"}) + elif "A" <= ch <= "Z": + events.append({"kind": "keyboard", "inputs": ["left_shift", ch.lower()], "transition": "tap"}) + else: + raise Failure(f"Unsupported keyboard tap text {text!r}") + return events + + +def parse_duration_seconds(text: str) -> float: + match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([smhSMH]?)\s*", text) + if not match: + raise Failure(f"Unsupported duration {text!r}; use values like 30, 45s, 5m, or 1.5h") + value = float(match.group(1)) + unit = match.group(2).lower() + scale = {"": 1.0, "s": 1.0, "m": 60.0, "h": 3600.0}[unit] + return value * scale + + +def append_screen_tail(screen_tail: str, text: str, limit: int = 200) -> str: + return (screen_tail + text.upper())[-limit:] + + +def soak_keyboard_basic_case(session: RestInputSession, screen_tail: str, text: str, pace_ms: int) -> str: + session.json_request( + "POST", + "/v1/machine:input", + payload={"events": keyboard_tap_events_for_text(text), "pace_ms": pace_ms}, + ) + screen_tail = append_screen_tail(screen_tail, text) + wait_for_screen_sequence(session, text_to_screen_codes(screen_tail), timeout=max(4.0, len(text) * 0.8)) + time.sleep(0.3) + assert_input_state(session, [], [], []) + session.post_events([{"kind": "release_all"}]) + return screen_tail + + +def soak_keyboard_hold_case(session: RestInputSession, persistent_inputs: List[str], tap_inputs: List[str]) -> None: + session.post_events([{"kind": "release_all"}]) + session.post_events( + [ + {"kind": "keyboard", "inputs": persistent_inputs, "transition": "press"}, + {"kind": "keyboard", "inputs": tap_inputs, "transition": "tap"}, + ] + ) + time.sleep(0.1) + state_inputs = session.get_state()["keyboard"]["inputs"] + for item in persistent_inputs: + if item not in state_inputs: + raise Failure(f"Expected persistent keyboard input {item} to remain active, got {state_inputs}") + for item in tap_inputs: + if item not in persistent_inputs and item in state_inputs: + raise Failure(f"Expected tap-only keyboard input {item} to auto-release, got {state_inputs}") + assert_keyboard_matrix_inputs(session, persistent_inputs) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + +def soak_joystick_case(session: RestInputSession, port: int, pressed_inputs: List[str], release_inputs: List[str]) -> None: + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": port, "inputs": pressed_inputs, "transition": "press"}]) + active = [item for item in pressed_inputs if item not in release_inputs] + if release_inputs: + session.post_events([{"kind": "joystick", "port": port, "inputs": release_inputs, "transition": "release"}]) + if port == 1: + port1 = 0x1F + if "up" in active: + port1 &= ~0x01 + if "down" in active: + port1 &= ~0x02 + if "left" in active: + port1 &= ~0x04 + if "right" in active: + port1 &= ~0x08 + if "fire" in active: + port1 &= ~0x10 + assert_joystick_ports(session, port1, 0x1F) + else: + port2 = 0x1F + if "up" in active: + port2 &= ~0x01 + if "down" in active: + port2 &= ~0x02 + if "left" in active: + port2 &= ~0x04 + if "right" in active: + port2 &= ~0x08 + if "fire" in active: + port2 &= ~0x10 + assert_joystick_ports(session, 0x1F, port2) + state = session.get_state()["joysticks"][port - 1]["inputs"] + if state != active: + raise Failure(f"Expected joystick port {port} inputs {active}, got {state}") + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + +def soak_interleaved_case( + session: RestInputSession, + screen_tail: str, + text: str, + joystick_port: int, + joystick_inputs: List[str], +) -> str: + session.post_events([{"kind": "joystick", "port": joystick_port, "inputs": joystick_inputs, "transition": "press"}]) + session.json_request( + "POST", + "/v1/machine:input", + payload={"events": keyboard_tap_events_for_text(text), "pace_ms": 140}, + ) + screen_tail = append_screen_tail(screen_tail, text) + wait_for_screen_sequence(session, text_to_screen_codes(screen_tail), timeout=max(4.0, len(text) * 0.8)) + state = session.get_state() + active_joy = state["joysticks"][joystick_port - 1]["inputs"] + if active_joy != joystick_inputs: + raise Failure(f"Expected joystick port {joystick_port} to remain active during keyboard batch, got {active_joy}") + if joystick_port == 1: + port1 = 0x1F + if "up" in joystick_inputs: + port1 &= ~0x01 + if "down" in joystick_inputs: + port1 &= ~0x02 + if "left" in joystick_inputs: + port1 &= ~0x04 + if "right" in joystick_inputs: + port1 &= ~0x08 + if "fire" in joystick_inputs: + port1 &= ~0x10 + assert_joystick_ports(session, port1, 0x1F) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + return screen_tail + + +def soak_invalid_atomic_case(session: RestInputSession) -> None: + session.post_events([{"kind": "release_all"}]) + session.post_events( + [ + {"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}, + {"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "press"}, + ] + ) + body = session.post_events_expect_error( + [ + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + {"kind": "joystick", "port": 3, "inputs": ["up"], "transition": "press"}, + ] + ) + assert_error_body_only(body) + assert_input_state(session, ["ctrl"], [], ["fire"]) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + +def soak_invalid_body_case(session: RestInputSession) -> None: + session.post_events( + [ + {"kind": "keyboard", "inputs": ["commodore"], "transition": "press"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, + ] + ) + body = session.post_without_body_expect_error() + assert_error_body_only(body) + assert_input_state(session, ["commodore"], ["fire"], []) + + large_event = b'{"kind":"keyboard","inputs":["a"],"transition":"tap"}' + oversized = b'{"events":[' + b",".join([large_event] * 120) + b"]}" + body = session.post_raw_expect_error(oversized, "application/json") + assert_error_body_only(body) + assert_input_state(session, ["commodore"], ["fire"], []) + + body = session.post_raw_expect_error(b'{"events":[', "application/json") + assert_error_body_only(body) + assert_input_state(session, ["commodore"], ["fire"], []) + + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + + +def soak_special_key_edge_case(session: RestInputSession) -> None: + for inputs in ( + ["ctrl", "9"], + ["ctrl", "0"], + ["left_shift", "cursor_left_right"], + ["cursor_left_right"], + ["inst_del"], + ["right_shift", "inst_del"], + ): + response = session.post_events([{"kind": "keyboard", "inputs": inputs, "transition": "tap"}]) + if sorted(response.get("keyboard", {}).get("inputs", [])) != sorted(inputs): + raise Failure(f"Expected special-key edge snapshot for {inputs}, got {response}") + time.sleep(0.2) + assert_state_empty(session) + + +def soak_rapid_mixed_case(session: RestInputSession, screen_tail: str, text_chunks: List[str], joystick_inputs: List[str]) -> str: + session.post_events([{"kind": "joystick", "port": 1, "inputs": joystick_inputs, "transition": "press"}]) + for chunk in text_chunks: + session.json_request( + "POST", + "/v1/machine:input", + payload={"events": keyboard_tap_events_for_text(chunk), "pace_ms": 140}, + ) + expected = "".join(text_chunks) + screen_tail = append_screen_tail(screen_tail, expected) + wait_for_screen_sequence(session, text_to_screen_codes(screen_tail), timeout=max(4.0, len(expected) * 0.8)) + assert_joystick_ports( + session, + 0x1F + & (~0x01 if "up" in joystick_inputs else 0x1F) + & (~0x02 if "down" in joystick_inputs else 0x1F) + & (~0x04 if "left" in joystick_inputs else 0x1F) + & (~0x08 if "right" in joystick_inputs else 0x1F) + & (~0x10 if "fire" in joystick_inputs else 0x1F), + 0x1F, + ) + assert_input_state(session, [], joystick_inputs, []) + session.post_events([{"kind": "release_all"}]) + assert_state_empty(session) + return screen_tail + + +def run_soak_tests(session: RestInputSession, duration_seconds: float) -> int: + keyboard_text_cases = [ + ("aaaaaa", 140), + ("Abab09", 120), + ("C64Z", 150), + ("qwertY", 110), + ("az09ZA", 130), + ] + keyboard_hold_cases = [ + (["left_shift"], ["left_shift", "a"]), + (["right_shift"], ["right_shift", "m"]), + (["commodore"], ["commodore", "q"]), + (["ctrl"], ["ctrl", "x"]), + ] + joystick_cases = [ + (1, ["up", "fire"], ["fire"]), + (1, ["left", "right", "fire"], ["right"]), + (2, ["down", "fire"], ["fire"]), + (2, ["up", "right", "fire"], ["right"]), + ] + interleaved_cases = [ + ("alpha", 1, ["up", "fire"]), + ("delta", 1, ["left", "fire"]), + ("omega", 1, ["right", "fire"]), + ("basic", 1, ["down"]), + ] + rapid_mix_cases = [ + (["ab", "C9", "za"], ["up", "fire"]), + (["Qw", "eR", "12"], ["left"]), + (["c6", "4Z", "aa"], ["right", "fire"]), + ] + + wait_for_input_ready(session, timeout=15.0) + wait_for_basic_ready(session) + screen_tail = "" + + deadline = time.monotonic() + duration_seconds + cycles = 0 + while time.monotonic() < deadline: + text_case = keyboard_text_cases[cycles % len(keyboard_text_cases)] + hold_case = keyboard_hold_cases[cycles % len(keyboard_hold_cases)] + joystick_case = joystick_cases[cycles % len(joystick_cases)] + interleaved_case = interleaved_cases[cycles % len(interleaved_cases)] + rapid_mix_case = rapid_mix_cases[cycles % len(rapid_mix_cases)] + + print(f"[soak {cycles + 1:03d}] text={text_case[0]} joy{joystick_case[0]}={'+'.join(joystick_case[1])}", flush=True) + screen_tail = soak_keyboard_basic_case(session, screen_tail, text_case[0], text_case[1]) + screen_tail = soak_interleaved_case(session, screen_tail, interleaved_case[0], interleaved_case[1], interleaved_case[2]) + screen_tail = soak_rapid_mixed_case(session, screen_tail, rapid_mix_case[0], rapid_mix_case[1]) + soak_keyboard_hold_case(session, hold_case[0], hold_case[1]) + soak_joystick_case(session, joystick_case[0], joystick_case[1], joystick_case[2]) + soak_special_key_edge_case(session) + if cycles % 3 == 0: + soak_invalid_atomic_case(session) + soak_invalid_body_case(session) + cycles += 1 + return cycles + + def run_contract_tests(session: RestInputSession) -> None: with check("input snapshot has stable empty response shape"): session.post_events([{"kind": "release_all"}]) @@ -494,6 +890,64 @@ def run_keyboard_tests(session: RestInputSession) -> None: time.sleep(0.2) assert_state_empty(session) + with check("keyboard special-key taps snapshot correctly and auto release"): + for inputs in (["commodore"], ["ctrl"], ["run_stop"], ["restore"], ["f1"], ["f3"], ["f5"], ["f7"], ["left_shift"], ["right_shift"]): + reset_to_basic(session) + response = session.post_events([{"kind": "keyboard", "inputs": inputs, "transition": "tap"}]) + if sorted(response.get("keyboard", {}).get("inputs", [])) != sorted(inputs): + raise Failure(f"Expected immediate special-key tap snapshot for {inputs}, got {response}") + time.sleep(0.2) + assert_state_empty(session) + + with check("keyboard tap is visible in the live hardware snapshot and auto releases"): + reset_to_basic(session) + response = session.post_events([{"kind": "keyboard", "inputs": ["a"], "transition": "tap"}]) + if response.get("keyboard", {}).get("inputs") != ["a"]: + raise Failure(f"Expected immediate tap snapshot for a, got {response}") + time.sleep(0.2) + assert_state_empty(session) + session.post_events([{"kind": "release_all"}]) + + with check("keyboard paced single taps are consumed by BASIC in order"): + reset_to_basic_for_keyboard_input(session) + session.json_request( + "POST", + "/v1/machine:input", + payload={"events": keyboard_tap_events_for_text("aaaaaa"), "pace_ms": 140}, + ) + wait_for_basic_input_prefix(session, "AAAAAA", timeout=4.0) + time.sleep(0.3) + assert_state_empty(session) + session.post_events([{"kind": "release_all"}]) + + with check("keyboard cursor-left tap is visible in the live hardware snapshot and auto releases"): + reset_to_basic(session) + response = session.post_events([{"kind": "keyboard", "inputs": ["left_shift", "cursor_left_right"], "transition": "tap"}]) + if sorted(response.get("keyboard", {}).get("inputs", [])) != ["cursor_left_right", "left_shift"]: + raise Failure(f"Expected immediate cursor-left tap snapshot, got {response}") + time.sleep(0.2) + assert_state_empty(session) + session.post_events([{"kind": "release_all"}]) + + with check("keyboard tap batch drains through the live matrix path"): + reset_to_basic(session) + response = session.json_request("POST", "/v1/machine:input", payload={"events": keyboard_tap_events_for_text("ABCDEFGHIJ"), "pace_ms": 0}) + if not response.get("keyboard", {}).get("inputs"): + raise Failure(f"Expected a live tap snapshot while the batch was draining, got {response}") + time.sleep(1.2) + assert_state_empty(session) + session.post_events([{"kind": "release_all"}]) + + with check("keyboard long repeated tap train drains fully without sticky state"): + reset_to_basic(session) + repeated = [{"kind": "keyboard", "inputs": ["a"], "transition": "tap"} for _ in range(60)] + response = session.json_request("POST", "/v1/machine:input", payload={"events": repeated, "pace_ms": 0}) + if response.get("keyboard", {}).get("inputs") != ["a"]: + raise Failure(f"Expected repeated tap train to expose the live a snapshot, got {response}") + time.sleep(6.0) + assert_state_empty(session) + session.post_events([{"kind": "release_all"}]) + with check("invalid keyboard batch does not mutate state"): reset_to_basic(session) body = session.post_events_expect_error( @@ -543,6 +997,15 @@ def run_joystick_tests(session: RestInputSession) -> None: session.post_events([{"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "release"}]) assert_joystick_ports(session, 0x1F, 0x1E) + with check("joystick fire2/fire3 round-trip through REST state"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["fire", "fire2", "fire3"], "transition": "press"}]) + assert_joystick_ports(session, 0x1F, 0x0F) + assert_input_state(session, [], [], ["fire", "fire2", "fire3"]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["fire2"], "transition": "release"}]) + assert_joystick_ports(session, 0x1F, 0x0F) + assert_input_state(session, [], [], ["fire", "fire3"]) + with check("joystick release_all then press in same batch is visible on CIA reads"): session.post_events( [ @@ -618,25 +1081,39 @@ def run_joystick_tests(session: RestInputSession) -> None: assert_state_empty(session) -def run_tests(session: RestInputSession) -> None: +def run_tests(session: RestInputSession, soak_duration_seconds: Optional[float] = None) -> int: wait_for_input_ready(session, timeout=15.0) + if soak_duration_seconds is not None: + return run_soak_tests(session, soak_duration_seconds) run_contract_tests(session) run_keyboard_tests(session) run_joystick_tests(session) + return 0 def main() -> int: - parser = argparse.ArgumentParser(description="Validate U64 keyboard and joystick REST input injection") - parser.add_argument("--host", default=os.environ.get("U64_INPUT_HOST", "u64")) - parser.add_argument("--rest-host", default=os.environ.get("U64_INPUT_REST_HOST")) - parser.add_argument("--password", default=os.environ.get("U64_INPUT_PASSWORD", os.environ.get("C64U_PASSWORD"))) - parser.add_argument("--timeout", type=float, default=float(os.environ.get("U64_INPUT_TIMEOUT", "5.0"))) + parser = argparse.ArgumentParser( + description="Validate U64 keyboard and joystick REST input injection", + epilog="Use --soak to continue with expanded long-run REST input coverage after the standard checks.", + ) + parser.add_argument("-H", "--host", default=os.environ.get("U64_INPUT_HOST", "u64")) + parser.add_argument("-r", "--rest-host", default=os.environ.get("U64_INPUT_REST_HOST")) + parser.add_argument("-p", "--password", default=os.environ.get("U64_INPUT_PASSWORD", os.environ.get("C64U_PASSWORD"))) + parser.add_argument("-t", "--timeout", type=float, default=float(os.environ.get("U64_INPUT_TIMEOUT", "5.0"))) + parser.add_argument("-s", "--soak", action="store_true", help="run the expanded soak suite after the standard checks") + parser.add_argument( + "-d", + "--soak-duration", + default="5m", + help="how long the soak suite should run (default: %(default)s)", + ) args = parser.parse_args() rest_host = args.rest_host or args.host session = RestInputSession(rest_host, args.password, args.timeout) + soak_duration_seconds = parse_duration_seconds(args.soak_duration) if args.soak else None try: - run_tests(session) + soak_cycles = run_tests(session, soak_duration_seconds=soak_duration_seconds) except Failure as exc: print(exc, file=sys.stderr) return 1 @@ -647,7 +1124,10 @@ def main() -> int: print(f"REST failure: {format_exception(exc)}", file=sys.stderr) return 1 - print(f"input_test: OK ({CHECK_COUNT} checks)") + if soak_duration_seconds is not None: + print(f"input_test: OK ({CHECK_COUNT} checks, {soak_cycles} soak cycles)") + else: + print(f"input_test: OK ({CHECK_COUNT} checks)") return 0 diff --git a/tools/api/input_tool.py b/tools/api/input_tool.py index 3a3aff878..89594f10e 100755 --- a/tools/api/input_tool.py +++ b/tools/api/input_tool.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 import argparse +import errno +import fcntl import glob import http.client import json -import math import os import select +import shutil import struct import sys import termios @@ -13,7 +15,8 @@ import tty import urllib.error from contextlib import contextmanager -from typing import Callable, Dict, List, Optional, Set, Tuple +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Set, Tuple from input_test import ( Failure, @@ -54,22 +57,69 @@ "C": "right", "D": "left", } +TERMINAL_SPECIAL_KEY_HELP_LINES = ( + "terminal fallback: direct text, arrows, Tab=CTRL, F1-F8=F1-F8, Home=CLR/HOME", + " Backspace/Delete/Insert=INST/DEL, End/PgDn=RUN/STOP, PgUp/F12=RESTORE", + " Ctrl=C= requires the sudo /dev/input path; raw-terminal fallback does not synthesize it", +) +LOW_LEVEL_SPECIAL_KEY_HELP_LINES = ( + "low-level keyboard: Linux keys map directly to C64 keys, including real L-SHIFT/R-SHIFT", + " Tab=CTRL, Ctrl=C=, arrows=cursor, Home=CLR/HOME, Backspace/Delete/Insert=INST/DEL", + " End/PgDn=RUN/STOP, PgUp/F12=RESTORE, F1-F8=F1-F8, Esc=release_all, Ctrl+Esc=quit", +) +DIRECT_KEY_SEQUENCE_MAP: Dict[str, List[str]] = { + "\x1bOP": ["f1"], + "\x1b[11~": ["f1"], + "\x1b[[A": ["f1"], + "\x1bOQ": ["left_shift", "f1"], + "\x1b[12~": ["left_shift", "f1"], + "\x1b[[B": ["left_shift", "f1"], + "\x1bOR": ["f3"], + "\x1b[13~": ["f3"], + "\x1b[[C": ["f3"], + "\x1bOS": ["left_shift", "f3"], + "\x1b[14~": ["left_shift", "f3"], + "\x1b[[D": ["left_shift", "f3"], + "\x1b[15~": ["f5"], + "\x1b[17~": ["left_shift", "f5"], + "\x1b[18~": ["f7"], + "\x1b[19~": ["left_shift", "f7"], + "\x1b[H": ["clr_home"], + "\x1bOH": ["clr_home"], + "\x1b[1~": ["clr_home"], + "\x1b[7~": ["clr_home"], + "\x1b[2~": ["inst_del"], + "\x1b[3~": ["inst_del"], + "\x1b[F": ["run_stop"], + "\x1bOF": ["run_stop"], + "\x1b[4~": ["run_stop"], + "\x1b[6~": ["run_stop"], + "\x1b[8~": ["run_stop"], + "\x1b[5~": ["restore"], + "\x1b[24~": ["restore"], +} +ESCAPE_SEQUENCE_TIMEOUT = float(os.environ.get("U64_INPUT_ESCAPE_TIMEOUT", "0.10")) MAX_BATCH_EVENTS = 64 +KEYBOARD_TAP_BATCH_EVENTS = max(1, int(os.environ.get("U64_INPUT_KEYBOARD_TAP_BATCH_EVENTS", "10"))) BATCH_PACE_MS = int(os.environ.get("U64_INPUT_PACE_MS", "10")) -KEY_REPEAT_HZ = float(os.environ.get("U64_INPUT_REPEAT_HZ", "50.0")) -KEY_REPEAT_INTERVAL = 1.0 / KEY_REPEAT_HZ if KEY_REPEAT_HZ > 0 else 0.05 -KEY_REPEAT_PACE_MS = int(os.environ.get("U64_INPUT_REPEAT_PACE_MS", "20")) -KEY_REPEAT_MAX_BURST = max(1, int(os.environ.get("U64_INPUT_REPEAT_MAX_BURST", "6"))) +REPEAT_BATCH_EVENTS = max(1, int(os.environ.get("U64_INPUT_REPEAT_BATCH_EVENTS", "6"))) +REPEAT_BATCH_TARGET_HZ = float(os.environ.get("U64_INPUT_REPEAT_HZ", "50.0")) +REPEAT_BATCH_INTERVAL = (float(REPEAT_BATCH_EVENTS) / REPEAT_BATCH_TARGET_HZ) if REPEAT_BATCH_TARGET_HZ > 0 else 0.06 +REPEATED_KEYBOARD_TAP_PACE_MS = max(0, int(os.environ.get("U64_INPUT_REPEAT_PACE_MS", "80"))) KEY_REPEAT_STALE_SECONDS = float(os.environ.get("U64_INPUT_REPEAT_STALE", "0.25")) -KEY_REPEAT_CONFIRM_COUNT = max(2, int(os.environ.get("U64_INPUT_REPEAT_CONFIRM", "3"))) +KEY_REPEAT_TRIGGER_SECONDS = float(os.environ.get("U64_INPUT_REPEAT_TRIGGER", "0.50")) +KEY_REPEAT_CONFIRM_COUNT = max(2, int(os.environ.get("U64_INPUT_REPEAT_CONFIRM", "4"))) +KEY_REPEAT_CADENCE_SECONDS = float(os.environ.get("U64_INPUT_REPEAT_CADENCE", "0.10")) +KEY_REPEAT_CADENCE_CONFIRM_COUNT = 2 GAMEPAD_AXIS_THRESHOLD = int(os.environ.get("U64_INPUT_GAMEPAD_AXIS_THRESHOLD", "12000")) GAMEPAD_FIRE_REPEAT_HZ = float(os.environ.get("U64_INPUT_GAMEPAD_FIRE_REPEAT_HZ", "10.0")) QUIT_SEQUENCES = ("\x03", "\x04") -JOYSTICK_INPUT_ORDER = ["up", "down", "left", "right", "fire"] +JOYSTICK_INPUT_ORDER = ["up", "down", "left", "right", "fire", "fire2", "fire3"] INPUT_EVENT_STRUCT = struct.Struct("llHHi") EV_KEY = 0x01 EV_ABS = 0x03 +EVIOCGRAB = 0x40044590 ABS_X = 0x00 ABS_Y = 0x01 ABS_RX = 0x03 @@ -78,7 +128,179 @@ ABS_HAT0Y = 0x11 BTN_SOUTH = 0x130 BTN_EAST = 0x131 +BTN_C = 0x132 +BTN_NORTH = 0x133 +BTN_WEST = 0x134 +BTN_TRIGGER = 0x120 +BTN_THUMB = 0x121 +BTN_THUMB2 = 0x122 +BTN_TOP = 0x123 GAMEPAD_NAME_KEYWORDS = ("xbox", "x-box", "controller", "gamepad", "joypad", "pad") +KEYBOARD_NAME_EXCLUSIONS = ("system control", "consumer control", "power button", "sleep button", "video bus") +KEY_ESC = 1 +KEY_1 = 2 +KEY_2 = 3 +KEY_3 = 4 +KEY_4 = 5 +KEY_5 = 6 +KEY_6 = 7 +KEY_7 = 8 +KEY_8 = 9 +KEY_9 = 10 +KEY_0 = 11 +KEY_MINUS = 12 +KEY_EQUAL = 13 +KEY_BACKSPACE = 14 +KEY_TAB = 15 +KEY_Q = 16 +KEY_W = 17 +KEY_E = 18 +KEY_R = 19 +KEY_T = 20 +KEY_Y = 21 +KEY_U = 22 +KEY_I = 23 +KEY_O = 24 +KEY_P = 25 +KEY_LEFTBRACE = 26 +KEY_RIGHTBRACE = 27 +KEY_ENTER = 28 +KEY_LEFTCTRL = 29 +KEY_A = 30 +KEY_S = 31 +KEY_D = 32 +KEY_F = 33 +KEY_G = 34 +KEY_H = 35 +KEY_J = 36 +KEY_K = 37 +KEY_L = 38 +KEY_SEMICOLON = 39 +KEY_APOSTROPHE = 40 +KEY_GRAVE = 41 +KEY_LEFTSHIFT = 42 +KEY_BACKSLASH = 43 +KEY_Z = 44 +KEY_X = 45 +KEY_C = 46 +KEY_V = 47 +KEY_B = 48 +KEY_N = 49 +KEY_M = 50 +KEY_COMMA = 51 +KEY_DOT = 52 +KEY_SLASH = 53 +KEY_RIGHTSHIFT = 54 +KEY_LEFTALT = 56 +KEY_SPACE = 57 +KEY_F1 = 59 +KEY_F2 = 60 +KEY_F3 = 61 +KEY_F4 = 62 +KEY_F5 = 63 +KEY_F6 = 64 +KEY_F7 = 65 +KEY_F8 = 66 +KEY_F9 = 67 +KEY_F10 = 68 +KEY_F11 = 87 +KEY_F12 = 88 +KEY_RIGHTCTRL = 97 +KEY_RIGHTALT = 100 +KEY_HOME = 102 +KEY_UP = 103 +KEY_PAGEUP = 104 +KEY_LEFT = 105 +KEY_RIGHT = 106 +KEY_END = 107 +KEY_DOWN = 108 +KEY_PAGEDOWN = 109 +KEY_INSERT = 110 +KEY_DELETE = 111 +LETTER_KEYCODES: Dict[int, str] = { + KEY_A: "a", KEY_B: "b", KEY_C: "c", KEY_D: "d", KEY_E: "e", KEY_F: "f", KEY_G: "g", KEY_H: "h", + KEY_I: "i", KEY_J: "j", KEY_K: "k", KEY_L: "l", KEY_M: "m", KEY_N: "n", KEY_O: "o", KEY_P: "p", + KEY_Q: "q", KEY_R: "r", KEY_S: "s", KEY_T: "t", KEY_U: "u", KEY_V: "v", KEY_W: "w", KEY_X: "x", + KEY_Y: "y", KEY_Z: "z", +} +DIGIT_KEYCODES: Dict[int, str] = { + KEY_0: "0", KEY_1: "1", KEY_2: "2", KEY_3: "3", KEY_4: "4", + KEY_5: "5", KEY_6: "6", KEY_7: "7", KEY_8: "8", KEY_9: "9", +} +KEYCODE_CHAR_MAP: Dict[int, Tuple[str, str]] = { + KEY_1: ("1", "!"), + KEY_2: ("2", "@"), + KEY_3: ("3", "#"), + KEY_4: ("4", "$"), + KEY_5: ("5", "%"), + KEY_6: ("6", "^"), + KEY_7: ("7", "&"), + KEY_8: ("8", "*"), + KEY_9: ("9", "("), + KEY_0: ("0", ")"), + KEY_MINUS: ("-", "_"), + KEY_EQUAL: ("=", "+"), + KEY_LEFTBRACE: ("[", "{"), + KEY_RIGHTBRACE: ("]", "}"), + KEY_BACKSLASH: ("\\", "|"), + KEY_SEMICOLON: (";", ":"), + KEY_APOSTROPHE: ("'", "\""), + KEY_GRAVE: ("`", "~"), + KEY_COMMA: (",", "<"), + KEY_DOT: (".", ">"), + KEY_SLASH: ("/", "?"), + KEY_SPACE: (" ", " "), +} +KEYCODE_BASE_INPUT_MAP: Dict[int, str] = { + **LETTER_KEYCODES, + **DIGIT_KEYCODES, + KEY_MINUS: "minus", + KEY_EQUAL: "equals", + KEY_SEMICOLON: "semicolon", + KEY_COMMA: "comma", + KEY_DOT: "period", + KEY_SLASH: "slash", +} +ARROW_KEYCODE_DIRECTION = { + KEY_UP: "up", + KEY_DOWN: "down", + KEY_LEFT: "left", + KEY_RIGHT: "right", +} +LOW_LEVEL_DIRECT_KEY_INPUTS: Dict[int, str] = { + **LETTER_KEYCODES, + **DIGIT_KEYCODES, + KEY_MINUS: "minus", + KEY_EQUAL: "equals", + KEY_SEMICOLON: "semicolon", + KEY_COMMA: "comma", + KEY_DOT: "period", + KEY_SLASH: "slash", + KEY_SPACE: "space", + KEY_ENTER: "return", + KEY_BACKSPACE: "inst_del", + KEY_DELETE: "inst_del", + KEY_INSERT: "inst_del", + KEY_HOME: "clr_home", + KEY_END: "run_stop", + KEY_PAGEDOWN: "run_stop", + KEY_PAGEUP: "restore", + KEY_RIGHT: "cursor_left_right", + KEY_DOWN: "cursor_up_down", + KEY_F1: "f1", + KEY_F3: "f3", + KEY_F5: "f5", + KEY_F7: "f7", + KEY_F12: "restore", +} +LOW_LEVEL_SHIFTED_KEY_INPUTS: Dict[int, str] = { + KEY_LEFT: "cursor_left_right", + KEY_UP: "cursor_up_down", + KEY_F2: "f1", + KEY_F4: "f3", + KEY_F6: "f5", + KEY_F8: "f7", +} @contextmanager @@ -92,14 +314,18 @@ def raw_terminal(): termios.tcsetattr(fd, termios.TCSADRAIN, old) -def read_key_sequence(timeout: float = 0.05) -> str: +def read_key_sequence(timeout: float = ESCAPE_SEQUENCE_TIMEOUT) -> str: ch = sys.stdin.read(1) if ch != "\x1b": return ch seq = ch while select.select([sys.stdin], [], [], timeout)[0]: seq += sys.stdin.read(1) - if seq.endswith("~") or (len(seq) >= 3 and seq[-1].isalpha()): + if ( + seq.endswith("~") + or (len(seq) >= 3 and seq[-1].isalpha()) + or (len(seq) >= 2 and not (seq.startswith("\x1b[") or seq.startswith("\x1bO"))) + ): break return seq @@ -135,6 +361,258 @@ def cursor_keyboard_event(direction: str) -> Dict[str, object]: return keyboard_event(["cursor_left_right"]) +def ordered_keyboard_inputs(inputs: List[str]) -> List[str]: + order = {"ctrl": 0, "commodore": 1, "left_shift": 2, "right_shift": 3} + return sorted(inputs, key=lambda item: (order.get(item, 10), item)) + + +def print_special_key_help(low_level_keyboard: bool = False) -> None: + lines = LOW_LEVEL_SPECIAL_KEY_HELP_LINES if low_level_keyboard else TERMINAL_SPECIAL_KEY_HELP_LINES + for line in lines: + print(line) + + +def keyboard_inputs_for_char(ch: str) -> Optional[List[str]]: + if len(ch) != 1: + return None + if "a" <= ch <= "z" or "0" <= ch <= "9": + return [ch] + if "A" <= ch <= "Z": + return ["left_shift", ch.lower()] + mapped = KEY_MAP.get(ch) + if mapped: + return [mapped] + return None + + +def incomplete_escape_sequence(seq: str) -> bool: + if not seq.startswith("\x1b"): + return False + if seq in ("\x1b", "\x1b[", "\x1bO"): + return True + if seq.startswith("\x1b["): + return not (seq.endswith("~") or (len(seq) >= 3 and seq[-1].isalpha())) + if seq.startswith("\x1bO"): + return not (len(seq) >= 3 and seq[-1].isalpha()) + return False + + +class SequenceDecoder: + def __init__(self) -> None: + self.escape_pending: Optional[str] = None + self.escape_pending_deadline = 0.0 + self.suppressed_literal: Optional[str] = None + self.suppressed_literal_deadline = 0.0 + + def clear(self) -> None: + self.escape_pending = None + self.escape_pending_deadline = 0.0 + self.suppressed_literal = None + self.suppressed_literal_deadline = 0.0 + + def timeout(self, now: float) -> Optional[float]: + if self.suppressed_literal is not None and now >= self.suppressed_literal_deadline: + self.suppressed_literal = None + self.suppressed_literal_deadline = 0.0 + if self.escape_pending is None: + return None + return max(0.0, self.escape_pending_deadline - now) + + def poll(self, now: float) -> Tuple[Optional[str], List[Dict[str, object]]]: + if self.escape_pending is None or now < self.escape_pending_deadline: + return None, [] + pending = self.escape_pending + self.escape_pending = None + self.escape_pending_deadline = 0.0 + if pending == "\x1b": + return "release_all", [release_all_event()] + return None, [] + + def translate(self, seq: str, joystick_port: int, now: float) -> Tuple[str, Optional[Dict[str, object]], Optional[str]]: + if self.suppressed_literal is not None: + if now < self.suppressed_literal_deadline and seq == self.suppressed_literal: + self.suppressed_literal = None + self.suppressed_literal_deadline = 0.0 + return "ignore", None, None + if now >= self.suppressed_literal_deadline: + self.suppressed_literal = None + self.suppressed_literal_deadline = 0.0 + if self.escape_pending is not None: + seq = self.escape_pending + seq + self.escape_pending = None + self.escape_pending_deadline = 0.0 + + if incomplete_escape_sequence(seq): + self.escape_pending = seq + self.escape_pending_deadline = now + ESCAPE_SEQUENCE_TIMEOUT + return "prefix", None, None + if seq in QUIT_SEQUENCES: + return "quit", None, None + event = translate_sequence(seq, joystick_port) + if not event: + return "ignore", None, None + if seq.startswith("\x1b") and seq[-1:] in ARROW_KEYS: + self.suppressed_literal = seq[-1] + self.suppressed_literal_deadline = now + 0.05 + if event.get("kind") == "release_all": + return "release_all", event, None + return "event", event, seq + + +def summarize_input_list(inputs: List[object]) -> str: + return "+".join(str(item) for item in inputs) if inputs else "-" + + +def summarize_event(event: Dict[str, object]) -> str: + kind = event.get("kind") + if kind == "release_all": + return "release_all" + inputs = summarize_input_list(list(event.get("inputs", []))) # type: ignore[arg-type] + transition = str(event.get("transition", "?")) + if kind == "keyboard": + return f"kbd:{transition}:{inputs}" + if kind == "joystick": + return f"joy{event.get('port', '?')}:{transition}:{inputs}" + return str(kind) + + +def summarize_events(events: List[Dict[str, object]], max_items: int = 5) -> str: + shown = [summarize_event(event) for event in events[:max_items]] + if len(events) > max_items: + shown.append(f"... +{len(events) - max_items}") + return ", ".join(shown) if shown else "-" + + +def summarize_json_response(response: Dict[str, Any]) -> str: + if not response: + return "empty" + if "keyboard" in response or "joysticks" in response: + keyboard = summarize_input_list(response.get("keyboard", {}).get("inputs", [])) + joysticks = response.get("joysticks", [{}, {}]) + joy1 = summarize_input_list(joysticks[0].get("inputs", [])) if len(joysticks) > 0 else "-" + joy2 = summarize_input_list(joysticks[1].get("inputs", [])) if len(joysticks) > 1 else "-" + return f"kb={keyboard} j1={joy1} j2={joy2}" + if "errors" in response: + return f"errors={len(response.get('errors', []))}" + keys = ",".join(sorted(response.keys())) + return keys or "json" + + +class RestCallLogger: + def __init__(self, level: int) -> None: + self.level = level + + def _emit(self, line: str) -> None: + if self.level <= 0: + return + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + sys.stdout.write(f"\r{timestamp} {line}\x1b[K\r\n") + sys.stdout.flush() + + def _serialize_verbatim(self, payload: Optional[Any], content_type: Optional[str]) -> str: + if payload is None: + return "-" + if isinstance(payload, (bytes, bytearray)): + if content_type == "application/json": + try: + return bytes(payload).decode("utf-8") + except UnicodeDecodeError: + return bytes(payload).hex() + return bytes(payload).hex() + if isinstance(payload, str): + return payload + if isinstance(payload, (dict, list)): + return json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + return str(payload) + + def _format_request(self, method: str, path: str, payload: Optional[Any], content_type: Optional[str]) -> str: + if path == "/v1/machine:input" and isinstance(payload, dict): + events = payload.get("events", []) + if isinstance(events, list): + pace_ms = payload.get("pace_ms") + suffix = f" pace={pace_ms}" if pace_ms is not None else "" + max_items = len(events) if self.level >= 2 else 5 + return f"{method} {path} events={len(events)}{suffix} [{summarize_events(events, max_items=max_items)}]" + if isinstance(payload, dict): + keys = ",".join(sorted(payload.keys())) + return f"{method} {path} json={keys or '-'}" + if isinstance(payload, (bytes, bytearray)): + return f"{method} {path} body={len(payload)}B type={content_type or '-'}" + return f"{method} {path}" + + def log_success( + self, + method: str, + path: str, + payload: Optional[Any], + response: Optional[Any], + elapsed_ms: float, + content_type: Optional[str] = None, + status: Optional[int] = None, + ) -> None: + if self.level <= 0: + return + request = self._format_request(method, path, payload, content_type) + response_text = "" + raw_response = "" + if isinstance(response, dict): + response_text = summarize_json_response(response) + raw_response = self._serialize_verbatim(response, content_type) + elif isinstance(response, (bytes, bytearray)): + stripped = bytes(response).strip() + if stripped.startswith(b"{") and stripped.endswith(b"}"): + try: + decoded_json = json.loads(stripped.decode("utf-8")) + response_text = summarize_json_response(decoded_json) + raw_response = stripped.decode("utf-8") + except Exception: + response_text = f"{len(response)}B" + raw_response = bytes(response).hex() + else: + response_text = f"{len(response)}B" + raw_response = bytes(response).hex() + elif response is not None: + response_text = str(response) + raw_response = str(response) + status_text = f" -> {status}" if status is not None else "" + tail = f" {response_text}" if response_text else "" + self._emit(f"[rest] {request}{status_text}{tail} ({elapsed_ms:.1f} ms)") + if self.level >= 2: + self._emit(f"[rest] >>> {self._serialize_verbatim(payload, content_type)}") + if raw_response: + self._emit(f"[rest] <<< {status if status is not None else '-'} {raw_response}") + + def log_error( + self, + method: str, + path: str, + payload: Optional[Any], + error: BaseException, + elapsed_ms: float, + content_type: Optional[str] = None, + status: Optional[int] = None, + ) -> None: + if self.level <= 0: + return + request = self._format_request(method, path, payload, content_type) + status_text = f" -> {status}" if status is not None else "" + self._emit(f"[rest] {request}{status_text} ERROR {format_exception(error)} ({elapsed_ms:.1f} ms)") + if self.level >= 2: + self._emit(f"[rest] >>> {self._serialize_verbatim(payload, content_type)}") + + +class InputDeviceOpenError(OSError): + def __init__(self, device_kind: str, path: str, error: OSError) -> None: + self.device_kind = device_kind + self.path = path + self.original_error = error + super().__init__(error.errno, error.strerror, error.filename) + + @property + def permission_denied(self) -> bool: + return self.errno in (errno.EACCES, errno.EPERM) + + class GamepadState: def __init__( self, @@ -152,8 +630,8 @@ def __init__( self.hat_x = 0 self.hat_y = 0 self.a_pressed = False - self.b_pressed = False - self.repeat_fire_at: Optional[float] = None + self.fire2_pressed = False + self.fire3_pressed = False self.logical_inputs: Set[str] = set() def _ordered(self, inputs: Set[str]) -> List[str]: @@ -179,6 +657,10 @@ def _recompute_logical_inputs(self) -> List[Dict[str, object]]: ) if self.a_pressed: new_inputs.add("fire") + if self.fire2_pressed: + new_inputs.add("fire2") + if self.fire3_pressed: + new_inputs.add("fire3") released = self.logical_inputs - new_inputs pressed = new_inputs - self.logical_inputs @@ -226,34 +708,29 @@ def apply_input_event(self, event_type: int, code: int, value: int, now: float) if event_type != EV_KEY: return [] - if code == BTN_SOUTH: + if code in (BTN_SOUTH, BTN_TRIGGER): self.a_pressed = value != 0 return self._recompute_logical_inputs() - if code == BTN_EAST: - pressed = value != 0 - if pressed and not self.b_pressed: - self.b_pressed = True - self.repeat_fire_at = now + self.fire_repeat_interval - return [joystick_event(self.port, ["fire"])] - if not pressed: - self.b_pressed = False - self.repeat_fire_at = None - return [] + if code in (BTN_NORTH, BTN_THUMB2): + self.a_pressed = value != 0 + return self._recompute_logical_inputs() + + if code in (BTN_EAST, BTN_THUMB): + self.fire2_pressed = value != 0 + return self._recompute_logical_inputs() + + if code in (BTN_WEST, BTN_TOP, BTN_C): + self.fire3_pressed = value != 0 + return self._recompute_logical_inputs() return [] def poll_repeat_fire(self, now: float) -> Optional[Dict[str, object]]: - if not self.b_pressed or self.repeat_fire_at is None or now < self.repeat_fire_at: - return None - while self.repeat_fire_at is not None and now >= self.repeat_fire_at: - self.repeat_fire_at += self.fire_repeat_interval - return joystick_event(self.port, ["fire"]) + return None def repeat_timeout(self, now: float) -> Optional[float]: - if not self.b_pressed or self.repeat_fire_at is None: - return None - return max(0.0, self.repeat_fire_at - now) + return None class GamepadDevice: @@ -287,12 +764,12 @@ def read_events(self, now: float) -> List[Dict[str, object]]: class InteractiveRestClient: - def __init__(self, session: RestInputSession) -> None: + def __init__(self, session: RestInputSession, logger: Optional[RestCallLogger] = None) -> None: self.host = session.host self.password = session.password self.timeout = session.timeout self._connection: Optional[http.client.HTTPConnection] = None - self.last_request_duration = max(KEY_REPEAT_INTERVAL, 0.08) + self.logger = logger def close(self) -> None: if self._connection is None: @@ -321,20 +798,75 @@ def post_events(self, events: List[Dict[str, object]], pace_ms: Optional[int] = headers["X-Password"] = self.password for attempt in range(2): connection = self._connect() - started_at = time.monotonic() + started = time.monotonic() try: connection.request("POST", "/v1/machine:input", body=body, headers=headers) response = connection.getresponse() data = response.read() - self.last_request_duration = max(time.monotonic() - started_at, KEY_REPEAT_INTERVAL) + elapsed_ms = (time.monotonic() - started) * 1000.0 if response.status >= 400: + if self.logger: + self.logger.log_error( + "POST", + "/v1/machine:input", + payload, + Failure(f"HTTP {response.status}"), + elapsed_ms, + content_type="application/json", + status=response.status, + ) self.close() text = data.decode("utf-8", errors="replace").strip() raise Failure(f"REST input POST failed with HTTP {response.status}: {text}") if not data: + if self.logger: + self.logger.log_success( + "POST", + "/v1/machine:input", + payload, + {}, + elapsed_ms, + content_type="application/json", + status=response.status, + ) return {} - return json.loads(data.decode("utf-8")) + decoded = json.loads(data.decode("utf-8")) + if self.logger: + self.logger.log_success( + "POST", + "/v1/machine:input", + payload, + decoded, + elapsed_ms, + content_type="application/json", + status=response.status, + ) + return decoded + except urllib.error.HTTPError as exc: + if self.logger: + self.logger.log_error( + "POST", + "/v1/machine:input", + payload, + exc, + (time.monotonic() - started) * 1000.0, + content_type="application/json", + status=exc.code, + ) + self.close() + if attempt == 0: + continue + raise except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc: + if self.logger: + self.logger.log_error( + "POST", + "/v1/machine:input", + payload, + exc, + (time.monotonic() - started) * 1000.0, + content_type="application/json", + ) self.close() if attempt == 0: continue @@ -342,6 +874,43 @@ def post_events(self, events: List[Dict[str, object]], pace_ms: Optional[int] = raise Failure("Interactive REST request failed.") +class VerboseRestSession(RestInputSession): + def __init__(self, host: str, password: Optional[str], timeout: float, logger: RestCallLogger) -> None: + super().__init__(host, password, timeout) + self.logger = logger + + def request( + self, + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + body: Optional[bytes] = None, + content_type: Optional[str] = "application/json", + ) -> bytes: + started = time.monotonic() + payload: Optional[Any] = None + if content_type == "application/json" and body: + try: + payload = json.loads(body.decode("utf-8")) + except Exception: + payload = body + elif body is not None: + payload = body + full_path = path + if params: + full_path = self.url(path, params).replace(f"http://{self.host}", "", 1) + try: + data = super().request(method, path, params=params, body=body, content_type=content_type) + self.logger.log_success(method, full_path, payload, data, (time.monotonic() - started) * 1000.0, content_type=content_type) + return data + except urllib.error.HTTPError as exc: + self.logger.log_error(method, full_path, payload, exc, (time.monotonic() - started) * 1000.0, content_type=content_type, status=exc.code) + raise + except (OSError, TimeoutError, urllib.error.URLError, http.client.HTTPException) as exc: + self.logger.log_error(method, full_path, payload, exc, (time.monotonic() - started) * 1000.0, content_type=content_type) + raise + + class RepeatState: def __init__(self) -> None: self.sequence: Optional[str] = None @@ -349,8 +918,10 @@ def __init__(self) -> None: self.event: Optional[Dict[str, object]] = None self.count = 0 self.confirmed = False + self.first_seen_at = 0.0 self.last_seen_at = 0.0 - self.next_emit_at = 0.0 + self.next_batch_at = 0.0 + self.fast_cadence_count = 0 def clear(self) -> None: self.sequence = None @@ -358,8 +929,10 @@ def clear(self) -> None: self.event = None self.count = 0 self.confirmed = False + self.first_seen_at = 0.0 self.last_seen_at = 0.0 - self.next_emit_at = 0.0 + self.next_batch_at = 0.0 + self.fast_cadence_count = 0 def _active(self) -> bool: return self.event is not None and self.sequence is not None and self.signature is not None @@ -368,50 +941,95 @@ def timeout(self, now: float) -> Optional[float]: if not self._active(): return None stale_deadline = self.last_seen_at + KEY_REPEAT_STALE_SECONDS + if not self.confirmed and self.count >= KEY_REPEAT_CONFIRM_COUNT: + return max(0.0, min(stale_deadline, self.first_seen_at + KEY_REPEAT_TRIGGER_SECONDS) - now) if self.confirmed: - deadline = min(stale_deadline, self.next_emit_at) - else: - deadline = stale_deadline - return max(0.0, deadline - now) + return max(0.0, min(stale_deadline, self.next_batch_at) - now) + return max(0.0, stale_deadline - now) + + def _repeatable(self, event: Dict[str, object]) -> bool: + return ( + event.get("kind") == "keyboard" + and event.get("transition") == "tap" + and "restore" not in event.get("inputs", []) + ) + + def stop(self) -> None: + self.clear() + + def _start( + self, + sequence: str, + signature: Tuple[object, object, Tuple[object, ...], object], + event: Dict[str, object], + now: float, + ) -> None: + self.sequence = sequence + self.signature = signature + self.event = event + self.count = 1 + self.confirmed = False + self.first_seen_at = now + self.last_seen_at = now + self.next_batch_at = 0.0 + self.fast_cadence_count = 0 - def observe(self, sequence: str, event: Dict[str, object], now: float) -> bool: - repeatable = event.get("transition") == "tap" and event.get("kind") in ("keyboard", "joystick") - if not repeatable: + def observe(self, sequence: str, event: Dict[str, object], now: float) -> Tuple[bool, List[Dict[str, object]]]: + if not self._repeatable(event): self.clear() - return False + return False, [] signature = event_signature(event) if self.sequence == sequence and self.signature == signature: + interval = now - self.last_seen_at + if not self.confirmed and interval > KEY_REPEAT_CADENCE_SECONDS: + self.clear() + self._start(sequence, signature, event, now) + return False, [] self.count += 1 self.last_seen_at = now + if interval <= KEY_REPEAT_CADENCE_SECONDS: + self.fast_cadence_count += 1 + else: + self.fast_cadence_count = 0 if self.confirmed: - return True - if self.count >= KEY_REPEAT_CONFIRM_COUNT: + return True, [] + if ( + self.count >= KEY_REPEAT_CONFIRM_COUNT + and self.fast_cadence_count >= KEY_REPEAT_CADENCE_CONFIRM_COUNT + and (now - self.first_seen_at) >= KEY_REPEAT_TRIGGER_SECONDS + ): self.confirmed = True - self.next_emit_at = now + KEY_REPEAT_INTERVAL - return True - return False + self.next_batch_at = now + REPEAT_BATCH_INTERVAL + return True, [self.event] * REPEAT_BATCH_EVENTS if self.event is not None else [] + return True, [] - self.sequence = sequence - self.signature = signature - self.event = event - self.count = 1 - self.confirmed = False - self.last_seen_at = now - self.next_emit_at = 0.0 - return False + self.clear() + self._start(sequence, signature, event, now) + return False, [] - def poll(self, now: float) -> Optional[Dict[str, object]]: + def poll(self, now: float) -> List[Dict[str, object]]: if not self._active(): - return None + return [] if (now - self.last_seen_at) > KEY_REPEAT_STALE_SECONDS: self.clear() - return None - if not self.confirmed or now < self.next_emit_at or self.event is None: - return None - while self.next_emit_at <= now: - self.next_emit_at += KEY_REPEAT_INTERVAL - return self.event + return [] + if not self.confirmed: + if ( + self.event is None + or self.count < KEY_REPEAT_CONFIRM_COUNT + or self.fast_cadence_count < KEY_REPEAT_CADENCE_CONFIRM_COUNT + or now < (self.first_seen_at + KEY_REPEAT_TRIGGER_SECONDS) + ): + return [] + self.confirmed = True + self.next_batch_at = now + REPEAT_BATCH_INTERVAL + return [self.event] * REPEAT_BATCH_EVENTS + if self.event is None or now < self.next_batch_at: + return [] + while self.next_batch_at <= now: + self.next_batch_at += REPEAT_BATCH_INTERVAL + return [self.event] * REPEAT_BATCH_EVENTS def combine_timeout(*timeouts: Optional[float]) -> Optional[float]: @@ -428,50 +1046,42 @@ def drain_stdin_sequences(stdin_fd: int) -> List[str]: return sequences -def flush_event_batch(client: InteractiveRestClient, events: List[Dict[str, object]]) -> None: - while events: - chunk = events[:MAX_BATCH_EVENTS] - client.post_events(chunk) - del events[:MAX_BATCH_EVENTS] - - -def flush_repeat_event(client: InteractiveRestClient, event: Dict[str, object]) -> None: - if event.get("kind") != "keyboard" or event.get("transition") != "tap": - client.post_events([event]) - return +def keyboard_batch_pace_ms(events: List[Dict[str, object]]) -> int: + if not events: + return 0 + if not all(event.get("kind") == "keyboard" and event.get("transition") == "tap" for event in events): + return 0 + if len(events) <= 1: + return 0 + first_signature = event_signature(events[0]) + for event in events[1:]: + if event_signature(event) != first_signature: + return 0 + return REPEATED_KEYBOARD_TAP_PACE_MS - burst = max(1, min(KEY_REPEAT_MAX_BURST, int(math.ceil(client.last_request_duration * KEY_REPEAT_HZ)))) - if burst == 1: - client.post_events([event]) - return - client.post_events([event] * burst, pace_ms=KEY_REPEAT_PACE_MS) +def flush_event_batch(client: InteractiveRestClient, events: List[Dict[str, object]]) -> None: + while events: + keyboard_taps_only = all(event.get("kind") == "keyboard" and event.get("transition") == "tap" for event in events) + chunk_size = KEYBOARD_TAP_BATCH_EVENTS if keyboard_taps_only else MAX_BATCH_EVENTS + chunk = events[:chunk_size] + pace_ms = keyboard_batch_pace_ms(chunk) if keyboard_taps_only else None + client.post_events(chunk, pace_ms=pace_ms) + del events[:chunk_size] def translate_sequence(seq: str, joystick_port: int) -> Optional[Dict[str, object]]: + inputs = DIRECT_KEY_SEQUENCE_MAP.get(seq) + if inputs: + return keyboard_event(inputs) if seq == "\x1b": return release_all_event() - if seq == "\n": - return joystick_event(joystick_port, ["fire"]) - if seq == "\x1b[3~": - return keyboard_event(["inst_del"]) - if seq.startswith("\x1b["): - if seq in ("\x1b[13;5u", "\x1b[27;5;13~"): - return joystick_event(joystick_port, ["fire"]) - if (seq.startswith("\x1b[") or seq.startswith("\x1bO")) and seq[-1:] in ARROW_KEYS: + if (seq.startswith("\x1b[") or seq.startswith("\x1bO")) and ";" not in seq and seq[-1:] in ARROW_KEYS: direction = ARROW_KEYS[seq[-1]] - if ";5" in seq: - return joystick_event(joystick_port, [direction]) return cursor_keyboard_event(direction) - if len(seq) != 1: - return None - if "a" <= seq <= "z" or "0" <= seq <= "9": - return keyboard_event([seq]) - if "A" <= seq <= "Z": - return keyboard_event(["left_shift", seq.lower()]) - mapped = KEY_MAP.get(seq) - if mapped: - return keyboard_event([mapped]) + inputs = keyboard_inputs_for_char(seq) + if inputs: + return keyboard_event(inputs) return None @@ -487,6 +1097,220 @@ def input_event_name(path: str) -> str: return "" +def input_event_path(path: str) -> str: + try: + return os.path.realpath(path) + except OSError: + return path + + +def keyboard_device_rank(path: str) -> Tuple[int, str, str]: + name = input_event_name(path).lower() + basename = os.path.basename(path).lower() + score = 0 + if basename.endswith("-event-kbd"): + score += 200 + if name: + score += 20 + if "keyboard" in name or "kbd" in name: + score += 100 + if any(keyword in name for keyword in KEYBOARD_NAME_EXCLUSIONS): + score -= 200 + if "mouse" in name or "joystick" in name or "gamepad" in name: + score -= 200 + return (-score, name, path) + + +def find_default_keyboard_device() -> Optional[str]: + candidates = list(glob.glob("/dev/input/by-id/*-event-kbd")) + candidates.extend(glob.glob("/dev/input/by-path/*-event-kbd")) + deduped: List[str] = [] + seen: Set[str] = set() + for path in candidates: + resolved = input_event_path(path) + if resolved in seen: + continue + seen.add(resolved) + deduped.append(path) + if deduped: + return sorted(deduped, key=keyboard_device_rank)[0] + fallback = sorted(glob.glob("/dev/input/event*")) + filtered = [] + for path in fallback: + name = input_event_name(path).lower() + if not name: + continue + if any(keyword in name for keyword in KEYBOARD_NAME_EXCLUSIONS): + continue + if "keyboard" in name or "kbd" in name: + filtered.append(path) + if filtered: + return sorted(filtered, key=keyboard_device_rank)[0] + return None + + +def keyboard_state_event(inputs: List[str], transition: str) -> Dict[str, object]: + return {"kind": "keyboard", "inputs": inputs, "transition": transition} + + +class HeldLogicalKey: + def __init__(self, input_name: str) -> None: + self.input_name = input_name + self.sources: Set[object] = set() + + def active(self) -> bool: + return bool(self.sources) + + def press(self, source: object) -> Optional[Dict[str, object]]: + if source in self.sources: + return None + was_active = self.active() + self.sources.add(source) + if not was_active: + return keyboard_state_event([self.input_name], "press") + return None + + def release(self, source: object) -> Optional[Dict[str, object]]: + if source not in self.sources: + return None + self.sources.remove(source) + if not self.active(): + return keyboard_state_event([self.input_name], "release") + return None + + +class LowLevelKeyboardState: + def __init__(self, joystick_port: int) -> None: + self.joystick_port = joystick_port + self.left_shift = HeldLogicalKey("left_shift") + self.right_shift = HeldLogicalKey("right_shift") + self.ctrl = HeldLogicalKey("ctrl") + self.commodore = HeldLogicalKey("commodore") + + def _shift_active(self) -> bool: + return self.left_shift.active() or self.right_shift.active() + + def _press_modifier(self, code: int) -> Optional[Dict[str, object]]: + if code == KEY_LEFTSHIFT: + return self.left_shift.press(("phys", code)) + if code == KEY_RIGHTSHIFT: + return self.right_shift.press(("phys", code)) + if code == KEY_LEFTCTRL: + return self.commodore.press(("phys", code)) + if code == KEY_RIGHTCTRL: + return self.commodore.press(("phys", code)) + if code == KEY_TAB: + return self.ctrl.press(("phys", code)) + return None + + def _release_modifier(self, code: int) -> Optional[Dict[str, object]]: + if code == KEY_LEFTSHIFT: + return self.left_shift.release(("phys", code)) + if code == KEY_RIGHTSHIFT: + return self.right_shift.release(("phys", code)) + if code == KEY_LEFTCTRL: + return self.commodore.release(("phys", code)) + if code == KEY_RIGHTCTRL: + return self.commodore.release(("phys", code)) + if code == KEY_TAB: + return self.ctrl.release(("phys", code)) + return None + + def _press_mapped_key(self, code: int) -> List[Dict[str, object]]: + direct_input = LOW_LEVEL_DIRECT_KEY_INPUTS.get(code) + if direct_input is not None: + return [keyboard_state_event([direct_input], "press")] + + shifted_input = LOW_LEVEL_SHIFTED_KEY_INPUTS.get(code) + if shifted_input is not None: + events: List[Dict[str, object]] = [] + if not self._shift_active(): + event = self.left_shift.press(("synthetic", code)) + if event is not None: + events.append(event) + events.append(keyboard_state_event([shifted_input], "press")) + return events + return [] + + def _release_mapped_key(self, code: int) -> List[Dict[str, object]]: + direct_input = LOW_LEVEL_DIRECT_KEY_INPUTS.get(code) + if direct_input is not None: + return [keyboard_state_event([direct_input], "release")] + + shifted_input = LOW_LEVEL_SHIFTED_KEY_INPUTS.get(code) + if shifted_input is not None: + events: List[Dict[str, object]] = [keyboard_state_event([shifted_input], "release")] + event = self.left_shift.release(("synthetic", code)) + if event is not None: + events.append(event) + return events + return [] + + def apply_input_event(self, code: int, value: int) -> Tuple[Optional[str], List[Dict[str, object]]]: + if value == 0: + event = self._release_modifier(code) + if event is not None: + return None, [event] + return None, self._release_mapped_key(code) + if value != 1: + return None, [] + modifier_event = self._press_modifier(code) + if modifier_event is not None: + return None, [modifier_event] + if code in (KEY_LEFTSHIFT, KEY_RIGHTSHIFT, KEY_LEFTCTRL, KEY_RIGHTCTRL, KEY_LEFTALT, KEY_RIGHTALT, KEY_TAB): + return None, [] + + if code == KEY_ESC and value == 1: + if self.commodore.active(): + return "quit", [] + return "release_all", [release_all_event()] + return None, self._press_mapped_key(code) + + +class KeyboardDevice: + def __init__(self, path: str, joystick_port: int) -> None: + self.path = path + self.fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) + self.state = LowLevelKeyboardState(joystick_port) + self.grabbed = False + try: + fcntl.ioctl(self.fd, EVIOCGRAB, 1) + self.grabbed = True + except OSError: + self.grabbed = False + + def fileno(self) -> int: + return self.fd + + def close(self) -> None: + try: + if self.grabbed: + fcntl.ioctl(self.fd, EVIOCGRAB, 0) + finally: + os.close(self.fd) + + def read_updates(self) -> List[Tuple[Optional[str], List[Dict[str, object]]]]: + updates: List[Tuple[Optional[str], List[Dict[str, object]]]] = [] + while True: + try: + data = os.read(self.fd, INPUT_EVENT_STRUCT.size * 64) + except BlockingIOError: + break + if not data: + break + limit = len(data) - (len(data) % INPUT_EVENT_STRUCT.size) + for offset in range(0, limit, INPUT_EVENT_STRUCT.size): + _, _, event_type, code, value = INPUT_EVENT_STRUCT.unpack_from(data, offset) + if event_type != EV_KEY: + continue + control, events = self.state.apply_input_event(code, value) + if control is not None or events: + updates.append((control, events)) + if len(data) < INPUT_EVENT_STRUCT.size * 64: + break + return updates + + def gamepad_device_rank(path: str) -> Tuple[int, str, str]: name = input_event_name(path).lower() basename = os.path.basename(path).lower() @@ -518,92 +1342,199 @@ def find_default_gamepad_device() -> Optional[str]: def open_gamepad(device_path: Optional[str], joystick_port: int) -> Optional[GamepadDevice]: + return open_gamepad_checked(device_path, joystick_port, tolerate_error=True) + + +def open_gamepad_checked(device_path: Optional[str], joystick_port: int, tolerate_error: bool = False) -> Optional[GamepadDevice]: path = device_path or find_default_gamepad_device() if not path: return None try: return GamepadDevice(path, joystick_port) - except OSError: + except OSError as exc: + if not tolerate_error: + raise InputDeviceOpenError("gamepad", path, exc) from exc + return None + + +def open_keyboard(device_path: Optional[str], joystick_port: int) -> Optional[KeyboardDevice]: + return open_keyboard_checked(device_path, joystick_port, tolerate_error=True) + + +def open_keyboard_checked(device_path: Optional[str], joystick_port: int, tolerate_error: bool = False) -> Optional[KeyboardDevice]: + path = device_path or find_default_keyboard_device() + if not path: + return None + try: + return KeyboardDevice(path, joystick_port) + except OSError as exc: + if not tolerate_error: + raise InputDeviceOpenError("keyboard", path, exc) from exc return None -def print_mapping_overview(host: str, joystick_port: int, gamepad: Optional[GamepadDevice]) -> None: +def preserved_input_tool_environment() -> List[str]: + names = sorted(name for name in os.environ if name.startswith("U64_INPUT_")) + if "C64U_PASSWORD" in os.environ: + names.append("C64U_PASSWORD") + return sorted(set(names)) + + +def build_sudo_reexec_command() -> List[str]: + command = ["sudo"] + preserved = preserved_input_tool_environment() + if preserved: + command.append("--preserve-env=" + ",".join(preserved)) + command.append(sys.executable) + command.append(os.path.abspath(__file__)) + command.extend(sys.argv[1:]) + return command + + +def can_prompt_for_sudo() -> bool: + return ( + os.geteuid() != 0 + and os.environ.get("U64_INPUT_TOOL_SUDO_ESCALATED") != "1" + and sys.stdin.isatty() + and sys.stdout.isatty() + and shutil.which("sudo") is not None + ) + + +def request_input_access_or_warn(error: InputDeviceOpenError) -> bool: + capability = "low-level keyboard capture" if error.device_kind == "keyboard" else "gamepad input" + if error.permission_denied and can_prompt_for_sudo(): + print(f"{error.device_kind} access denied: {error.path}") + print(f"{capability} needs read access to /dev/input event devices.") + answer = input("Re-run with sudo now to grant that access? [Y/n] ").strip().lower() + if answer in ("", "y", "yes"): + env = os.environ.copy() + env["U64_INPUT_TOOL_SUDO_ESCALATED"] = "1" + command = build_sudo_reexec_command() + try: + os.execvpe("sudo", command, env) + except OSError as exc: + raise Failure(f"Unable to re-run with sudo: {format_exception(exc)}") from exc + return False + if error.permission_denied: + print( + f"warning: {error.device_kind} access denied for {error.path}; " + f"continuing without {capability}. Re-run with sudo to enable it.", + file=sys.stderr, + ) + return False + raise Failure(f"Unable to open {error.device_kind} device {error.path}: {format_exception(error.original_error)}") + + +def print_mapping_overview( + host: str, + joystick_port: int, + keyboard: Optional[KeyboardDevice], + gamepad: Optional[GamepadDevice], + verbosity: int, +) -> None: print(f"REST input tool -> {host}") - print("keys: text=C64 keys; arrows=C64 cursor; Ctrl+arrows=joy; Ctrl+Enter/Ctrl+J=fire; Esc=release_all; Ctrl+C/Ctrl+D=quit") - print(f"interactive repeat: {KEY_REPEAT_HZ:.0f}/s without client backlog") + if keyboard: + print("keys: low-level Linux mapping; Ctrl=C=, Tab=CTRL, arrows/F1-F8/Home/etc map directly; Esc=release_all; Ctrl+Esc=quit") + else: + print("keys: terminal fallback; direct text/arrows/F1-F8/Home/Tab only; Esc=release_all") + print_special_key_help(low_level_keyboard=keyboard is not None) + if keyboard: + print("keyboard repeats: Linux low-level key-repeat events") + else: + if REPEATED_KEYBOARD_TAP_PACE_MS > 0: + print( + f"interactive hold queue: repeated same-key taps are paced at {REPEATED_KEYBOARD_TAP_PACE_MS} ms/event " + f"in batches up to {REPEAT_BATCH_EVENTS} events" + ) + else: + print(f"interactive hold queue: repeated same-key taps are sent in batches up to {REPEAT_BATCH_EVENTS} events") + print(f"rapid mixed-key batches: up to {KEYBOARD_TAP_BATCH_EVENTS} taps/request at pace_ms=0") print(f"paced multi-key batches: {BATCH_PACE_MS} ms between batched events") print(f"joystick port: {joystick_port}") + if verbosity >= 2: + verbose_text = "full" + elif verbosity == 1: + verbose_text = "concise" + else: + verbose_text = "off" + print(f"verbose REST logging: {verbose_text}") + if keyboard: + print(f"keyboard: {keyboard.path} (low-level Linux input, exclusive grab enabled)") + else: + print("keyboard: stdin raw-terminal fallback") if gamepad: - print(f"gamepad: {gamepad.path} (left stick, right stick, d-pad -> movement; A=fire; B=repeat fire)") + print(f"gamepad: {gamepad.path} (left stick, right stick, d-pad -> movement; A/X=fire; B=fire2; Y=fire3)") else: print("gamepad: not detected") -def classify_sequence(seq: str, joystick_port: int) -> Tuple[str, Optional[Dict[str, object]]]: - if seq in QUIT_SEQUENCES: - return "quit", None - event = translate_sequence(seq, joystick_port) - if not event: - return "ignore", None - if event.get("kind") == "release_all": - return "release_all", event - return "event", event - - def handle_interactive_sequence( joystick_port: int, seq: str, repeat_state: RepeatState, + decoder: SequenceDecoder, now: float, ) -> Tuple[Optional[str], List[Dict[str, object]]]: - action, event = classify_sequence(seq, joystick_port) - if action == "ignore": - return None, [] + action, event, repeat_sequence = decoder.translate(seq, joystick_port, now) + if action in ("ignore", "prefix"): + return None, repeat_state.poll(now) + if action == "help": + return "help", repeat_state.poll(now) if action == "quit": - repeat_state.clear() + repeat_state.stop() + decoder.clear() return "quit", [] if action == "release_all": - repeat_state.clear() + repeat_state.stop() + decoder.clear() return "release_all", [release_all_event()] if event is None: - return None, [] - if repeat_state.observe(seq, event, now): - return None, [] - return None, [event] + return None, repeat_state.poll(now) + suppress_original, extra_events = repeat_state.observe(repeat_sequence or seq, event, now) + if suppress_original: + return None, extra_events + return None, extra_events + [event] def run_interactive_loop( session: RestInputSession, joystick_port: int, + logger: Optional[RestCallLogger], ) -> None: stdin_fd = sys.stdin.fileno() - client = InteractiveRestClient(session) + client = InteractiveRestClient(session, logger=logger) repeat_state = RepeatState() + decoder = SequenceDecoder() try: while True: now = time.monotonic() - ready, _, _ = select.select([stdin_fd], [], [], repeat_state.timeout(now)) + ready, _, _ = select.select([stdin_fd], [], [], combine_timeout(repeat_state.timeout(now), decoder.timeout(now))) now = time.monotonic() pending_events: List[Dict[str, object]] = [] + control, events = decoder.poll(now) + if control == "release_all": + repeat_state.stop() + pending_events.extend(events) if ready: for seq in drain_stdin_sequences(stdin_fd): - control, events = handle_interactive_sequence(joystick_port, seq, repeat_state, time.monotonic()) + control, events = handle_interactive_sequence(joystick_port, seq, repeat_state, decoder, time.monotonic()) if events: pending_events.extend(events) if control == "release_all": if pending_events: flush_event_batch(client, pending_events) pending_events = [] + elif control == "help": + print_special_key_help() elif control == "quit": if pending_events: flush_event_batch(client, pending_events) client.post_events([release_all_event()]) return - repeat_event = repeat_state.poll(time.monotonic()) + pending_events.extend(repeat_state.poll(time.monotonic())) if pending_events: flush_event_batch(client, pending_events) - if repeat_event: - flush_repeat_event(client, repeat_event) finally: client.close() @@ -612,19 +1543,25 @@ def run_interactive_with_gamepad( session: RestInputSession, joystick_port: int, gamepad: GamepadDevice, + logger: Optional[RestCallLogger], ) -> None: stdin_fd = sys.stdin.fileno() gamepad_fd = gamepad.fileno() - client = InteractiveRestClient(session) + client = InteractiveRestClient(session, logger=logger) repeat_state = RepeatState() + decoder = SequenceDecoder() try: while True: now = time.monotonic() - timeout = combine_timeout(repeat_state.timeout(now), gamepad.state.repeat_timeout(now)) + timeout = combine_timeout(repeat_state.timeout(now), gamepad.state.repeat_timeout(now), decoder.timeout(now)) ready, _, _ = select.select([stdin_fd, gamepad_fd], [], [], timeout) now = time.monotonic() pending_events: List[Dict[str, object]] = [] + control, events = decoder.poll(now) + if control == "release_all": + repeat_state.stop() + pending_events.extend(events) if not ready: repeat_event = gamepad.state.poll_repeat_fire(now) if repeat_event: @@ -637,36 +1574,101 @@ def run_interactive_with_gamepad( pending_events.append(repeat_event) if stdin_fd in ready: for seq in drain_stdin_sequences(stdin_fd): - control, events = handle_interactive_sequence(joystick_port, seq, repeat_state, time.monotonic()) + control, events = handle_interactive_sequence(joystick_port, seq, repeat_state, decoder, time.monotonic()) if events: pending_events.extend(events) if control == "release_all": if pending_events: flush_event_batch(client, pending_events) pending_events = [] + elif control == "help": + print_special_key_help() elif control == "quit": if pending_events: flush_event_batch(client, pending_events) client.post_events([release_all_event()]) return - repeat_keyboard_event = repeat_state.poll(time.monotonic()) + pending_events.extend(repeat_state.poll(time.monotonic())) if pending_events: flush_event_batch(client, pending_events) - if repeat_keyboard_event: - flush_repeat_event(client, repeat_keyboard_event) finally: client.close() -def run_interactive(session: RestInputSession, joystick_port: int, gamepad: Optional[GamepadDevice]) -> None: - print_mapping_overview(session.host, joystick_port, gamepad) +def run_interactive_low_level( + session: RestInputSession, + keyboard: KeyboardDevice, + gamepad: Optional[GamepadDevice], + logger: Optional[RestCallLogger], +) -> None: + keyboard_fd = keyboard.fileno() + gamepad_fd = gamepad.fileno() if gamepad is not None else None + client = InteractiveRestClient(session, logger=logger) + + try: + while True: + now = time.monotonic() + timeout = gamepad.state.repeat_timeout(now) if gamepad is not None else None + fds = [keyboard_fd] + if gamepad_fd is not None: + fds.append(gamepad_fd) + ready, _, _ = select.select(fds, [], [], timeout) + now = time.monotonic() + pending_events: List[Dict[str, object]] = [] + + if not ready and gamepad is not None: + repeat_event = gamepad.state.poll_repeat_fire(now) + if repeat_event: + pending_events.append(repeat_event) + else: + if keyboard_fd in ready: + for control, events in keyboard.read_updates(): + if events: + pending_events.extend(events) + if control == "release_all": + if pending_events: + flush_event_batch(client, pending_events) + pending_events = [] + elif control == "quit": + if pending_events: + flush_event_batch(client, pending_events) + client.post_events([release_all_event()]) + return + if gamepad is not None and gamepad_fd is not None and gamepad_fd in ready: + pending_events.extend(gamepad.read_events(now)) + repeat_event = gamepad.state.poll_repeat_fire(now) + if repeat_event: + pending_events.append(repeat_event) + + if pending_events: + flush_event_batch(client, pending_events) + finally: + client.close() + + +def run_interactive( + session: RestInputSession, + joystick_port: int, + keyboard: Optional[KeyboardDevice], + gamepad: Optional[GamepadDevice], + logger: Optional[RestCallLogger], + verbosity: int, +) -> None: + print_mapping_overview(session.host, joystick_port, keyboard, gamepad, verbosity) wait_for_input_ready(session, timeout=15.0) - session.post_events([release_all_event()]) - with raw_terminal(): - if gamepad: - run_interactive_with_gamepad(session, joystick_port, gamepad) - else: - run_interactive_loop(session, joystick_port) + startup_client = InteractiveRestClient(session, logger=logger) + try: + startup_client.post_events([release_all_event()]) + finally: + startup_client.close() + if keyboard: + run_interactive_low_level(session, keyboard, gamepad, logger) + else: + with raw_terminal(): + if gamepad: + run_interactive_with_gamepad(session, joystick_port, gamepad, logger) + else: + run_interactive_loop(session, joystick_port, logger) def assert_input_state(session: RestInputSession, keyboard: List[str], joystick1: List[str], joystick2: List[str]) -> None: @@ -677,27 +1679,355 @@ def assert_input_state(session: RestInputSession, keyboard: List[str], joystick1 raise Failure(f"Joystick state mismatch: {state}") +def collect_interactive_events(sequence_times: List[Tuple[float, str]], joystick_port: int) -> List[Tuple[float, Dict[str, object]]]: + repeat_state = RepeatState() + decoder = SequenceDecoder() + emitted: List[Tuple[float, Dict[str, object]]] = [] + for now, seq in sequence_times: + control, events = decoder.poll(now) + if control == "release_all": + repeat_state.stop() + for event in events: + emitted.append((now, event)) + for event in repeat_state.poll(now): + emitted.append((now, event)) + control, events = handle_interactive_sequence(joystick_port, seq, repeat_state, decoder, now) + if control == "release_all": + repeat_state.stop() + if control == "quit": + break + for event in events: + emitted.append((now, event)) + final_time = sequence_times[-1][0] + KEY_REPEAT_STALE_SECONDS + 0.05 if sequence_times else 0.0 + control, events = decoder.poll(final_time) + if control == "release_all": + repeat_state.stop() + for event in events: + emitted.append((final_time, event)) + for event in repeat_state.poll(final_time): + emitted.append((final_time, event)) + return emitted + + +def collect_low_level_keyboard_events(key_events: List[Tuple[int, int]], joystick_port: int) -> List[Tuple[Optional[str], List[Dict[str, object]]]]: + state = LowLevelKeyboardState(joystick_port) + return [state.apply_input_event(code, value) for code, value in key_events] + + +def run_local_input_tool_regressions(joystick_port: int) -> None: + decoder = SequenceDecoder() + for seq, expected in ( + ("\x1b[A", ["left_shift", "cursor_up_down"]), + ("\x1b[B", ["cursor_up_down"]), + ("\x1b[C", ["cursor_left_right"]), + ("\x1b[D", ["left_shift", "cursor_left_right"]), + ("\x1bOA", ["left_shift", "cursor_up_down"]), + ("\x1bOB", ["cursor_up_down"]), + ("\x1bOC", ["cursor_left_right"]), + ("\x1bOD", ["left_shift", "cursor_left_right"]), + ): + action, event, _ = decoder.translate(seq, joystick_port, 0.0) + if action != "event" or event != keyboard_event(expected): + raise Failure(f"Cursor translation mismatch for {seq!r}: {event}") + + fragmented_cursor_events = collect_interactive_events([(0.00, "\x1b["), (0.01, "D"), (0.30, "\x1bO"), (0.31, "A")], joystick_port) + fragmented_expected = [ + keyboard_event(["left_shift", "cursor_left_right"]), + keyboard_event(["left_shift", "cursor_up_down"]), + ] + if [event for _, event in fragmented_cursor_events] != fragmented_expected: + raise Failure(f"Fragmented cursor translation mismatch: {fragmented_cursor_events}") + + duplicate_cursor_suffix_events = collect_interactive_events([(0.00, "\x1b[D"), (0.01, "D")], joystick_port) + if [event for _, event in duplicate_cursor_suffix_events] != [keyboard_event(["left_shift", "cursor_left_right"])]: + raise Failure(f"Duplicate cursor suffix should be suppressed: {duplicate_cursor_suffix_events}") + + esc_release_events = collect_interactive_events([(0.00, "\x1b")], joystick_port) + if [event for _, event in esc_release_events] != [release_all_event()]: + raise Failure(f"Standalone Esc should release all, got {esc_release_events}") + + pretrigger_hold_events = collect_interactive_events([(0.00, "a"), (0.18, "a"), (0.34, "a"), (0.49, "a")], joystick_port) + if [event for _, event in pretrigger_hold_events] != [keyboard_event(["a"])] * 4: + raise Failure(f"Slow same-key taps should stay distinct before hold-repeat cadence: {pretrigger_hold_events}") + + quick_repeat_taps = collect_interactive_events([(0.00, "a"), (0.20, "a"), (0.40, "a"), (0.60, "a")], joystick_port) + if [event for _, event in quick_repeat_taps] != [keyboard_event(["a"])] * 4: + raise Failure(f"Distinct same-key taps should not trigger hold-repeat: {quick_repeat_taps}") + + left_shift_events = collect_low_level_keyboard_events([(KEY_LEFTSHIFT, 1), (KEY_LEFTSHIFT, 0)], joystick_port) + if left_shift_events != [ + (None, [keyboard_state_event(["left_shift"], "press")]), + (None, [keyboard_state_event(["left_shift"], "release")]), + ]: + raise Failure(f"Low-level left-shift mapping mismatch: {left_shift_events}") + + right_shift_events = collect_low_level_keyboard_events([(KEY_RIGHTSHIFT, 1), (KEY_RIGHTSHIFT, 0)], joystick_port) + if right_shift_events != [ + (None, [keyboard_state_event(["right_shift"], "press")]), + (None, [keyboard_state_event(["right_shift"], "release")]), + ]: + raise Failure(f"Low-level right-shift mapping mismatch: {right_shift_events}") + + shifted_letter_events = collect_low_level_keyboard_events( + [(KEY_RIGHTSHIFT, 1), (KEY_A, 1), (KEY_A, 0), (KEY_RIGHTSHIFT, 0)], + joystick_port, + ) + if shifted_letter_events != [ + (None, [keyboard_state_event(["right_shift"], "press")]), + (None, [keyboard_state_event(["a"], "press")]), + (None, [keyboard_state_event(["a"], "release")]), + (None, [keyboard_state_event(["right_shift"], "release")]), + ]: + raise Failure(f"Low-level shifted-letter mapping mismatch: {shifted_letter_events}") + + commodore_events = collect_low_level_keyboard_events([(KEY_LEFTCTRL, 1), (KEY_LEFTCTRL, 0)], joystick_port) + if commodore_events != [ + (None, [keyboard_state_event(["commodore"], "press")]), + (None, [keyboard_state_event(["commodore"], "release")]), + ]: + raise Failure(f"Low-level commodore mapping mismatch: {commodore_events}") + + ctrl_events = collect_low_level_keyboard_events([(KEY_TAB, 1), (KEY_TAB, 0)], joystick_port) + if ctrl_events != [ + (None, [keyboard_state_event(["ctrl"], "press")]), + (None, [keyboard_state_event(["ctrl"], "release")]), + ]: + raise Failure(f"Low-level ctrl mapping mismatch: {ctrl_events}") + + quit_events = collect_low_level_keyboard_events([(KEY_LEFTCTRL, 1), (KEY_ESC, 1)], joystick_port) + if quit_events != [ + (None, [keyboard_state_event(["commodore"], "press")]), + ("quit", []), + ]: + raise Failure(f"Low-level Ctrl+Esc quit mapping mismatch: {quit_events}") + + fkey_events = { + KEY_F1: [ + (None, [keyboard_state_event(["f1"], "press")]), + (None, [keyboard_state_event(["f1"], "release")]), + ], + KEY_F2: [ + (None, [keyboard_state_event(["left_shift"], "press"), keyboard_state_event(["f1"], "press")]), + (None, [keyboard_state_event(["f1"], "release"), keyboard_state_event(["left_shift"], "release")]), + ], + KEY_F3: [ + (None, [keyboard_state_event(["f3"], "press")]), + (None, [keyboard_state_event(["f3"], "release")]), + ], + KEY_F4: [ + (None, [keyboard_state_event(["left_shift"], "press"), keyboard_state_event(["f3"], "press")]), + (None, [keyboard_state_event(["f3"], "release"), keyboard_state_event(["left_shift"], "release")]), + ], + KEY_F5: [ + (None, [keyboard_state_event(["f5"], "press")]), + (None, [keyboard_state_event(["f5"], "release")]), + ], + KEY_F6: [ + (None, [keyboard_state_event(["left_shift"], "press"), keyboard_state_event(["f5"], "press")]), + (None, [keyboard_state_event(["f5"], "release"), keyboard_state_event(["left_shift"], "release")]), + ], + KEY_F7: [ + (None, [keyboard_state_event(["f7"], "press")]), + (None, [keyboard_state_event(["f7"], "release")]), + ], + KEY_F8: [ + (None, [keyboard_state_event(["left_shift"], "press"), keyboard_state_event(["f7"], "press")]), + (None, [keyboard_state_event(["f7"], "release"), keyboard_state_event(["left_shift"], "release")]), + ], + } + for keycode, expected in fkey_events.items(): + events = collect_low_level_keyboard_events([(keycode, 1), (keycode, 0)], joystick_port) + if events != expected: + raise Failure(f"Low-level F-key mapping mismatch for {keycode}: {events}") + + rapid_low_level_events = collect_low_level_keyboard_events( + [ + (KEY_A, 1), (KEY_A, 0), + (KEY_S, 1), (KEY_S, 0), + (KEY_D, 1), (KEY_D, 0), + (KEY_F, 1), (KEY_F, 0), + ], + joystick_port, + ) + if [events for _, events in rapid_low_level_events if events] != [ + [keyboard_state_event(["a"], "press")], + [keyboard_state_event(["a"], "release")], + [keyboard_state_event(["s"], "press")], + [keyboard_state_event(["s"], "release")], + [keyboard_state_event(["d"], "press")], + [keyboard_state_event(["d"], "release")], + [keyboard_state_event(["f"], "press")], + [keyboard_state_event(["f"], "release")], + ]: + raise Failure(f"Low-level random typing injected unexpected modifiers: {rapid_low_level_events}") + + left_arrow_events = collect_low_level_keyboard_events( + [(KEY_LEFT, 1), (KEY_LEFT, 0)], + joystick_port, + ) + if left_arrow_events != [ + (None, [keyboard_state_event(["left_shift"], "press"), keyboard_state_event(["cursor_left_right"], "press")]), + (None, [keyboard_state_event(["cursor_left_right"], "release"), keyboard_state_event(["left_shift"], "release")]), + ]: + raise Failure(f"Low-level left-arrow mapping mismatch: {left_arrow_events}") + + gamepad = GamepadState(joystick_port) + fire23_events = [ + gamepad.apply_input_event(EV_KEY, BTN_SOUTH, 1, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_SOUTH, 0, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_NORTH, 1, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_NORTH, 0, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_EAST, 1, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_WEST, 1, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_EAST, 0, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_WEST, 0, 0.0), + ] + if fire23_events != [ + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "press"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "release"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "press"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "release"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire2"], "transition": "press"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire3"], "transition": "press"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire2"], "transition": "release"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire3"], "transition": "release"}], + ]: + raise Failure(f"Gamepad face-button mapping mismatch: {fire23_events}") + + generic_button_events = [ + gamepad.apply_input_event(EV_KEY, BTN_TRIGGER, 1, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_TRIGGER, 0, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_THUMB, 1, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_THUMB2, 1, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_TOP, 1, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_THUMB, 0, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_THUMB2, 0, 0.0), + gamepad.apply_input_event(EV_KEY, BTN_TOP, 0, 0.0), + ] + if generic_button_events != [ + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "press"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "release"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire2"], "transition": "press"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "press"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire3"], "transition": "press"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire2"], "transition": "release"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire"], "transition": "release"}], + [{"kind": "joystick", "port": joystick_port, "inputs": ["fire3"], "transition": "release"}], + ]: + raise Failure(f"Generic gamepad button mapping mismatch: {generic_button_events}") + + for seq, expected in ( + ("\t", ["ctrl"]), + ("\x1bOP", ["f1"]), + ("\x1bOQ", ["left_shift", "f1"]), + ("\x1bOR", ["f3"]), + ("\x1bOS", ["left_shift", "f3"]), + ("\x1b[15~", ["f5"]), + ("\x1b[17~", ["left_shift", "f5"]), + ("\x1b[18~", ["f7"]), + ("\x1b[19~", ["left_shift", "f7"]), + ("\x1b[H", ["clr_home"]), + ("\x1b[3~", ["inst_del"]), + ("\b", ["inst_del"]), + ("\x1b[F", ["run_stop"]), + ("\x1b[5~", ["restore"]), + ("\n", ["return"]), + ): + decoder.clear() + action, event, repeat_sequence = decoder.translate(seq, joystick_port, 0.0) + if action != "event" or event != keyboard_event(expected) or repeat_sequence != seq: + raise Failure(f"Special mapping mismatch for {seq!r}: {event}") + + for seq in ("\x1b[20~", "\x1b[Z", "\x1b[21~", "\x1b[23~", "\x1ba", "\x1bA", "\x1bo", "\x01", "\x18", "\x1b[1;5A", "\x1b[13;5u"): + decoder.clear() + action, event, repeat_sequence = decoder.translate(seq, joystick_port, 0.0) + if action != "ignore" or event is not None or repeat_sequence is not None: + raise Failure(f"Legacy terminal workaround should be ignored for {seq!r}: {(action, event, repeat_sequence)}") + + rapid_text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 2 + rapid_events = collect_interactive_events([(index * 0.01, ch) for index, ch in enumerate(rapid_text)], joystick_port) + expected_rapid = [keyboard_event(["left_shift", ch.lower()]) for ch in rapid_text] + if [event for _, event in rapid_events] != expected_rapid: + raise Failure("Rapid mixed-key stream dropped or reordered keys") + + hold_sequences = [(0.0, "a")] + [(0.60 + index * 0.033, "a") for index in range(36)] + hold_events = collect_interactive_events(hold_sequences, joystick_port) + hold_times = [timestamp for timestamp, event in hold_events if event == keyboard_event(["a"])] + if not hold_times: + raise Failure("Held-key regression produced no emitted events") + if hold_times[0] != 0.0: + raise Failure(f"Held-key regression lost the initial tap: {hold_times[:4]}") + if len(hold_times) < 38: + raise Failure(f"Held-key regression emitted too few repeated taps: {len(hold_times)}") + if any(timestamp < KEY_REPEAT_TRIGGER_SECONDS for timestamp in hold_times[1:]): + raise Failure(f"Held-key regression emitted repeats too early: {hold_times[:6]}") + if hold_times[-1] < 1.4: + raise Failure(f"Held-key regression stopped too early: {hold_times[-4:]}") + + def run_self_test(session: RestInputSession, joystick_port: int, gamepad: Optional[GamepadDevice]) -> None: - print_mapping_overview(session.host, joystick_port, gamepad) + verbosity = session.logger.level if isinstance(session, VerboseRestSession) else 0 + print_mapping_overview(session.host, joystick_port, None, gamepad, verbosity) wait_for_input_ready(session, timeout=15.0) - print("[1] keyboard input reaches the live C64 matrix ... ", end="", flush=True) + print("[1] local repeat, cursor, and special-key regressions ... ", end="", flush=True) + run_local_input_tool_regressions(joystick_port) + print("OK") + + print("[2] keyboard input reaches the live C64 matrix ... ", end="", flush=True) reset_to_basic(session) session.post_events([{"kind": "keyboard", "inputs": ["x"], "transition": "press"}]) assert_keyboard_matrix_inputs(session, ["x"]) session.post_events([release_all_event()]) print("OK") - print("[2] keyboard persistent state round-trips ... ", end="", flush=True) + print("[3] cursor keys map to C64 cursor movement ... ", end="", flush=True) + for seq, expected in ( + ("\x1b[A", ["left_shift", "cursor_up_down"]), + ("\x1b[B", ["cursor_up_down"]), + ("\x1b[C", ["cursor_left_right"]), + ("\x1b[D", ["left_shift", "cursor_left_right"]), + ): + translated = translate_sequence(seq, joystick_port) + if translated != keyboard_event(expected): + raise Failure(f"Cursor translation mismatch for {seq!r}: {translated}") + reset_to_basic(session) + response = session.post_events([{"kind": "keyboard", "inputs": ["left_shift", "cursor_left_right"], "transition": "tap"}]) + if sorted(response.get("keyboard", {}).get("inputs", [])) != ["cursor_left_right", "left_shift"]: + raise Failure(f"Cursor tap snapshot mismatch: {response}") + time.sleep(0.2) + assert_input_state(session, [], [], []) + session.post_events([release_all_event()]) + print("OK") + + print("[4] special-key mappings reach the REST keyboard snapshot ... ", end="", flush=True) + for inputs in (["commodore"], ["ctrl"], ["run_stop"], ["restore"], ["f1"], ["f7"], ["left_shift"], ["right_shift"]): + response = session.post_events([{"kind": "keyboard", "inputs": inputs, "transition": "tap"}]) + if sorted(response.get("keyboard", {}).get("inputs", [])) != sorted(inputs): + raise Failure(f"Special-key snapshot mismatch for {inputs}: {response}") + time.sleep(0.2) + assert_input_state(session, [], [], []) + session.post_events([release_all_event()]) + print("OK") + + print("[5] keyboard persistent state round-trips ... ", end="", flush=True) session.post_events([{"kind": "keyboard", "inputs": ["ctrl"], "transition": "press"}]) assert_input_state(session, ["ctrl"], [], []) session.post_events([release_all_event()]) assert_input_state(session, [], [], []) print("OK") - print("[3] joystick movement reaches the live CIA joystick lines ... ", end="", flush=True) - direction = translate_sequence("\x1b[1;5A", joystick_port) - fire = translate_sequence("\n", joystick_port) + print("[6] extended joystick fire buttons round-trip through REST state ... ", end="", flush=True) + session.post_events([{"kind": "joystick", "port": joystick_port, "inputs": ["fire2", "fire3"], "transition": "press"}]) + if joystick_port == 1: + assert_input_state(session, [], ["fire2", "fire3"], []) + else: + assert_input_state(session, [], [], ["fire2", "fire3"]) + session.post_events([release_all_event()]) + assert_input_state(session, [], [], []) + print("OK") + + print("[7] joystick movement reaches the live CIA joystick lines ... ", end="", flush=True) reset_to_basic(session) session.post_events( [ @@ -709,8 +2039,6 @@ def run_self_test(session: RestInputSession, joystick_port: int, gamepad: Option assert_joystick_ports(session, 0x0E, 0x1F) else: assert_joystick_ports(session, 0x1F, 0x0E) - if direction is None or fire is None: - raise Failure("internal joystick key translation failed") session.post_events([release_all_event()]) assert_input_state(session, [], [], []) print("OK") @@ -719,25 +2047,140 @@ def run_self_test(session: RestInputSession, joystick_port: int, gamepad: Option def main() -> int: - parser = argparse.ArgumentParser(description="Send local keyboard input to U64 REST input injection") - parser.add_argument("--host", default=os.environ.get("U64_INPUT_HOST", "u64")) - parser.add_argument("--rest-host", default=os.environ.get("U64_INPUT_REST_HOST")) - parser.add_argument("--password", default=os.environ.get("U64_INPUT_PASSWORD", os.environ.get("C64U_PASSWORD"))) - parser.add_argument("--timeout", type=float, default=float(os.environ.get("U64_INPUT_TIMEOUT", "5.0"))) - parser.add_argument("--self-test", action="store_true", help="run key/joystick verification and exit") - parser.add_argument("--joystick-port", type=int, choices=(1, 2), default=2) - parser.add_argument("--gamepad-device", help="Linux /dev/input event device for the USB gamepad") - parser.add_argument("--no-gamepad", action="store_true", help="disable automatic USB gamepad support") + parser = argparse.ArgumentParser( + description="Inject local keyboard and gamepad input via the Ultimate 64 REST API.", + epilog=( + "Environment: U64_INPUT_HOST, U64_INPUT_REST_HOST, " + "U64_INPUT_PASSWORD or C64U_PASSWORD, U64_INPUT_TIMEOUT.\n" + "Use -v for concise REST logging, -vv or -V for full request/response details." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + connection = parser.add_argument_group("connection options") + connection.add_argument( + "-H", + "--host", + metavar="HOST", + default=os.environ.get("U64_INPUT_HOST", "u64"), + help="target Ultimate 64 host name (default: %(default)s)", + ) + connection.add_argument( + "-r", + "--rest-host", + metavar="HOST", + default=os.environ.get("U64_INPUT_REST_HOST"), + help="REST API host name; defaults to --host", + ) + connection.add_argument( + "-p", + "--password", + metavar="PASSWORD", + default=os.environ.get("U64_INPUT_PASSWORD", os.environ.get("C64U_PASSWORD")), + help="REST password; defaults to $U64_INPUT_PASSWORD or $C64U_PASSWORD", + ) + connection.add_argument( + "-t", + "--timeout", + metavar="SECONDS", + type=float, + default=float(os.environ.get("U64_INPUT_TIMEOUT", "5.0")), + help="per-request timeout in seconds (default: %(default)s)", + ) + + action = parser.add_argument_group("action options") + action.add_argument( + "-s", + "--self-test", + action="store_true", + help="run keyboard and joystick verification, then exit", + ) + + input_group = parser.add_argument_group("input options") + input_group.add_argument( + "-j", + "--joystick-port", + metavar="PORT", + type=int, + choices=(1, 2), + default=2, + help="joystick port driven by keyboard shortcuts and gamepad input (default: %(default)s)", + ) + input_group.add_argument( + "-g", + "--gamepad-device", + metavar="DEVICE", + help="Linux /dev/input event device to use as the USB gamepad", + ) + input_group.add_argument( + "-k", + "--keyboard-device", + metavar="DEVICE", + help="Linux /dev/input event device to use as the keyboard (default: auto-detect *-event-kbd)", + ) + input_group.add_argument( + "-G", + "--no-gamepad", + action="store_true", + help="disable automatic USB gamepad detection", + ) + input_group.add_argument( + "-T", + "--terminal-keyboard", + action="store_true", + help="use the legacy stdin raw-terminal decoder instead of low-level /dev/input keyboard capture", + ) + + logging = parser.add_argument_group("logging options") + logging.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="log REST calls; repeat for more detail", + ) + logging.add_argument( + "-V", + "--verboser", + action="store_true", + help="show full REST request and response payloads (same as -vv)", + ) args = parser.parse_args() rest_host = args.rest_host or args.host - session = RestInputSession(rest_host, args.password, args.timeout) - gamepad = None if args.no_gamepad else open_gamepad(args.gamepad_device, args.joystick_port) + verbosity = max(args.verbose, 2 if args.verboser else 0) + logger = RestCallLogger(verbosity) + session: RestInputSession + if verbosity > 0: + session = VerboseRestSession(rest_host, args.password, args.timeout, logger) + else: + session = RestInputSession(rest_host, args.password, args.timeout) + keyboard = None + if not args.terminal_keyboard: + try: + keyboard = open_keyboard_checked(args.keyboard_device, args.joystick_port) + except InputDeviceOpenError as exc: + if request_input_access_or_warn(exc): + return 0 + keyboard = None + if args.keyboard_device and keyboard is None and not args.terminal_keyboard: + raise Failure(f"Unable to open keyboard device {args.keyboard_device}") + + gamepad = None + if not args.no_gamepad: + try: + gamepad = open_gamepad_checked(args.gamepad_device, args.joystick_port) + except InputDeviceOpenError as exc: + if request_input_access_or_warn(exc): + return 0 + gamepad = None + if args.gamepad_device and gamepad is None and not args.no_gamepad: + raise Failure(f"Unable to open gamepad device {args.gamepad_device}") try: if args.self_test: run_self_test(session, args.joystick_port, gamepad) else: - run_interactive(session, args.joystick_port, gamepad) + run_interactive(session, args.joystick_port, keyboard, gamepad, logger if verbosity > 0 else None, verbosity) except KeyboardInterrupt: try: session.post_events([release_all_event()]) @@ -755,6 +2198,8 @@ def main() -> int: print(f"REST failure: {format_exception(exc)}", file=sys.stderr) return 1 finally: + if keyboard: + keyboard.close() if gamepad: gamepad.close() return 0 From 1340443303c1d7a7a919d96efbf8d6839ec2202f Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Tue, 19 May 2026 22:01:56 +0100 Subject: [PATCH 4/7] Enhance joystick REST API to support fire2 and fire3 buttons --- software/api/tests/Makefile | 4 +- software/api/tests/input_api_state_test.cpp | 16 +- software/io/c64/joystick_output.cc | 14 +- software/io/c64/keyboard_c64.cc | 155 ++++++++++++-------- software/io/c64/keyboard_c64.h | 19 +-- software/io/usb/usb_hid.cc | 21 ++- tools/api/input_test.py | 61 +++++++- tools/api/input_tool.py | 14 +- 8 files changed, 204 insertions(+), 100 deletions(-) diff --git a/software/api/tests/Makefile b/software/api/tests/Makefile index 1ad5973e9..4803a411e 100644 --- a/software/api/tests/Makefile +++ b/software/api/tests/Makefile @@ -1,6 +1,6 @@ CXX ?= g++ CXXFLAGS := -std=c++14 -g -I.. -I../../components -I../../system -I../../io/stream -I../../io/usb/tests -STATE_TEST_FLAGS := $(CXXFLAGS) -DNO_FILE_ACCESS -I../../io/c64 +STATE_TEST_FLAGS := $(CXXFLAGS) -DNO_FILE_ACCESS -I../../io/c64 -I../../io/usb -I../../infra all: input-api input-api-state @@ -8,4 +8,4 @@ input-api: $(CXX) $(CXXFLAGS) input_api_validation_test.cpp ../json.cc ../../components/mystring.cc ../../system/small_printf.cc ../../io/usb/tests/host_test_main.cpp -lpthread -o inputApiValidationTest && ./inputApiValidationTest input-api-state: - $(CXX) $(STATE_TEST_FLAGS) input_api_state_test.cpp ../../io/usb/keyboard_usb.cc ../../io/c64/joystick_output.cc ../../io/usb/tests/host_test_main.cpp -lpthread -o inputApiStateTest && ./inputApiStateTest + $(CXX) $(STATE_TEST_FLAGS) input_api_state_test.cpp ../../io/usb/keyboard_usb.cc ../../io/c64/joystick_output.cc ../../io/c64/keyboard_c64.cc ../../io/usb/tests/host_test_main.cpp -lpthread -o inputApiStateTest && ./inputApiStateTest diff --git a/software/api/tests/input_api_state_test.cpp b/software/api/tests/input_api_state_test.cpp index 081c45ceb..df54ab4fc 100644 --- a/software/api/tests/input_api_state_test.cpp +++ b/software/api/tests/input_api_state_test.cpp @@ -1,8 +1,13 @@ #include "../../io/usb/tests/host_test/host_test.h" +#include "../../io/c64/keyboard_c64.h" #include "../../io/usb/keyboard_usb.h" #include "../../io/c64/joystick_output.h" #include "../input_api.h" +extern "C" void wait_ms(int) +{ +} + namespace { const InputKeyboardMapEntry *find_keyboard_entry(const char *name) @@ -166,6 +171,13 @@ TEST(RestKeyboardStateTest, ReleaseAllClearsPersistentAndOverlayState) EXPECT_FALSE(restore); } +TEST(KeyboardC64StateTest, SoftwareJoystickDoesNotBlockKeyboardScan) +{ + EXPECT_FALSE(Keyboard_C64::joystick_blocks_keyboard(0x0E, 0x0E)); + EXPECT_TRUE(Keyboard_C64::joystick_blocks_keyboard(0x0E, 0x1F)); + EXPECT_TRUE(Keyboard_C64::joystick_blocks_keyboard(0x0C, 0x0E)); +} + TEST(RestJoystickStateTest, TapOverlayAutoReleasesWithoutClearingPersistentInput) { reset_joystick_output(); @@ -234,10 +246,10 @@ TEST(RestJoystickStateTest, Fire2MapsToPotXAndFire3MapsToPotY) EXPECT_EQ(0x1F, port1); EXPECT_EQ(0x1F, port2); EXPECT_EQ(0x00, pot2x); - EXPECT_EQ(0x7F, pot2y); + EXPECT_EQ(0x80, pot2y); JoystickOutput::instance().setRestPort2Persistent(0x3F); JoystickOutput::instance().outputSnapshot(port1, port2, pot1x, pot1y, pot2x, pot2y); - EXPECT_EQ(0x7F, pot2x); + EXPECT_EQ(0x80, pot2x); EXPECT_EQ(0x00, pot2y); } diff --git a/software/io/c64/joystick_output.cc b/software/io/c64/joystick_output.cc index 0f4fc168f..6b5e6972e 100644 --- a/software/io/c64/joystick_output.cc +++ b/software/io/c64/joystick_output.cc @@ -7,6 +7,7 @@ #include "timers.h" #endif #include "u64.h" +extern "C" int usb_hid_get_active_mouse_interfaces(void) __attribute__((weak)); #ifndef portENTER_CRITICAL #define portENTER_CRITICAL() @@ -22,7 +23,7 @@ static const uint8_t JOYSTICK_DIGITAL_MASK = 0x1F; static const uint8_t JOYSTICK_FIRE2_BIT = (1 << 5); static const uint8_t JOYSTICK_FIRE3_BIT = (1 << 6); static const uint8_t JOYSTICK_INPUT_MASK = JOYSTICK_DIGITAL_MASK | JOYSTICK_FIRE2_BIT | JOYSTICK_FIRE3_BIT; -static const uint8_t JOYSTICK_POT_RELEASED = 0x7F; +static const uint8_t JOYSTICK_POT_RELEASED = 0x80; static const uint8_t JOYSTICK_POT_PRESSED = 0x00; static uint8_t joystick_potx_value(uint8_t active_low_mask) @@ -35,6 +36,11 @@ static uint8_t joystick_poty_value(uint8_t active_low_mask) return (active_low_mask & JOYSTICK_FIRE3_BIT) ? JOYSTICK_POT_RELEASED : JOYSTICK_POT_PRESSED; } +static bool joystick_has_extra_button_press(uint8_t active_low_mask) +{ + return (active_low_mask & (JOYSTICK_FIRE2_BIT | JOYSTICK_FIRE3_BIT)) != (JOYSTICK_FIRE2_BIT | JOYSTICK_FIRE3_BIT); +} + #if U64 && !RECOVERYAPP static void joystick_overlay_timer(TimerHandle_t timer) { @@ -70,6 +76,7 @@ void JoystickOutput :: apply(void) { #if U64 uint8_t port1, port2, pot1x, pot1y, pot2x, pot2y; + bool mouse_port1_enabled = false; outputSnapshot(port1, port2, pot1x, pot1y, pot2x, pot2y); C64_JOY1_SWOUT = port1 | 0xE0; C64_JOY2_SWOUT = port2 | 0xE0; @@ -77,6 +84,11 @@ void JoystickOutput :: apply(void) C64_PADDLE_1_Y = pot1y; C64_PADDLE_2_X = pot2x; C64_PADDLE_2_Y = pot2y; + if (usb_hid_get_active_mouse_interfaces) { + mouse_port1_enabled = usb_hid_get_active_mouse_interfaces() > 0; + } + C64_MOUSE_EN_1 = (mouse_port1_enabled || joystick_has_extra_button_press(usb_p1 & rest_p1_persistent & rest_p1_overlay)) ? 1 : 0; + C64_MOUSE_EN_2 = joystick_has_extra_button_press(rest_p2_persistent & rest_p2_overlay) ? 1 : 0; #endif } diff --git a/software/io/c64/keyboard_c64.cc b/software/io/c64/keyboard_c64.cc index 501480098..99f8e3158 100644 --- a/software/io/c64/keyboard_c64.cc +++ b/software/io/c64/keyboard_c64.cc @@ -1,8 +1,10 @@ -#include -#include "itu.h" -#include "keyboard_c64.h" -#include "c64.h" -#include "keyboard_usb.h" +#include +#include "itu.h" +#include "keyboard_c64.h" +#include "keyboard_usb.h" +#if U64 && !RECOVERYAPP +#include "joystick_output.h" +#endif #ifndef NO_FILE_ACCESS #include "FreeRTOS.h" @@ -97,8 +99,8 @@ Keyboard_C64 :: ~Keyboard_C64() { } -uint8_t Keyboard_C64 :: scan_keyboard(volatile uint8_t *row_reg, volatile uint8_t *col_reg) -{ +uint8_t Keyboard_C64 :: scan_keyboard(volatile uint8_t *row_reg, volatile uint8_t *col_reg) +{ const uint8_t *map; uint8_t shift_flag = 0; @@ -133,21 +135,35 @@ uint8_t Keyboard_C64 :: scan_keyboard(volatile uint8_t *row_reg, volatile uint8_ } map = keymaps[shift_flag & 0x07]; - return map[mtrx]; -} -#include "u64.h" // temporary! -void Keyboard_C64 :: scan(void) -{ - const uint8_t *map; - - uint8_t shift_flag = 0; - uint8_t mtrx = 0x40; - uint8_t col, row, key = 0xFF; - uint8_t mod = 0; - bool joy = false; - - if(!host) { - return; + return map[mtrx]; +} + +bool Keyboard_C64 :: joystick_blocks_keyboard(uint8_t observed_active_low, uint8_t injected_active_low) +{ + observed_active_low &= 0x1F; + injected_active_low &= 0x1F; + return (observed_active_low != 0x1F) && (observed_active_low != injected_active_low); +} + +#include "u64.h" // temporary! +void Keyboard_C64 :: scan(void) +{ + const uint8_t *map; + + uint8_t shift_flag = 0; + uint8_t mtrx = 0x40; + uint8_t col, row, key = 0xFF; + uint8_t mod = 0; + bool joy = false; + bool software_joy_only = false; + bool keyboard_pressed = false; + uint8_t joy_shift_flag = 0; + uint8_t joy_mtrx = 0x40; + uint8_t injected_joy2 = 0x1F; + uint8_t injected_joy1 = 0x1F; + + if(!host) { + return; } // check if we have access to the I/O if(!(host->is_accessible())) @@ -161,28 +177,39 @@ void Keyboard_C64 :: scan(void) *row_register = 0xFF; *row_register = 0xFF; - // Scan Joystick Port 2 first - *col_register = 0xFF; // deselect keyboard for pure joystick scan - *col_register = 0XFF; // delay - - row = *joy_register; - row = *joy_register; - if((row & 0x1F) != 0x1F) { - joy = true; - if (!(row & 0x01)) { shift_flag = 0x01; mtrx = 0x07; } - else if(!(row & 0x02)) { shift_flag = 0x00; mtrx = 0x07; } - else if(!(row & 0x04)) { shift_flag = 0x01; mtrx = 0x02; } - else if(!(row & 0x08)) { shift_flag = 0x00; mtrx = 0x02; } - else if(!(row & 0x10)) { shift_flag = 0x00; mtrx = 0x01; } - } - - // If the joystick was not used, we can safely scan the keyboard - if(!joy) { - *col_register = 0; // select all rows of keyboard - if (*row_register != 0xFF) { // process key image - map = keymap_normal; - col = 0xFE; - for(int idx=0,y=0;y<8;y++) { + // Scan Joystick Port 2 first + *col_register = 0xFF; // deselect keyboard for pure joystick scan + *col_register = 0XFF; // delay + + row = *joy_register; + row = *joy_register; +#if U64 && !RECOVERYAPP + JoystickOutput::instance().snapshot(injected_joy1, injected_joy2); +#endif + if((row & 0x1F) != 0x1F) { + if (!(row & 0x01)) { joy_shift_flag = 0x01; joy_mtrx = 0x07; } + else if(!(row & 0x02)) { joy_shift_flag = 0x00; joy_mtrx = 0x07; } + else if(!(row & 0x04)) { joy_shift_flag = 0x01; joy_mtrx = 0x02; } + else if(!(row & 0x08)) { joy_shift_flag = 0x00; joy_mtrx = 0x02; } + else if(!(row & 0x10)) { joy_shift_flag = 0x00; joy_mtrx = 0x01; } + + software_joy_only = !joystick_blocks_keyboard(row, injected_joy2); + if (!software_joy_only) { + joy = true; + shift_flag = joy_shift_flag; + mtrx = joy_mtrx; + } + } + + // Physical joystick activity shares matrix lines with the keyboard and must keep + // the old precedence. REST-owned port 2 activity should not starve the local keyboard. + if(!joy) { + *col_register = 0; // select all rows of keyboard + if (*row_register != 0xFF) { // process key image + keyboard_pressed = true; + map = keymap_normal; + col = 0xFE; + for(int idx=0,y=0;y<8;y++) { *col_register = 0xFF; *col_register = col; *col_register = col; @@ -199,24 +226,28 @@ void Keyboard_C64 :: scan(void) } } row >>= 1; - } - col = (col << 1) | 1; - } - } else { // no key pressed - mtrx_prev = 0xFF; - shift_prev = 0xFF; -#if U64 == 2 - MATRIX_WASD_TO_JOY = wasd_to_joy; - BLING_RX_FLAGS = 0x00; // reenable shift lock -#endif - return; - } - } - -#if U64 == 2 - MATRIX_WASD_TO_JOY = wasd_to_joy; - BLING_RX_FLAGS = 0x00; // reenable shift lock -#endif + } + col = (col << 1) | 1; + } + } else if (!software_joy_only) { // no key pressed + mtrx_prev = 0xFF; + shift_prev = 0xFF; +#if U64 == 2 + MATRIX_WASD_TO_JOY = wasd_to_joy; + BLING_RX_FLAGS = 0x00; // reenable shift lock +#endif + return; + } + } + if (software_joy_only && !keyboard_pressed) { + shift_flag = joy_shift_flag; + mtrx = joy_mtrx; + } + +#if U64 == 2 + MATRIX_WASD_TO_JOY = wasd_to_joy; + BLING_RX_FLAGS = 0x00; // reenable shift lock +#endif // there was a key pressed (or the joystick is used) // determine which map to use map = keymaps[shift_flag & 0x07]; diff --git a/software/io/c64/keyboard_c64.h b/software/io/c64/keyboard_c64.h index ddaf9cd9c..650285182 100644 --- a/software/io/c64/keyboard_c64.h +++ b/software/io/c64/keyboard_c64.h @@ -26,15 +26,16 @@ class Keyboard_C64 : public Keyboard int key_buffer[KEY_BUFFER_SIZE]; int key_head; int key_tail; -public: - Keyboard_C64(GenericHost *, volatile uint8_t *r, volatile uint8_t *c, volatile uint8_t *j); - ~Keyboard_C64(); - - static uint8_t scan_keyboard(volatile uint8_t *r, volatile uint8_t *c); - - void scan(void); - void set_delays(int, int); - int getch(void); +public: + Keyboard_C64(GenericHost *, volatile uint8_t *r, volatile uint8_t *c, volatile uint8_t *j); + ~Keyboard_C64(); + + static uint8_t scan_keyboard(volatile uint8_t *r, volatile uint8_t *c); + static bool joystick_blocks_keyboard(uint8_t observed_active_low, uint8_t injected_active_low); + + void scan(void); + void set_delays(int, int); + int getch(void); void push_head(int); void wait_free(void); void clear_buffer(void); diff --git a/software/io/usb/usb_hid.cc b/software/io/usb/usb_hid.cc index 93d4f775c..1369af341 100644 --- a/software/io/usb/usb_hid.cc +++ b/software/io/usb/usb_hid.cc @@ -295,20 +295,25 @@ void usb_hid_clear_visibility_if_source_matches(t_usb_hid_visibility& visibility } } -void usb_hid_apply_mouse_output_enable() -{ -#if U64 +void usb_hid_apply_mouse_output_enable() +{ +#if U64 C64_MOUSE_EN_1 = (usb_hid_active_mouse_interfaces > 0) ? 1 : 0; if (usb_hid_active_mouse_interfaces == 0) { usb_hid_set_joy1_output(0x1F); } #endif } - -} - -void usb_hid_get_status_snapshot(t_usb_hid_status_snapshot& snapshot) -{ + +} + +extern "C" int usb_hid_get_active_mouse_interfaces(void) +{ + return usb_hid_active_mouse_interfaces; +} + +void usb_hid_get_status_snapshot(t_usb_hid_status_snapshot& snapshot) +{ portENTER_CRITICAL(); memcpy(snapshot.mouse_name, usb_hid_mouse_visibility.name, sizeof(snapshot.mouse_name)); memcpy(snapshot.mouse_mode, usb_hid_mouse_visibility.mode, sizeof(snapshot.mouse_mode)); diff --git a/tools/api/input_test.py b/tools/api/input_test.py index 98afe83e6..7bd861b7a 100755 --- a/tools/api/input_test.py +++ b/tools/api/input_test.py @@ -310,11 +310,13 @@ def prepare_basic_entry_row(session: RestInputSession) -> int: def assert_joystick_ports(session: RestInputSession, port1: int, port2: int) -> None: - actual_port1, actual_port2 = read_joystick_cia(session) - if (actual_port1 & 0x1F) != port1 or (actual_port2 & 0x1F) != port2: + actual_port_a, actual_port_b = read_joystick_cia(session) + actual_port1 = actual_port_b & 0x1F + actual_port2 = actual_port_a & 0x1F + if actual_port1 != port1 or actual_port2 != port2: raise Failure( f"Expected joy1=${port1:02X} joy2=${port2:02X}, " - f"got joy1=${actual_port1 & 0x1F:02X} joy2=${actual_port2 & 0x1F:02X}" + f"got joy1=${actual_port1:02X} joy2=${actual_port2:02X}" ) @@ -373,6 +375,39 @@ def read_keyboard_row(session: RestInputSession, row: int) -> int: session.resume() +def read_joystick_pots(session: RestInputSession, port: int) -> Tuple[int, int]: + if port not in (1, 2): + raise Failure(f"Invalid joystick port for POT read: {port}") + # Mirror Anykey's VIC-II probe: select the joystick port on CIA1 first, + # leave the machine running briefly, then read SID POTX/POTY. + session.write_memory(0xDC02, b"\xC0") + session.write_memory(0xDC00, b"\x40" if port == 1 else b"\x80") + time.sleep(0.10) + regs = session.read_memory(0xD419, 2) + return regs[0], regs[1] + + +def assert_joystick_pots(session: RestInputSession, port: int, potx: int, poty: int) -> None: + actual_potx, actual_poty = read_joystick_pots(session, port) + if (actual_potx, actual_poty) != (potx, poty): + raise Failure( + f"Joystick port {port} POT mismatch; expected ${potx:02X}/${poty:02X}, " + f"got ${actual_potx:02X}/${actual_poty:02X}" + ) + + +def assert_anykey_extra_buttons(session: RestInputSession, port: int, fire2: bool, fire3: bool) -> None: + potx, poty = read_joystick_pots(session, port) + actual_fire2 = (potx & 0x80) == 0 + actual_fire3 = (poty & 0x80) == 0 + if (actual_fire2, actual_fire3) != (fire2, fire3): + raise Failure( + f"Joystick port {port} Anykey extra-button mismatch; " + f"expected fire2/fire3={fire2}/{fire3}, got {actual_fire2}/{actual_fire3} " + f"from POTX/POTY=${potx:02X}/${poty:02X}" + ) + + def assert_keyboard_matrix(session: RestInputSession, input_name: str, active: bool) -> None: mapping = KEYBOARD_MATRIX.get(input_name) if mapping is None: @@ -961,7 +996,23 @@ def run_keyboard_tests(session: RestInputSession) -> None: def run_joystick_tests(session: RestInputSession) -> None: - reset_to_basic(session) + session.post_events([{"kind": "release_all"}]) + + with check("joystick port 2 fire keeps Anykey buttons 2 and 3 released"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "press"}]) + assert_joystick_ports(session, 0x1F, 0x0F) + assert_anykey_extra_buttons(session, 2, fire2=False, fire3=False) + + with check("joystick port 2 fire2 lights only Anykey button 2"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["fire2"], "transition": "press"}]) + assert_anykey_extra_buttons(session, 2, fire2=True, fire3=False) + + with check("joystick port 2 fire3 lights only Anykey button 3"): + session.post_events([{"kind": "release_all"}]) + session.post_events([{"kind": "joystick", "port": 2, "inputs": ["fire3"], "transition": "press"}]) + assert_anykey_extra_buttons(session, 2, fire2=False, fire3=True) with check("joystick port 1 up press is visible on CIA reads"): session.post_events([{"kind": "release_all"}]) @@ -1086,8 +1137,8 @@ def run_tests(session: RestInputSession, soak_duration_seconds: Optional[float] if soak_duration_seconds is not None: return run_soak_tests(session, soak_duration_seconds) run_contract_tests(session) - run_keyboard_tests(session) run_joystick_tests(session) + run_keyboard_tests(session) return 0 diff --git a/tools/api/input_tool.py b/tools/api/input_tool.py index 89594f10e..12160cbd5 100755 --- a/tools/api/input_tool.py +++ b/tools/api/input_tool.py @@ -58,14 +58,10 @@ "D": "left", } TERMINAL_SPECIAL_KEY_HELP_LINES = ( - "terminal fallback: direct text, arrows, Tab=CTRL, F1-F8=F1-F8, Home=CLR/HOME", - " Backspace/Delete/Insert=INST/DEL, End/PgDn=RUN/STOP, PgUp/F12=RESTORE", - " Ctrl=C= requires the sudo /dev/input path; raw-terminal fallback does not synthesize it", + "fallback: text/arrows/F1-F8/Home/Ins/Del/PgUp/PgDn, Tab=CTRL, Esc=release_all", ) LOW_LEVEL_SPECIAL_KEY_HELP_LINES = ( - "low-level keyboard: Linux keys map directly to C64 keys, including real L-SHIFT/R-SHIFT", - " Tab=CTRL, Ctrl=C=, arrows=cursor, Home=CLR/HOME, Backspace/Delete/Insert=INST/DEL", - " End/PgDn=RUN/STOP, PgUp/F12=RESTORE, F1-F8=F1-F8, Esc=release_all, Ctrl+Esc=quit", + "keys: direct Linux mapping; Ctrl=C=, Tab=CTRL, Esc=release_all, Ctrl+Esc=quit", ) DIRECT_KEY_SEQUENCE_MAP: Dict[str, List[str]] = { "\x1bOP": ["f1"], @@ -708,11 +704,7 @@ def apply_input_event(self, event_type: int, code: int, value: int, now: float) if event_type != EV_KEY: return [] - if code in (BTN_SOUTH, BTN_TRIGGER): - self.a_pressed = value != 0 - return self._recompute_logical_inputs() - - if code in (BTN_NORTH, BTN_THUMB2): + if code in (BTN_SOUTH, BTN_NORTH, BTN_TRIGGER, BTN_THUMB2): self.a_pressed = value != 0 return self._recompute_logical_inputs() From 90486bad640328b133bbc9ed7eb583c3b5f02004 Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Tue, 19 May 2026 22:37:19 +0100 Subject: [PATCH 5/7] Improve input_tool.py to offer positional US keyboard mapping and joystick port toggle via F12 --- tools/api/input_tool.py | 278 +++++++++++++++++++++++++++++++++++----- 1 file changed, 246 insertions(+), 32 deletions(-) diff --git a/tools/api/input_tool.py b/tools/api/input_tool.py index 12160cbd5..1835187e2 100755 --- a/tools/api/input_tool.py +++ b/tools/api/input_tool.py @@ -34,16 +34,21 @@ "\r": "return", "\n": "return", "\t": "ctrl", - "\x7f": "inst_del", - "\b": "inst_del", + "\x7f": "pound", + "\b": "pound", + "`": "arrow_left", ":": "colon", - ";": "semicolon", + ";": "colon", + "'": "semicolon", ",": "comma", ".": "period", "/": "slash", "+": "plus", - "-": "minus", - "=": "equals", + "-": "plus", + "=": "minus", + "[": "at", + "]": "star", + "\\": "arrow_up", "@": "at", "*": "star", "^": "arrow_up", @@ -58,10 +63,10 @@ "D": "left", } TERMINAL_SPECIAL_KEY_HELP_LINES = ( - "fallback: text/arrows/F1-F8/Home/Ins/Del/PgUp/PgDn, Tab=CTRL, Esc=release_all", + "fallback: text/arrows/F1-F8/Home/Ins/Del/PgUp/PgDn, Tab=CTRL, F12=joy 1/2, Esc=release_all", ) LOW_LEVEL_SPECIAL_KEY_HELP_LINES = ( - "keys: direct Linux mapping; Ctrl=C=, Tab=CTRL, Esc=release_all, Ctrl+Esc=quit", + "keys: direct Linux mapping; Ctrl=C=, Tab=CTRL, F12=joy 1/2, Esc=release_all, Ctrl+Esc=quit", ) DIRECT_KEY_SEQUENCE_MAP: Dict[str, List[str]] = { "\x1bOP": ["f1"], @@ -80,19 +85,18 @@ "\x1b[17~": ["left_shift", "f5"], "\x1b[18~": ["f7"], "\x1b[19~": ["left_shift", "f7"], - "\x1b[H": ["clr_home"], - "\x1bOH": ["clr_home"], - "\x1b[1~": ["clr_home"], - "\x1b[7~": ["clr_home"], - "\x1b[2~": ["inst_del"], - "\x1b[3~": ["inst_del"], + "\x1b[H": ["inst_del"], + "\x1bOH": ["inst_del"], + "\x1b[1~": ["inst_del"], + "\x1b[7~": ["inst_del"], + "\x1b[2~": ["clr_home"], + "\x1b[3~": ["restore"], "\x1b[F": ["run_stop"], "\x1bOF": ["run_stop"], "\x1b[4~": ["run_stop"], "\x1b[6~": ["run_stop"], "\x1b[8~": ["run_stop"], - "\x1b[5~": ["restore"], - "\x1b[24~": ["restore"], + "\x1b[5~": ["equals"], } ESCAPE_SEQUENCE_TIMEOUT = float(os.environ.get("U64_INPUT_ESCAPE_TIMEOUT", "0.10")) MAX_BATCH_EVENTS = 64 @@ -189,6 +193,7 @@ KEY_RIGHTSHIFT = 54 KEY_LEFTALT = 56 KEY_SPACE = 57 +KEY_CAPSLOCK = 58 KEY_F1 = 59 KEY_F2 = 60 KEY_F3 = 61 @@ -250,12 +255,22 @@ KEYCODE_BASE_INPUT_MAP: Dict[int, str] = { **LETTER_KEYCODES, **DIGIT_KEYCODES, - KEY_MINUS: "minus", - KEY_EQUAL: "equals", - KEY_SEMICOLON: "semicolon", + KEY_GRAVE: "arrow_left", + KEY_MINUS: "plus", + KEY_EQUAL: "minus", + KEY_LEFTBRACE: "at", + KEY_RIGHTBRACE: "star", + KEY_BACKSLASH: "arrow_up", + KEY_SEMICOLON: "colon", + KEY_APOSTROPHE: "semicolon", KEY_COMMA: "comma", KEY_DOT: "period", KEY_SLASH: "slash", + KEY_BACKSPACE: "pound", + KEY_INSERT: "clr_home", + KEY_HOME: "inst_del", + KEY_PAGEUP: "equals", + KEY_CAPSLOCK: "run_stop", } ARROW_KEYCODE_DIRECTION = { KEY_UP: "up", @@ -266,27 +281,35 @@ LOW_LEVEL_DIRECT_KEY_INPUTS: Dict[int, str] = { **LETTER_KEYCODES, **DIGIT_KEYCODES, - KEY_MINUS: "minus", - KEY_EQUAL: "equals", - KEY_SEMICOLON: "semicolon", + KEY_GRAVE: "arrow_left", + KEY_MINUS: "plus", + KEY_EQUAL: "minus", + KEY_LEFTBRACE: "at", + KEY_RIGHTBRACE: "star", + KEY_BACKSLASH: "arrow_up", + KEY_SEMICOLON: "colon", + KEY_APOSTROPHE: "semicolon", KEY_COMMA: "comma", KEY_DOT: "period", KEY_SLASH: "slash", KEY_SPACE: "space", KEY_ENTER: "return", - KEY_BACKSPACE: "inst_del", - KEY_DELETE: "inst_del", - KEY_INSERT: "inst_del", - KEY_HOME: "clr_home", + KEY_BACKSPACE: "pound", + KEY_INSERT: "clr_home", + KEY_HOME: "inst_del", + KEY_CAPSLOCK: "run_stop", KEY_END: "run_stop", KEY_PAGEDOWN: "run_stop", - KEY_PAGEUP: "restore", + KEY_PAGEUP: "equals", KEY_RIGHT: "cursor_left_right", KEY_DOWN: "cursor_up_down", KEY_F1: "f1", KEY_F3: "f3", KEY_F5: "f5", KEY_F7: "f7", +} +LOW_LEVEL_TAP_KEY_INPUTS: Dict[int, str] = { + KEY_DELETE: "restore", KEY_F12: "restore", } LOW_LEVEL_SHIFTED_KEY_INPUTS: Dict[int, str] = { @@ -444,6 +467,8 @@ def translate(self, seq: str, joystick_port: int, now: float) -> Tuple[str, Opti return "prefix", None, None if seq in QUIT_SEQUENCES: return "quit", None, None + if seq == "\x1b[24~": + return "toggle_joystick_port", None, None event = translate_sequence(seq, joystick_port) if not event: return "ignore", None, None @@ -724,6 +749,18 @@ def poll_repeat_fire(self, now: float) -> Optional[Dict[str, object]]: def repeat_timeout(self, now: float) -> Optional[float]: return None + def toggle_port(self) -> List[Dict[str, object]]: + if self.logical_inputs: + held_inputs = self._ordered(self.logical_inputs) + old_port = self.port + self.port = 1 if self.port == 2 else 2 + return [ + {"kind": "joystick", "port": old_port, "inputs": held_inputs, "transition": "release"}, + {"kind": "joystick", "port": self.port, "inputs": held_inputs, "transition": "press"}, + ] + self.port = 1 if self.port == 2 else 2 + return [] + class GamepadDevice: def __init__(self, path: str, port: int) -> None: @@ -1209,6 +1246,10 @@ def _release_modifier(self, code: int) -> Optional[Dict[str, object]]: return None def _press_mapped_key(self, code: int) -> List[Dict[str, object]]: + tap_input = LOW_LEVEL_TAP_KEY_INPUTS.get(code) + if tap_input is not None: + return [keyboard_event([tap_input])] + direct_input = LOW_LEVEL_DIRECT_KEY_INPUTS.get(code) if direct_input is not None: return [keyboard_state_event([direct_input], "press")] @@ -1225,6 +1266,10 @@ def _press_mapped_key(self, code: int) -> List[Dict[str, object]]: return [] def _release_mapped_key(self, code: int) -> List[Dict[str, object]]: + tap_input = LOW_LEVEL_TAP_KEY_INPUTS.get(code) + if tap_input is not None: + return [] + direct_input = LOW_LEVEL_DIRECT_KEY_INPUTS.get(code) if direct_input is not None: return [keyboard_state_event([direct_input], "release")] @@ -1252,6 +1297,9 @@ def apply_input_event(self, code: int, value: int) -> Tuple[Optional[str], List[ if code in (KEY_LEFTSHIFT, KEY_RIGHTSHIFT, KEY_LEFTCTRL, KEY_RIGHTCTRL, KEY_LEFTALT, KEY_RIGHTALT, KEY_TAB): return None, [] + if code == KEY_F12: + return "toggle_joystick_port", [] + if code == KEY_ESC and value == 1: if self.commodore.active(): return "quit", [] @@ -1427,9 +1475,9 @@ def print_mapping_overview( ) -> None: print(f"REST input tool -> {host}") if keyboard: - print("keys: low-level Linux mapping; Ctrl=C=, Tab=CTRL, arrows/F1-F8/Home/etc map directly; Esc=release_all; Ctrl+Esc=quit") + print("keys: low-level Linux mapping; Ctrl=C=, Tab=CTRL, F12 swaps joy 1/2, arrows/F1-F8/Home/etc map directly; Esc=release_all; Ctrl+Esc=quit") else: - print("keys: terminal fallback; direct text/arrows/F1-F8/Home/Tab only; Esc=release_all") + print("keys: terminal fallback; direct text/arrows/F1-F8/Home/Tab only; F12 swaps joy 1/2; Esc=release_all") print_special_key_help(low_level_keyboard=keyboard is not None) if keyboard: print("keyboard repeats: Linux low-level key-repeat events") @@ -1473,6 +1521,10 @@ def handle_interactive_sequence( return None, repeat_state.poll(now) if action == "help": return "help", repeat_state.poll(now) + if action == "toggle_joystick_port": + repeat_state.stop() + decoder.clear() + return "toggle_joystick_port", [] if action == "quit": repeat_state.stop() decoder.clear() @@ -1489,6 +1541,13 @@ def handle_interactive_sequence( return None, extra_events + [event] +def toggle_gamepad_port(gamepad: Optional[GamepadDevice]) -> Tuple[Optional[int], List[Dict[str, object]]]: + if gamepad is None: + return None, [] + events = gamepad.state.toggle_port() + return gamepad.state.port, events + + def run_interactive_loop( session: RestInputSession, joystick_port: int, @@ -1519,6 +1578,10 @@ def run_interactive_loop( pending_events = [] elif control == "help": print_special_key_help() + elif control == "toggle_joystick_port": + new_port, toggle_events = toggle_gamepad_port(None) + if toggle_events: + pending_events.extend(toggle_events) elif control == "quit": if pending_events: flush_event_batch(client, pending_events) @@ -1575,6 +1638,12 @@ def run_interactive_with_gamepad( pending_events = [] elif control == "help": print_special_key_help() + elif control == "toggle_joystick_port": + new_port, toggle_events = toggle_gamepad_port(gamepad) + if toggle_events: + pending_events.extend(toggle_events) + if new_port is not None: + print(f"joystick port: {new_port}") elif control == "quit": if pending_events: flush_event_batch(client, pending_events) @@ -1621,6 +1690,12 @@ def run_interactive_low_level( if pending_events: flush_event_batch(client, pending_events) pending_events = [] + elif control == "toggle_joystick_port": + new_port, toggle_events = toggle_gamepad_port(gamepad) + if toggle_events: + pending_events.extend(toggle_events) + if new_port is not None: + print(f"joystick port: {new_port}") elif control == "quit": if pending_events: flush_event_batch(client, pending_events) @@ -1708,6 +1783,9 @@ def collect_low_level_keyboard_events(key_events: List[Tuple[int, int]], joystic def run_local_input_tool_regressions(joystick_port: int) -> None: decoder = SequenceDecoder() + if any(input_name in ("shift_lock", "shl") for input_name in LOW_LEVEL_DIRECT_KEY_INPUTS.values()): + raise Failure("Low-level positional map must not expose shift_lock/shl") + for seq, expected in ( ("\x1b[A", ["left_shift", "cursor_up_down"]), ("\x1b[B", ["cursor_up_down"]), @@ -1786,6 +1864,86 @@ def run_local_input_tool_regressions(joystick_port: int) -> None: ]: raise Failure(f"Low-level ctrl mapping mismatch: {ctrl_events}") + direct_home_row_events = collect_low_level_keyboard_events( + [ + (KEY_A, 1), (KEY_A, 0), + (KEY_S, 1), (KEY_S, 0), + (KEY_D, 1), (KEY_D, 0), + (KEY_F, 1), (KEY_F, 0), + (KEY_G, 1), (KEY_G, 0), + (KEY_H, 1), (KEY_H, 0), + (KEY_J, 1), (KEY_J, 0), + (KEY_K, 1), (KEY_K, 0), + (KEY_L, 1), (KEY_L, 0), + ], + joystick_port, + ) + if [events for _, events in direct_home_row_events if events] != [ + [keyboard_state_event(["a"], "press")], + [keyboard_state_event(["a"], "release")], + [keyboard_state_event(["s"], "press")], + [keyboard_state_event(["s"], "release")], + [keyboard_state_event(["d"], "press")], + [keyboard_state_event(["d"], "release")], + [keyboard_state_event(["f"], "press")], + [keyboard_state_event(["f"], "release")], + [keyboard_state_event(["g"], "press")], + [keyboard_state_event(["g"], "release")], + [keyboard_state_event(["h"], "press")], + [keyboard_state_event(["h"], "release")], + [keyboard_state_event(["j"], "press")], + [keyboard_state_event(["j"], "release")], + [keyboard_state_event(["k"], "press")], + [keyboard_state_event(["k"], "release")], + [keyboard_state_event(["l"], "press")], + [keyboard_state_event(["l"], "release")], + ]: + raise Failure(f"Low-level home-row mapping mismatch: {direct_home_row_events}") + + positional_special_events = { + KEY_CAPSLOCK: "run_stop", + KEY_ENTER: "return", + KEY_BACKSPACE: "pound", + KEY_INSERT: "clr_home", + KEY_HOME: "inst_del", + KEY_PAGEUP: "equals", + KEY_GRAVE: "arrow_left", + KEY_MINUS: "plus", + KEY_EQUAL: "minus", + KEY_LEFTBRACE: "at", + KEY_RIGHTBRACE: "star", + KEY_BACKSLASH: "arrow_up", + KEY_SEMICOLON: "colon", + KEY_APOSTROPHE: "semicolon", + } + for keycode, input_name in positional_special_events.items(): + events = collect_low_level_keyboard_events([(keycode, 1), (keycode, 0)], joystick_port) + expected = [ + (None, [keyboard_state_event([input_name], "press")]), + (None, [keyboard_state_event([input_name], "release")]), + ] + if events != expected: + raise Failure(f"Low-level positional mapping mismatch for {keycode}: {events}") + + restore_tap_events = { + KEY_DELETE: "restore", + } + for keycode, input_name in restore_tap_events.items(): + events = collect_low_level_keyboard_events([(keycode, 1), (keycode, 0)], joystick_port) + expected = [ + (None, [keyboard_event([input_name])]), + (None, []), + ] + if events != expected: + raise Failure(f"Low-level restore tap mismatch for {keycode}: {events}") + + low_level_toggle_events = collect_low_level_keyboard_events([(KEY_F12, 1), (KEY_F12, 0)], joystick_port) + if low_level_toggle_events != [ + ("toggle_joystick_port", []), + (None, []), + ]: + raise Failure(f"Low-level F12 toggle mismatch: {low_level_toggle_events}") + quit_events = collect_low_level_keyboard_events([(KEY_LEFTCTRL, 1), (KEY_ESC, 1)], joystick_port) if quit_events != [ (None, [keyboard_state_event(["commodore"], "press")]), @@ -1863,6 +2021,36 @@ def run_local_input_tool_regressions(joystick_port: int) -> None: ]: raise Failure(f"Low-level left-arrow mapping mismatch: {left_arrow_events}") + down_arrow_events = collect_low_level_keyboard_events( + [(KEY_DOWN, 1), (KEY_DOWN, 0)], + joystick_port, + ) + if down_arrow_events != [ + (None, [keyboard_state_event(["cursor_up_down"], "press")]), + (None, [keyboard_state_event(["cursor_up_down"], "release")]), + ]: + raise Failure(f"Low-level down-arrow mapping mismatch: {down_arrow_events}") + + right_arrow_events = collect_low_level_keyboard_events( + [(KEY_RIGHT, 1), (KEY_RIGHT, 0)], + joystick_port, + ) + if right_arrow_events != [ + (None, [keyboard_state_event(["cursor_left_right"], "press")]), + (None, [keyboard_state_event(["cursor_left_right"], "release")]), + ]: + raise Failure(f"Low-level right-arrow mapping mismatch: {right_arrow_events}") + + up_arrow_events = collect_low_level_keyboard_events( + [(KEY_UP, 1), (KEY_UP, 0)], + joystick_port, + ) + if up_arrow_events != [ + (None, [keyboard_state_event(["left_shift"], "press"), keyboard_state_event(["cursor_up_down"], "press")]), + (None, [keyboard_state_event(["cursor_up_down"], "release"), keyboard_state_event(["left_shift"], "release")]), + ]: + raise Failure(f"Low-level up-arrow mapping mismatch: {up_arrow_events}") + gamepad = GamepadState(joystick_port) fire23_events = [ gamepad.apply_input_event(EV_KEY, BTN_SOUTH, 1, 0.0), @@ -1908,6 +2096,17 @@ def run_local_input_tool_regressions(joystick_port: int) -> None: ]: raise Failure(f"Generic gamepad button mapping mismatch: {generic_button_events}") + toggled_gamepad = GamepadState(2) + toggled_gamepad.apply_input_event(EV_KEY, BTN_SOUTH, 1, 0.0) + toggle_events = toggled_gamepad.toggle_port() + if toggle_events != [ + {"kind": "joystick", "port": 2, "inputs": ["fire"], "transition": "release"}, + {"kind": "joystick", "port": 1, "inputs": ["fire"], "transition": "press"}, + ]: + raise Failure(f"Gamepad port toggle mismatch: {toggle_events}") + if toggled_gamepad.port != 1: + raise Failure(f"Gamepad port toggle did not switch to port 1: {toggled_gamepad.port}") + for seq, expected in ( ("\t", ["ctrl"]), ("\x1bOP", ["f1"]), @@ -1918,18 +2117,33 @@ def run_local_input_tool_regressions(joystick_port: int) -> None: ("\x1b[17~", ["left_shift", "f5"]), ("\x1b[18~", ["f7"]), ("\x1b[19~", ["left_shift", "f7"]), - ("\x1b[H", ["clr_home"]), - ("\x1b[3~", ["inst_del"]), - ("\b", ["inst_del"]), + ("\x1b[H", ["inst_del"]), + ("\x1b[2~", ["clr_home"]), + ("\x1b[3~", ["restore"]), + ("\b", ["pound"]), + ("\x7f", ["pound"]), ("\x1b[F", ["run_stop"]), - ("\x1b[5~", ["restore"]), + ("\x1b[5~", ["equals"]), ("\n", ["return"]), + ("`", ["arrow_left"]), + ("-", ["plus"]), + ("=", ["minus"]), + ("[", ["at"]), + ("]", ["star"]), + ("\\", ["arrow_up"]), + (";", ["colon"]), + ("'", ["semicolon"]), ): decoder.clear() action, event, repeat_sequence = decoder.translate(seq, joystick_port, 0.0) if action != "event" or event != keyboard_event(expected) or repeat_sequence != seq: raise Failure(f"Special mapping mismatch for {seq!r}: {event}") + decoder.clear() + action, event, repeat_sequence = decoder.translate("\x1b[24~", joystick_port, 0.0) + if action != "toggle_joystick_port" or event is not None or repeat_sequence is not None: + raise Failure(f"F12 terminal toggle mismatch: {(action, event, repeat_sequence)}") + for seq in ("\x1b[20~", "\x1b[Z", "\x1b[21~", "\x1b[23~", "\x1ba", "\x1bA", "\x1bo", "\x01", "\x18", "\x1b[1;5A", "\x1b[13;5u"): decoder.clear() action, event, repeat_sequence = decoder.translate(seq, joystick_port, 0.0) From 22d0c58783987d2cf56c5ad935712478e3012210 Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Tue, 19 May 2026 23:26:21 +0100 Subject: [PATCH 6/7] Code cleanup --- .gitignore | 4 + software/api/input_api.h | 17 +- software/api/route_input.cc | 23 +- .../api/tests/input_api_validation_test.cpp | 67 ++---- software/io/c64/joystick_output.cc | 15 +- software/io/c64/keyboard_c64.cc | 198 +++++++++--------- software/io/c64/keyboard_c64.h | 26 +-- software/io/usb/keyboard_usb.cc | 30 ++- software/io/usb/keyboard_usb.h | 4 +- target/u2/riscv/ultimate/Makefile | 1 + target/u2plus/nios/ultimate/Makefile | 1 + target/u2plus_L/riscv/ultimate/Makefile | 1 + tools/api/input_test.py | 37 ++-- tools/api/input_tool.py | 41 +--- 14 files changed, 210 insertions(+), 255 deletions(-) diff --git a/.gitignore b/.gitignore index 1c3e82dee..12edbf8c7 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,10 @@ fpga/sid_old roms/JiffyDOS_1541_6.00_(rebadged_5.0).bin software/api/tests/inputApiValidationTest software/api/tests/inputApiStateTest +software/io/usb/tests/usbHidKeyboardTest +software/io/usb/tests/usbHidMouseTest +software/io/usb/tests/usbHidSelectionTest +software/io/usb/tests/usbKeyboardQueueTest software/Debug software/Lean software/PathTest diff --git a/software/api/input_api.h b/software/api/input_api.h index e53d8bd35..45aa3af65 100644 --- a/software/api/input_api.h +++ b/software/api/input_api.h @@ -443,7 +443,7 @@ static inline bool input_api_parse_event(JSON *json, InputParsedEvent &out, char } static inline bool input_api_validate_batch(JSON *root, InputParsedEvent events[INPUT_API_MAX_EVENTS], int &event_count, - int &pace_ms, int &error_index, char *err, size_t err_size) + int &error_index, char *err, size_t err_size) { error_index = -1; if (!root || (root->type() != eObject)) { @@ -451,23 +451,10 @@ static inline bool input_api_validate_batch(JSON *root, InputParsedEvent events[ return false; } JSON_Object *obj = (JSON_Object *)root; - static const char *const allowed[] = { "events", "pace_ms" }; + static const char *const allowed[] = { "events" }; if (!input_api_reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err, err_size)) { return false; } - pace_ms = 0; - JSON *pace = obj->get("pace_ms"); - if (pace) { - if (pace->type() != eInteger) { - input_api_set_error(err, err_size, "`pace_ms` must be an integer."); - return false; - } - pace_ms = ((JSON_Integer *)pace)->get_value(); - if ((pace_ms < 0) || (pace_ms > 1000)) { - input_api_set_error(err, err_size, "`pace_ms` must be between 0 and 1000."); - return false; - } - } JSON *events_json = obj->get("events"); if (!events_json) { input_api_set_error(err, err_size, "`events` is required."); diff --git a/software/api/route_input.cc b/software/api/route_input.cc index 216b3bc83..219d9f4b9 100644 --- a/software/api/route_input.cc +++ b/software/api/route_input.cc @@ -7,7 +7,6 @@ #include "FreeRTOS.h" #include "semphr.h" -#include "task.h" #include #include @@ -137,10 +136,7 @@ void *input_json_writer(HTTPReqMessage *req, HTTPRespMessage *resp, const ApiCal #if U64 -// The REST tap timer ticks every 10 ms in Keyboard_USB. Hold taps for 60 ms so -// BASIC's asynchronous keyboard scan sees them reliably without regressing into -// the old long 200 ms experimental hold. -static const uint8_t REST_TAP_HOLD_TICKS = 6; +static const uint8_t REST_TAP_HOLD_TICKS = 1; static SemaphoreHandle_t rest_input_mutex = NULL; static SemaphoreHandle_t input_mutex(void) @@ -233,15 +229,8 @@ static void apply_keyboard_event(const InputParsedEvent &event) } } -static void apply_batch(const InputParsedEvent *events, int event_count, int pace_ms) +static void apply_batch(const InputParsedEvent *events, int event_count) { - TickType_t delay_ticks = 0; - if (pace_ms > 0) { - delay_ticks = pdMS_TO_TICKS(pace_ms); - if (delay_ticks == 0) { - delay_ticks = 1; - } - } for (int i = 0; i < event_count; i++) { switch (events[i].kind) { case INPUT_PARSED_KEYBOARD: @@ -255,9 +244,6 @@ static void apply_batch(const InputParsedEvent *events, int event_count, int pac JoystickOutput::instance().releaseAllRest(); break; } - if (delay_ticks && ((i + 1) < event_count)) { - vTaskDelay(delay_ticks); - } } } @@ -399,12 +385,11 @@ API_CALL(POST, machine, input, &input_json_writer, ARRAY( { })) static InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; char err[INPUT_API_ERROR_SIZE]; SemaphoreHandle_t mutex = input_mutex(); xSemaphoreTake(mutex, portMAX_DELAY); - if (!input_api_validate_batch(obj, events, event_count, pace_ms, error_index, err, sizeof(err))) { + if (!input_api_validate_batch(obj, events, event_count, error_index, err, sizeof(err))) { xSemaphoreGive(mutex); delete obj; if (error_index >= 0) { @@ -415,7 +400,7 @@ API_CALL(POST, machine, input, &input_json_writer, ARRAY( { })) resp->json_response(HTTP_BAD_REQUEST); return; } - apply_batch(events, event_count, pace_ms); + apply_batch(events, event_count); emit_state_snapshot(resp); xSemaphoreGive(mutex); resp->json_response(HTTP_OK); diff --git a/software/api/tests/input_api_validation_test.cpp b/software/api/tests/input_api_validation_test.cpp index eb7ed89cb..b04bc0576 100644 --- a/software/api/tests/input_api_validation_test.cpp +++ b/software/api/tests/input_api_validation_test.cpp @@ -41,20 +41,16 @@ JSON_Object *make_release_all_event(void) return JSON::Obj()->add("kind", "release_all"); } -JSON_Object *make_root(JSON_List *events, int pace_ms = 0, bool include_pace = false) +JSON_Object *make_root(JSON_List *events) { - JSON_Object *root = JSON::Obj()->add("events", events); - if (include_pace) { - root->add("pace_ms", pace_ms); - } - return root; + return JSON::Obj()->add("events", events); } -bool validate(JSON *root, InputParsedEvent events[INPUT_API_MAX_EVENTS], int &event_count, int &pace_ms, - int &error_index, std::string &err) +bool validate(JSON *root, InputParsedEvent events[INPUT_API_MAX_EVENTS], int &event_count, int &error_index, + std::string &err) { char buffer[INPUT_API_ERROR_SIZE] = { 0 }; - bool ok = input_api_validate_batch(root, events, event_count, pace_ms, error_index, buffer, sizeof(buffer)); + bool ok = input_api_validate_batch(root, events, event_count, error_index, buffer, sizeof(buffer)); err = buffer; return ok; } @@ -76,7 +72,6 @@ TEST(InputApiValidationTest, ParsesValidBatchAndPreservesEventDetails) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; @@ -84,12 +79,10 @@ TEST(InputApiValidationTest, ParsesValidBatchAndPreservesEventDetails) JSON::List() ->add(make_keyboard_event("press", { "a", "left_shift" })) ->add(make_joystick_event(2, "tap", { "up", "fire", "fire2", "fire3" })) - ->add(make_release_all_event()), - 25, true); + ->add(make_release_all_event())); - ASSERT_TRUE(validate(root, events, event_count, pace_ms, error_index, err)); + ASSERT_TRUE(validate(root, events, event_count, error_index, err)); EXPECT_EQ(3, event_count); - EXPECT_EQ(25, pace_ms); EXPECT_EQ(-1, error_index); EXPECT_EQ(INPUT_PARSED_KEYBOARD, events[0].kind); EXPECT_EQ(INPUT_PARSED_PRESS, events[0].transition); @@ -109,42 +102,23 @@ TEST(InputApiValidationTest, RejectsUnknownRootField) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; JSON *root = make_root(JSON::List()->add(make_release_all_event())); ((JSON_Object *)root)->add("extra", true); - EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_FALSE(validate(root, events, event_count, error_index, err)); EXPECT_EQ(-1, error_index); EXPECT_EQ(std::string("Unknown field `extra`."), err); delete root; } -TEST(InputApiValidationTest, RejectsInvalidPaceValue) -{ - InputParsedEvent events[INPUT_API_MAX_EVENTS]; - int event_count = 0; - int pace_ms = 0; - int error_index = -1; - std::string err; - - JSON *root = make_root(JSON::List()->add(make_release_all_event())); - ((JSON_Object *)root)->add("pace_ms", 1001); - - EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); - EXPECT_EQ(std::string("`pace_ms` must be between 0 and 1000."), err); - - delete root; -} - TEST(InputApiValidationTest, RejectsLateInvalidEventAndReportsIndex) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; @@ -154,7 +128,7 @@ TEST(InputApiValidationTest, RejectsLateInvalidEventAndReportsIndex) ->add(make_keyboard_event("press", { "left_shift" })) ->add(make_joystick_event(3, "press", { "up" }))); - EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_FALSE(validate(root, events, event_count, error_index, err)); EXPECT_EQ(2, error_index); EXPECT_EQ(std::string("`port` must be 1 or 2."), err); @@ -167,14 +141,13 @@ TEST(InputApiValidationTest, AcceptsEveryDocumentedKeyboardInput) for (int i = 0; i < input_api_keyboard_map_count(); i++) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; const char *transition = keyboard_map[i].restore ? "tap" : "press"; JSON *root = make_root(JSON::List()->add(make_keyboard_event(transition, { keyboard_map[i].name }))); - EXPECT_TRUE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_TRUE(validate(root, events, event_count, error_index, err)); delete root; } } @@ -183,13 +156,12 @@ TEST(InputApiValidationTest, RejectsRestoreOutsideTapAloneRule) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; JSON *root = make_root(JSON::List()->add(make_keyboard_event("press", { "restore" }))); - EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_FALSE(validate(root, events, event_count, error_index, err)); EXPECT_EQ(0, error_index); EXPECT_TRUE(err.find("`restore` must appear alone") != std::string::npos); @@ -200,13 +172,12 @@ TEST(InputApiValidationTest, RejectsKeyboardDuplicateInputs) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; JSON *root = make_root(JSON::List()->add(make_keyboard_event("tap", { "a", "a" }))); - EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_FALSE(validate(root, events, event_count, error_index, err)); EXPECT_EQ(std::string("`a` appears more than once in `inputs`."), err); delete root; @@ -218,13 +189,12 @@ TEST(InputApiValidationTest, AcceptsEveryDocumentedJoystickInput) for (int i = 0; i < input_api_joystick_map_count(); i++) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; JSON *root = make_root(JSON::List()->add(make_joystick_event(1, "press", { joystick_map[i].name }))); - EXPECT_TRUE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_TRUE(validate(root, events, event_count, error_index, err)); delete root; } } @@ -233,17 +203,16 @@ TEST(InputApiValidationTest, RejectsJoystickDuplicateAndUnknownInputs) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; JSON *duplicate = make_root(JSON::List()->add(make_joystick_event(1, "tap", { "up", "up" }))); - EXPECT_FALSE(validate(duplicate, events, event_count, pace_ms, error_index, err)); + EXPECT_FALSE(validate(duplicate, events, event_count, error_index, err)); EXPECT_EQ(std::string("`up` appears more than once in `inputs`."), err); delete duplicate; JSON *unknown = make_root(JSON::List()->add(make_joystick_event(1, "tap", { "jump" }))); - EXPECT_FALSE(validate(unknown, events, event_count, pace_ms, error_index, err)); + EXPECT_FALSE(validate(unknown, events, event_count, error_index, err)); EXPECT_EQ(std::string("`jump` is not a valid joystick input."), err); delete unknown; } @@ -252,14 +221,13 @@ TEST(InputApiValidationTest, AcceptsAllSevenJoystickInputsInOneEvent) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; JSON *root = make_root(JSON::List()->add( make_joystick_event(2, "press", { "up", "down", "left", "right", "fire", "fire2", "fire3" }))); - ASSERT_TRUE(validate(root, events, event_count, pace_ms, error_index, err)); + ASSERT_TRUE(validate(root, events, event_count, error_index, err)); ASSERT_EQ(1, event_count); EXPECT_EQ(0x7F, events[0].joystick_mask); @@ -270,7 +238,6 @@ TEST(InputApiValidationTest, RejectsReleaseAllExtraFields) { InputParsedEvent events[INPUT_API_MAX_EVENTS]; int event_count = 0; - int pace_ms = 0; int error_index = -1; std::string err; @@ -278,7 +245,7 @@ TEST(InputApiValidationTest, RejectsReleaseAllExtraFields) event->add("port", 1); JSON *root = make_root(JSON::List()->add(event)); - EXPECT_FALSE(validate(root, events, event_count, pace_ms, error_index, err)); + EXPECT_FALSE(validate(root, events, event_count, error_index, err)); EXPECT_EQ(std::string("Unknown field `port`."), err); delete root; diff --git a/software/io/c64/joystick_output.cc b/software/io/c64/joystick_output.cc index 6b5e6972e..dcd740ec7 100644 --- a/software/io/c64/joystick_output.cc +++ b/software/io/c64/joystick_output.cc @@ -80,6 +80,15 @@ void JoystickOutput :: apply(void) outputSnapshot(port1, port2, pot1x, pot1y, pot2x, pot2y); C64_JOY1_SWOUT = port1 | 0xE0; C64_JOY2_SWOUT = port2 | 0xE0; + // U64 exposes the C64-visible POT pair through the first paddle output registers. + // Keep the port 2 registers updated, but mirror asserted lows so Anykey-style + // button reads observe REST fire2/fire3 on either joystick port. + if (pot2x == JOYSTICK_POT_PRESSED) { + pot1x = JOYSTICK_POT_PRESSED; + } + if (pot2y == JOYSTICK_POT_PRESSED) { + pot1y = JOYSTICK_POT_PRESSED; + } C64_PADDLE_1_X = pot1x; C64_PADDLE_1_Y = pot1y; C64_PADDLE_2_X = pot2x; @@ -87,8 +96,10 @@ void JoystickOutput :: apply(void) if (usb_hid_get_active_mouse_interfaces) { mouse_port1_enabled = usb_hid_get_active_mouse_interfaces() > 0; } - C64_MOUSE_EN_1 = (mouse_port1_enabled || joystick_has_extra_button_press(usb_p1 & rest_p1_persistent & rest_p1_overlay)) ? 1 : 0; - C64_MOUSE_EN_2 = joystick_has_extra_button_press(rest_p2_persistent & rest_p2_overlay) ? 1 : 0; + bool rest_port1_extra = joystick_has_extra_button_press(usb_p1 & rest_p1_persistent & rest_p1_overlay); + bool rest_port2_extra = joystick_has_extra_button_press(rest_p2_persistent & rest_p2_overlay); + C64_MOUSE_EN_1 = (mouse_port1_enabled || rest_port1_extra || rest_port2_extra) ? 1 : 0; + C64_MOUSE_EN_2 = rest_port2_extra ? 1 : 0; #endif } diff --git a/software/io/c64/keyboard_c64.cc b/software/io/c64/keyboard_c64.cc index 99f8e3158..a9e614a20 100644 --- a/software/io/c64/keyboard_c64.cc +++ b/software/io/c64/keyboard_c64.cc @@ -1,10 +1,10 @@ -#include -#include "itu.h" -#include "keyboard_c64.h" -#include "keyboard_usb.h" -#if U64 && !RECOVERYAPP -#include "joystick_output.h" -#endif +#include +#include "itu.h" +#include "keyboard_c64.h" +#include "keyboard_usb.h" +#if U64 && !RECOVERYAPP +#include "joystick_output.h" +#endif #ifndef NO_FILE_ACCESS #include "FreeRTOS.h" @@ -22,8 +22,8 @@ const uint8_t modifier_map[] = { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00, // right shift 0x00,0x00,0x04,0x00,0x00,0x02,0x00,0x00, // control, C= - 0x00 }; - + 0x00 }; + const uint8_t keymap_normal[] = { KEY_BACK, KEY_RETURN, KEY_RIGHT, KEY_F7, KEY_F1, KEY_F3, KEY_F5, KEY_DOWN, '3', 'w', 'a', '4', 'z', 's', 'e', 0x00, @@ -94,13 +94,13 @@ Keyboard_C64 :: Keyboard_C64(GenericHost *h, volatile uint8_t *row, volatile uin shift_prev = 0xFF; delay_count = first_delay; } - + Keyboard_C64 :: ~Keyboard_C64() { } -uint8_t Keyboard_C64 :: scan_keyboard(volatile uint8_t *row_reg, volatile uint8_t *col_reg) -{ +uint8_t Keyboard_C64 :: scan_keyboard(volatile uint8_t *row_reg, volatile uint8_t *col_reg) +{ const uint8_t *map; uint8_t shift_flag = 0; @@ -133,37 +133,37 @@ uint8_t Keyboard_C64 :: scan_keyboard(volatile uint8_t *row_reg, volatile uint8_ col = (col << 1) | 1; } } - + map = keymaps[shift_flag & 0x07]; - return map[mtrx]; -} - -bool Keyboard_C64 :: joystick_blocks_keyboard(uint8_t observed_active_low, uint8_t injected_active_low) -{ - observed_active_low &= 0x1F; - injected_active_low &= 0x1F; - return (observed_active_low != 0x1F) && (observed_active_low != injected_active_low); -} - -#include "u64.h" // temporary! -void Keyboard_C64 :: scan(void) -{ - const uint8_t *map; - - uint8_t shift_flag = 0; - uint8_t mtrx = 0x40; - uint8_t col, row, key = 0xFF; - uint8_t mod = 0; - bool joy = false; - bool software_joy_only = false; - bool keyboard_pressed = false; - uint8_t joy_shift_flag = 0; - uint8_t joy_mtrx = 0x40; - uint8_t injected_joy2 = 0x1F; - uint8_t injected_joy1 = 0x1F; - - if(!host) { - return; + return map[mtrx]; +} + +bool Keyboard_C64 :: joystick_blocks_keyboard(uint8_t observed_active_low, uint8_t injected_active_low) +{ + observed_active_low &= 0x1F; + injected_active_low &= 0x1F; + return (observed_active_low != 0x1F) && (observed_active_low != injected_active_low); +} + +#include "u64.h" // temporary! +void Keyboard_C64 :: scan(void) +{ + const uint8_t *map; + + uint8_t shift_flag = 0; + uint8_t mtrx = 0x40; + uint8_t col, row, key = 0xFF; + uint8_t mod = 0; + bool joy = false; + bool software_joy_only = false; + bool keyboard_pressed = false; + uint8_t joy_shift_flag = 0; + uint8_t joy_mtrx = 0x40; + uint8_t injected_joy2 = 0x1F; + uint8_t injected_joy1 = 0x1F; + + if(!host) { + return; } // check if we have access to the I/O if(!(host->is_accessible())) @@ -177,39 +177,39 @@ void Keyboard_C64 :: scan(void) *row_register = 0xFF; *row_register = 0xFF; - // Scan Joystick Port 2 first - *col_register = 0xFF; // deselect keyboard for pure joystick scan - *col_register = 0XFF; // delay - - row = *joy_register; - row = *joy_register; -#if U64 && !RECOVERYAPP - JoystickOutput::instance().snapshot(injected_joy1, injected_joy2); -#endif - if((row & 0x1F) != 0x1F) { - if (!(row & 0x01)) { joy_shift_flag = 0x01; joy_mtrx = 0x07; } - else if(!(row & 0x02)) { joy_shift_flag = 0x00; joy_mtrx = 0x07; } - else if(!(row & 0x04)) { joy_shift_flag = 0x01; joy_mtrx = 0x02; } - else if(!(row & 0x08)) { joy_shift_flag = 0x00; joy_mtrx = 0x02; } - else if(!(row & 0x10)) { joy_shift_flag = 0x00; joy_mtrx = 0x01; } - - software_joy_only = !joystick_blocks_keyboard(row, injected_joy2); - if (!software_joy_only) { - joy = true; - shift_flag = joy_shift_flag; - mtrx = joy_mtrx; - } - } - - // Physical joystick activity shares matrix lines with the keyboard and must keep - // the old precedence. REST-owned port 2 activity should not starve the local keyboard. - if(!joy) { - *col_register = 0; // select all rows of keyboard - if (*row_register != 0xFF) { // process key image - keyboard_pressed = true; - map = keymap_normal; - col = 0xFE; - for(int idx=0,y=0;y<8;y++) { + // Scan Joystick Port 2 first + *col_register = 0xFF; // deselect keyboard for pure joystick scan + *col_register = 0XFF; // delay + + row = *joy_register; + row = *joy_register; +#if U64 && !RECOVERYAPP + JoystickOutput::instance().snapshot(injected_joy1, injected_joy2); +#endif + if((row & 0x1F) != 0x1F) { + if (!(row & 0x01)) { joy_shift_flag = 0x01; joy_mtrx = 0x07; } + else if(!(row & 0x02)) { joy_shift_flag = 0x00; joy_mtrx = 0x07; } + else if(!(row & 0x04)) { joy_shift_flag = 0x01; joy_mtrx = 0x02; } + else if(!(row & 0x08)) { joy_shift_flag = 0x00; joy_mtrx = 0x02; } + else if(!(row & 0x10)) { joy_shift_flag = 0x00; joy_mtrx = 0x01; } + + software_joy_only = !joystick_blocks_keyboard(row, injected_joy2); + if (!software_joy_only) { + joy = true; + shift_flag = joy_shift_flag; + mtrx = joy_mtrx; + } + } + + // Physical joystick activity shares matrix lines with the keyboard and must keep + // the old precedence. REST-owned port 2 activity should not starve the local keyboard. + if(!joy) { + *col_register = 0; // select all rows of keyboard + if (*row_register != 0xFF) { // process key image + keyboard_pressed = true; + map = keymap_normal; + col = 0xFE; + for(int idx=0,y=0;y<8;y++) { *col_register = 0xFF; *col_register = col; *col_register = col; @@ -226,28 +226,28 @@ void Keyboard_C64 :: scan(void) } } row >>= 1; - } - col = (col << 1) | 1; - } - } else if (!software_joy_only) { // no key pressed - mtrx_prev = 0xFF; - shift_prev = 0xFF; -#if U64 == 2 - MATRIX_WASD_TO_JOY = wasd_to_joy; - BLING_RX_FLAGS = 0x00; // reenable shift lock -#endif - return; - } - } - if (software_joy_only && !keyboard_pressed) { - shift_flag = joy_shift_flag; - mtrx = joy_mtrx; - } - -#if U64 == 2 - MATRIX_WASD_TO_JOY = wasd_to_joy; - BLING_RX_FLAGS = 0x00; // reenable shift lock -#endif + } + col = (col << 1) | 1; + } + } else if (!software_joy_only) { // no key pressed + mtrx_prev = 0xFF; + shift_prev = 0xFF; +#if U64 == 2 + MATRIX_WASD_TO_JOY = wasd_to_joy; + BLING_RX_FLAGS = 0x00; // reenable shift lock +#endif + return; + } + } + if (software_joy_only && !keyboard_pressed) { + shift_flag = joy_shift_flag; + mtrx = joy_mtrx; + } + +#if U64 == 2 + MATRIX_WASD_TO_JOY = wasd_to_joy; + BLING_RX_FLAGS = 0x00; // reenable shift lock +#endif // there was a key pressed (or the joystick is used) // determine which map to use map = keymaps[shift_flag & 0x07]; @@ -258,7 +258,7 @@ void Keyboard_C64 :: scan(void) shift_prev = 0xFF; return; } - + if((shift_flag == shift_prev) && (mtrx_prev == mtrx)) { // this key was pressed before if (delay_count == 0) { delay_count = repeat_speed; @@ -274,7 +274,7 @@ void Keyboard_C64 :: scan(void) } else { // first time this key was pressed delay_count = first_delay; mtrx_prev = mtrx; - shift_prev = shift_flag; + shift_prev = shift_flag; } // printf("%b ", key); diff --git a/software/io/c64/keyboard_c64.h b/software/io/c64/keyboard_c64.h index 650285182..3bce5928b 100644 --- a/software/io/c64/keyboard_c64.h +++ b/software/io/c64/keyboard_c64.h @@ -14,28 +14,28 @@ class Keyboard_C64 : public Keyboard volatile uint8_t *row_register; volatile uint8_t *col_register; volatile uint8_t *joy_register; - + uint8_t shift_prev; uint8_t mtrx_prev; - + int repeat_speed; int first_delay; - + int delay_count; int key_buffer[KEY_BUFFER_SIZE]; int key_head; int key_tail; -public: - Keyboard_C64(GenericHost *, volatile uint8_t *r, volatile uint8_t *c, volatile uint8_t *j); - ~Keyboard_C64(); - - static uint8_t scan_keyboard(volatile uint8_t *r, volatile uint8_t *c); - static bool joystick_blocks_keyboard(uint8_t observed_active_low, uint8_t injected_active_low); - - void scan(void); - void set_delays(int, int); - int getch(void); +public: + Keyboard_C64(GenericHost *, volatile uint8_t *r, volatile uint8_t *c, volatile uint8_t *j); + ~Keyboard_C64(); + + static uint8_t scan_keyboard(volatile uint8_t *r, volatile uint8_t *c); + static bool joystick_blocks_keyboard(uint8_t observed_active_low, uint8_t injected_active_low); + + void scan(void); + void set_delays(int, int); + int getch(void); void push_head(int); void wait_free(void); void clear_buffer(void); diff --git a/software/io/usb/keyboard_usb.cc b/software/io/usb/keyboard_usb.cc index a62de7b47..51de12358 100644 --- a/software/io/usb/keyboard_usb.cc +++ b/software/io/usb/keyboard_usb.cc @@ -13,6 +13,9 @@ #if U64 && !RECOVERYAPP #include "timers.h" #endif +#if U64 == 2 +#include "u64.h" +#endif #ifndef portENTER_CRITICAL #define portENTER_CRITICAL() @@ -36,12 +39,16 @@ uint8_t usb_matrix_lookup(const uint8_t *map, size_t map_size, uint8_t key) } #if U64 && !RECOVERYAPP -static const uint32_t REST_INPUT_TIMER_TICKS = (pdMS_TO_TICKS(10) > 0) ? pdMS_TO_TICKS(10) : 1; +static const uint32_t REST_INPUT_TIMER_TICKS = (pdMS_TO_TICKS(20) > 0) ? pdMS_TO_TICKS(20) : 1; #endif static const uint8_t REST_TAP_GAP_TICKS = 2; } +#if U64 == 2 +extern uint8_t wasd_to_joy __attribute__((weak)); +#endif + const uint8_t keymap_normal[] = { 0x00, KEY_ERR, KEY_ERR, KEY_ERR, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', @@ -185,6 +192,7 @@ void Keyboard_USB :: putch(uint8_t ch) void Keyboard_USB :: applyMatrixState(void) { + applyRestWasdGuard(); if (!matrix) { return; } @@ -200,6 +208,26 @@ uint8_t Keyboard_USB :: effectiveRestoreBit(void) const return usb_restore | (rest_restore ? 1 : 0) | (rest_restore_overlay ? 1 : 0); } +bool Keyboard_USB :: restMatrixActive(void) const +{ + if (rest_restore || rest_restore_overlay) { + return true; + } + for (int i = 0; i < 8; i++) { + if (rest_matrix_state[i] || rest_matrix_overlay[i]) { + return true; + } + } + return false; +} + +void Keyboard_USB :: applyRestWasdGuard(void) const +{ +#if U64 == 2 + MATRIX_WASD_TO_JOY = restMatrixActive() ? 0 : (&wasd_to_joy ? wasd_to_joy : 0); +#endif +} + void Keyboard_USB :: clearInjectedMatrixState(void) { if (injected_matrix_hold == 0) { diff --git a/software/io/usb/keyboard_usb.h b/software/io/usb/keyboard_usb.h index 281825224..747f89175 100644 --- a/software/io/usb/keyboard_usb.h +++ b/software/io/usb/keyboard_usb.h @@ -64,10 +64,12 @@ class Keyboard_USB : public Keyboard int first_delay; int delay_count; int injected_matrix_hold; - void applyMatrixState(void); + void applyMatrixState(void); void clearInjectedMatrixState(void); void setInjectedMatrixKey(int key); uint8_t effectiveRestoreBit(void) const; + bool restMatrixActive(void) const; + void applyRestWasdGuard(void) const; bool restTapQueueEmpty(void) const; bool restTapOverlayActive(void) const; void startRestTap(const RestTapEntry& entry); diff --git a/target/u2/riscv/ultimate/Makefile b/target/u2/riscv/ultimate/Makefile index b5bab7441..ca0004c14 100755 --- a/target/u2/riscv/ultimate/Makefile +++ b/target/u2/riscv/ultimate/Makefile @@ -152,6 +152,7 @@ SRCS_CC = small_printf.cc \ route_runners.cc \ route_configs.cc \ route_machine.cc \ + route_input.cc \ assembly_search.cc \ assembly.cc \ filesystem_a64.cc \ diff --git a/target/u2plus/nios/ultimate/Makefile b/target/u2plus/nios/ultimate/Makefile index a2e66caa2..7a2c37f80 100755 --- a/target/u2plus/nios/ultimate/Makefile +++ b/target/u2plus/nios/ultimate/Makefile @@ -155,6 +155,7 @@ SRCS_CC = u2p_init.cc \ route_runners.cc \ route_configs.cc \ route_machine.cc \ + route_input.cc \ assembly_search.cc \ assembly.cc \ filesystem_a64.cc \ diff --git a/target/u2plus_L/riscv/ultimate/Makefile b/target/u2plus_L/riscv/ultimate/Makefile index a35e7eae8..60a08e2e0 100755 --- a/target/u2plus_L/riscv/ultimate/Makefile +++ b/target/u2plus_L/riscv/ultimate/Makefile @@ -158,6 +158,7 @@ SRCS_CC = u2pl_init.cc \ route_runners.cc \ route_configs.cc \ route_machine.cc \ + route_input.cc \ assembly_search.cc \ assembly.cc \ filesystem_a64.cc \ diff --git a/tools/api/input_test.py b/tools/api/input_test.py index 7bd861b7a..07277c333 100755 --- a/tools/api/input_test.py +++ b/tools/api/input_test.py @@ -515,11 +515,11 @@ def append_screen_tail(screen_tail: str, text: str, limit: int = 200) -> str: return (screen_tail + text.upper())[-limit:] -def soak_keyboard_basic_case(session: RestInputSession, screen_tail: str, text: str, pace_ms: int) -> str: +def soak_keyboard_basic_case(session: RestInputSession, screen_tail: str, text: str) -> str: session.json_request( "POST", "/v1/machine:input", - payload={"events": keyboard_tap_events_for_text(text), "pace_ms": pace_ms}, + payload={"events": keyboard_tap_events_for_text(text)}, ) screen_tail = append_screen_tail(screen_tail, text) wait_for_screen_sequence(session, text_to_screen_codes(screen_tail), timeout=max(4.0, len(text) * 0.8)) @@ -600,7 +600,7 @@ def soak_interleaved_case( session.json_request( "POST", "/v1/machine:input", - payload={"events": keyboard_tap_events_for_text(text), "pace_ms": 140}, + payload={"events": keyboard_tap_events_for_text(text)}, ) screen_tail = append_screen_tail(screen_tail, text) wait_for_screen_sequence(session, text_to_screen_codes(screen_tail), timeout=max(4.0, len(text) * 0.8)) @@ -693,7 +693,7 @@ def soak_rapid_mixed_case(session: RestInputSession, screen_tail: str, text_chun session.json_request( "POST", "/v1/machine:input", - payload={"events": keyboard_tap_events_for_text(chunk), "pace_ms": 140}, + payload={"events": keyboard_tap_events_for_text(chunk)}, ) expected = "".join(text_chunks) screen_tail = append_screen_tail(screen_tail, expected) @@ -716,11 +716,11 @@ def soak_rapid_mixed_case(session: RestInputSession, screen_tail: str, text_chun def run_soak_tests(session: RestInputSession, duration_seconds: float) -> int: keyboard_text_cases = [ - ("aaaaaa", 140), - ("Abab09", 120), - ("C64Z", 150), - ("qwertY", 110), - ("az09ZA", 130), + "aaaaaa", + "Abab09", + "C64Z", + "qwertY", + "az09ZA", ] keyboard_hold_cases = [ (["left_shift"], ["left_shift", "a"]), @@ -759,8 +759,8 @@ def run_soak_tests(session: RestInputSession, duration_seconds: float) -> int: interleaved_case = interleaved_cases[cycles % len(interleaved_cases)] rapid_mix_case = rapid_mix_cases[cycles % len(rapid_mix_cases)] - print(f"[soak {cycles + 1:03d}] text={text_case[0]} joy{joystick_case[0]}={'+'.join(joystick_case[1])}", flush=True) - screen_tail = soak_keyboard_basic_case(session, screen_tail, text_case[0], text_case[1]) + print(f"[soak {cycles + 1:03d}] text={text_case} joy{joystick_case[0]}={'+'.join(joystick_case[1])}", flush=True) + screen_tail = soak_keyboard_basic_case(session, screen_tail, text_case) screen_tail = soak_interleaved_case(session, screen_tail, interleaved_case[0], interleaved_case[1], interleaved_case[2]) screen_tail = soak_rapid_mixed_case(session, screen_tail, rapid_mix_case[0], rapid_mix_case[1]) soak_keyboard_hold_case(session, hold_case[0], hold_case[1]) @@ -847,7 +847,7 @@ def run_keyboard_tests(session: RestInputSession) -> None: session.post_events([{"kind": "release_all"}]) assert_state_empty(session) - with check("paced keyboard batch applies multiple presses atomically"): + with check("keyboard batch applies multiple presses atomically"): reset_to_basic(session) body = session.json_request( "POST", @@ -856,12 +856,11 @@ def run_keyboard_tests(session: RestInputSession) -> None: "events": [ {"kind": "keyboard", "inputs": ["a"], "transition": "press"}, {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, - ], - "pace_ms": 25, + ] }, ) if body.get("keyboard", {}).get("inputs") != ["a", "left_shift"]: - raise Failure(f"Unexpected paced batch keyboard state: {body}") + raise Failure(f"Unexpected batch keyboard state: {body}") assert_keyboard_matrix_inputs(session, ["a", "left_shift"]) session.post_events([{"kind": "release_all"}]) assert_state_empty(session) @@ -943,12 +942,12 @@ def run_keyboard_tests(session: RestInputSession) -> None: assert_state_empty(session) session.post_events([{"kind": "release_all"}]) - with check("keyboard paced single taps are consumed by BASIC in order"): + with check("keyboard single-tap batch is consumed by BASIC in order"): reset_to_basic_for_keyboard_input(session) session.json_request( "POST", "/v1/machine:input", - payload={"events": keyboard_tap_events_for_text("aaaaaa"), "pace_ms": 140}, + payload={"events": keyboard_tap_events_for_text("aaaaaa")}, ) wait_for_basic_input_prefix(session, "AAAAAA", timeout=4.0) time.sleep(0.3) @@ -966,7 +965,7 @@ def run_keyboard_tests(session: RestInputSession) -> None: with check("keyboard tap batch drains through the live matrix path"): reset_to_basic(session) - response = session.json_request("POST", "/v1/machine:input", payload={"events": keyboard_tap_events_for_text("ABCDEFGHIJ"), "pace_ms": 0}) + response = session.json_request("POST", "/v1/machine:input", payload={"events": keyboard_tap_events_for_text("ABCDEFGHIJ")}) if not response.get("keyboard", {}).get("inputs"): raise Failure(f"Expected a live tap snapshot while the batch was draining, got {response}") time.sleep(1.2) @@ -976,7 +975,7 @@ def run_keyboard_tests(session: RestInputSession) -> None: with check("keyboard long repeated tap train drains fully without sticky state"): reset_to_basic(session) repeated = [{"kind": "keyboard", "inputs": ["a"], "transition": "tap"} for _ in range(60)] - response = session.json_request("POST", "/v1/machine:input", payload={"events": repeated, "pace_ms": 0}) + response = session.json_request("POST", "/v1/machine:input", payload={"events": repeated}) if response.get("keyboard", {}).get("inputs") != ["a"]: raise Failure(f"Expected repeated tap train to expose the live a snapshot, got {response}") time.sleep(6.0) diff --git a/tools/api/input_tool.py b/tools/api/input_tool.py index 1835187e2..d7405cd9e 100755 --- a/tools/api/input_tool.py +++ b/tools/api/input_tool.py @@ -101,11 +101,9 @@ ESCAPE_SEQUENCE_TIMEOUT = float(os.environ.get("U64_INPUT_ESCAPE_TIMEOUT", "0.10")) MAX_BATCH_EVENTS = 64 KEYBOARD_TAP_BATCH_EVENTS = max(1, int(os.environ.get("U64_INPUT_KEYBOARD_TAP_BATCH_EVENTS", "10"))) -BATCH_PACE_MS = int(os.environ.get("U64_INPUT_PACE_MS", "10")) REPEAT_BATCH_EVENTS = max(1, int(os.environ.get("U64_INPUT_REPEAT_BATCH_EVENTS", "6"))) REPEAT_BATCH_TARGET_HZ = float(os.environ.get("U64_INPUT_REPEAT_HZ", "50.0")) REPEAT_BATCH_INTERVAL = (float(REPEAT_BATCH_EVENTS) / REPEAT_BATCH_TARGET_HZ) if REPEAT_BATCH_TARGET_HZ > 0 else 0.06 -REPEATED_KEYBOARD_TAP_PACE_MS = max(0, int(os.environ.get("U64_INPUT_REPEAT_PACE_MS", "80"))) KEY_REPEAT_STALE_SECONDS = float(os.environ.get("U64_INPUT_REPEAT_STALE", "0.25")) KEY_REPEAT_TRIGGER_SECONDS = float(os.environ.get("U64_INPUT_REPEAT_TRIGGER", "0.50")) KEY_REPEAT_CONFIRM_COUNT = max(2, int(os.environ.get("U64_INPUT_REPEAT_CONFIRM", "4"))) @@ -550,10 +548,8 @@ def _format_request(self, method: str, path: str, payload: Optional[Any], conten if path == "/v1/machine:input" and isinstance(payload, dict): events = payload.get("events", []) if isinstance(events, list): - pace_ms = payload.get("pace_ms") - suffix = f" pace={pace_ms}" if pace_ms is not None else "" max_items = len(events) if self.level >= 2 else 5 - return f"{method} {path} events={len(events)}{suffix} [{summarize_events(events, max_items=max_items)}]" + return f"{method} {path} events={len(events)} [{summarize_events(events, max_items=max_items)}]" if isinstance(payload, dict): keys = ",".join(sorted(payload.keys())) return f"{method} {path} json={keys or '-'}" @@ -814,13 +810,8 @@ def _connect(self) -> http.client.HTTPConnection: self._connection = http.client.HTTPConnection(self.host, timeout=self.timeout) return self._connection - def post_events(self, events: List[Dict[str, object]], pace_ms: Optional[int] = None) -> Dict[str, object]: + def post_events(self, events: List[Dict[str, object]]) -> Dict[str, object]: payload: Dict[str, object] = {"events": events} - effective_pace_ms = pace_ms - if effective_pace_ms is None and len(events) > 1 and BATCH_PACE_MS > 0: - effective_pace_ms = BATCH_PACE_MS - if effective_pace_ms is not None: - payload["pace_ms"] = effective_pace_ms body = json.dumps(payload).encode("utf-8") headers: Dict[str, str] = {"Content-Type": "application/json"} if self.password: @@ -1075,27 +1066,12 @@ def drain_stdin_sequences(stdin_fd: int) -> List[str]: return sequences -def keyboard_batch_pace_ms(events: List[Dict[str, object]]) -> int: - if not events: - return 0 - if not all(event.get("kind") == "keyboard" and event.get("transition") == "tap" for event in events): - return 0 - if len(events) <= 1: - return 0 - first_signature = event_signature(events[0]) - for event in events[1:]: - if event_signature(event) != first_signature: - return 0 - return REPEATED_KEYBOARD_TAP_PACE_MS - - def flush_event_batch(client: InteractiveRestClient, events: List[Dict[str, object]]) -> None: while events: keyboard_taps_only = all(event.get("kind") == "keyboard" and event.get("transition") == "tap" for event in events) chunk_size = KEYBOARD_TAP_BATCH_EVENTS if keyboard_taps_only else MAX_BATCH_EVENTS chunk = events[:chunk_size] - pace_ms = keyboard_batch_pace_ms(chunk) if keyboard_taps_only else None - client.post_events(chunk, pace_ms=pace_ms) + client.post_events(chunk) del events[:chunk_size] @@ -1482,15 +1458,8 @@ def print_mapping_overview( if keyboard: print("keyboard repeats: Linux low-level key-repeat events") else: - if REPEATED_KEYBOARD_TAP_PACE_MS > 0: - print( - f"interactive hold queue: repeated same-key taps are paced at {REPEATED_KEYBOARD_TAP_PACE_MS} ms/event " - f"in batches up to {REPEAT_BATCH_EVENTS} events" - ) - else: - print(f"interactive hold queue: repeated same-key taps are sent in batches up to {REPEAT_BATCH_EVENTS} events") - print(f"rapid mixed-key batches: up to {KEYBOARD_TAP_BATCH_EVENTS} taps/request at pace_ms=0") - print(f"paced multi-key batches: {BATCH_PACE_MS} ms between batched events") + print(f"interactive hold queue: repeated same-key taps are sent in batches up to {REPEAT_BATCH_EVENTS} events") + print(f"rapid keyboard batches: up to {KEYBOARD_TAP_BATCH_EVENTS} taps/request") print(f"joystick port: {joystick_port}") if verbosity >= 2: verbose_text = "full" From 382639dc8ce10da38b0d4892395d6f9b10418f6c Mon Sep 17 00:00:00 2001 From: Christian Gleissner Date: Wed, 20 May 2026 00:24:16 +0100 Subject: [PATCH 7/7] Add critical section handling for mutex and snapshot functions in joystick and keyboard APIs --- software/api/route_input.cc | 6 +++++- software/io/c64/joystick_output.cc | 6 ++++++ software/io/usb/keyboard_usb.cc | 4 ++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/software/api/route_input.cc b/software/api/route_input.cc index 219d9f4b9..ae5747139 100644 --- a/software/api/route_input.cc +++ b/software/api/route_input.cc @@ -142,7 +142,11 @@ static SemaphoreHandle_t rest_input_mutex = NULL; static SemaphoreHandle_t input_mutex(void) { if (!rest_input_mutex) { - rest_input_mutex = xSemaphoreCreateMutex(); + taskENTER_CRITICAL(); + if (!rest_input_mutex) { + rest_input_mutex = xSemaphoreCreateMutex(); + } + taskEXIT_CRITICAL(); } return rest_input_mutex; } diff --git a/software/io/c64/joystick_output.cc b/software/io/c64/joystick_output.cc index dcd740ec7..dd6709887 100644 --- a/software/io/c64/joystick_output.cc +++ b/software/io/c64/joystick_output.cc @@ -235,8 +235,14 @@ void JoystickOutput :: releaseAllRest(void) void JoystickOutput :: snapshot(uint8_t &port1_active_low, uint8_t &port2_active_low) const { +#if U64 + portENTER_CRITICAL(); +#endif port1_active_low = (rest_p1_persistent & rest_p1_overlay) & JOYSTICK_INPUT_MASK; port2_active_low = (rest_p2_persistent & rest_p2_overlay) & JOYSTICK_INPUT_MASK; +#if U64 + portEXIT_CRITICAL(); +#endif } void JoystickOutput :: outputSnapshot(uint8_t &port1_active_low, uint8_t &port2_active_low, diff --git a/software/io/usb/keyboard_usb.cc b/software/io/usb/keyboard_usb.cc index 51de12358..34c9b6db5 100644 --- a/software/io/usb/keyboard_usb.cc +++ b/software/io/usb/keyboard_usb.cc @@ -745,18 +745,22 @@ void Keyboard_USB :: restReleaseAll(void) void Keyboard_USB :: restSnapshot(uint8_t out_matrix[8], bool &out_restore) const { + portENTER_CRITICAL(); for (int i = 0; i < 8; i++) { out_matrix[i] = rest_matrix_state[i] | rest_matrix_overlay[i]; } out_restore = rest_restore || (rest_restore_overlay != 0); + portEXIT_CRITICAL(); } void Keyboard_USB :: restPersistentSnapshot(uint8_t out_matrix[8], bool &out_restore) const { + portENTER_CRITICAL(); for (int i = 0; i < 8; i++) { out_matrix[i] = rest_matrix_state[i] & ~rest_matrix_overlay[i]; } out_restore = rest_restore; + portEXIT_CRITICAL(); } void Keyboard_USB :: tickRestOverlays(void)