diff --git a/.gitignore b/.gitignore index 58f259f30..12edbf8c7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,12 @@ 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/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 @@ -287,4 +293,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 new file mode 100644 index 000000000..45aa3af65 --- /dev/null +++ b/software/api/input_api.h @@ -0,0 +1,482 @@ +#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 = 7; +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 }, + { "fire2", 5 }, + { "fire3", 6 }, +}; + +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..7 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 &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" }; + if (!input_api_reject_unknown_keys(obj, allowed, sizeof(allowed) / sizeof(allowed[0]), err, err_size)) { + 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/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..ae5747139 --- /dev/null +++ b/software/api/route_input.cc @@ -0,0 +1,416 @@ +#include "routes.h" +#include "attachment_writer.h" +#include "input_api.h" +#include "itu.h" +#include "keyboard_usb.h" +#include "joystick_output.h" + +#include "FreeRTOS.h" +#include "semphr.h" + +#include +#include +#include + +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; +static SemaphoreHandle_t rest_input_mutex = NULL; + +static SemaphoreHandle_t input_mutex(void) +{ + if (!rest_input_mutex) { + taskENTER_CRITICAL(); + if (!rest_input_mutex) { + rest_input_mutex = xSemaphoreCreateMutex(); + } + taskEXIT_CRITICAL(); + } + return rest_input_mutex; +} + +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, 0, 0 }; + + JoystickOutput::instance().restPersistentSnapshot(p1, p2); + active_low = (event.port == 1) ? p1 : p2; + + switch (event.transition) { + case INPUT_PARSED_PRESS: + active_low &= ~event.joystick_mask; + if (event.port == 1) { + JoystickOutput::instance().setRestPort1Persistent(active_low); + } else { + JoystickOutput::instance().setRestPort2Persistent(active_low); + } + break; + case INPUT_PARSED_RELEASE: + active_low |= event.joystick_mask; + if (event.port == 1) { + JoystickOutput::instance().setRestPort1Persistent(active_low); + } else { + JoystickOutput::instance().setRestPort2Persistent(active_low); + } + break; + 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; + } + } + active_low = ((1 << INPUT_API_MAX_JOYSTICK_INPUTS) - 1) & ~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 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]]; + if (entry.restore) { + system_usb_keyboard.restTapRestore(REST_TAP_HOLD_TICKS); + continue; + } + switch (event.transition) { + case INPUT_PARSED_PRESS: + system_usb_keyboard.restPress(entry.row, entry.col); + break; + case INPUT_PARSED_RELEASE: + system_usb_keyboard.restRelease(entry.row, entry.col); + break; + case INPUT_PARSED_TAP: + break; + } + } +} + +static void apply_batch(const InputParsedEvent *events, int event_count) +{ + for (int i = 0; i < event_count; i++) { + switch (events[i].kind) { + case INPUT_PARSED_KEYBOARD: + apply_keyboard_event(events[i]); + break; + case INPUT_PARSED_JOYSTICK: + apply_joystick_event(events[i]); + break; + case INPUT_PARSED_RELEASE_ALL: + system_usb_keyboard.restReleaseAll(); + JoystickOutput::instance().releaseAllRest(); + break; + } + } +} + +static void emit_joystick_inputs(JSON_Object *obj, uint8_t active_low) +{ + JSON_List *inputs = JSON::List(); + 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); + } + } + 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); + + const InputKeyboardMapEntry *keyboard_map = input_api_keyboard_map(); + JSON_List *keyboard_inputs = JSON::List(); + 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); + } + } 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, &input_json_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; + } + InputJsonWriter *handler = (InputJsonWriter *)body; + if (!handler) { + resp->error("Request body is required."); + resp->json_response(HTTP_BAD_REQUEST); + return; + } + 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("Request body is required."); + } + resp->json_response(HTTP_BAD_REQUEST); + return; + } + JSON *obj = NULL; + 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; + } + + 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); + resp->json_response(HTTP_BAD_REQUEST); + return; + } + + static InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 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, error_index, err, sizeof(err))) { + xSemaphoreGive(mutex); + delete obj; + if (error_index >= 0) { + resp->error("events[%d]: %s", error_index, err); + } else { + resp->error("%s", err); + } + resp->json_response(HTTP_BAD_REQUEST); + return; + } + apply_batch(events, event_count); + emit_state_snapshot(resp); + xSemaphoreGive(mutex); + resp->json_response(HTTP_OK); + delete obj; +#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/api/tests/Makefile b/software/api/tests/Makefile new file mode 100644 index 000000000..4803a411e --- /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 -I../../io/usb -I../../infra + +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/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 new file mode 100644 index 000000000..df54ab4fc --- /dev/null +++ b/software/api/tests/input_api_state_test.cpp @@ -0,0 +1,255 @@ +#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) +{ + 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 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); + 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, 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; + 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(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(); + uint8_t hold[7] = { 0, 0, 0, 0, 1, 1, 1 }; + uint8_t port1 = 0; + uint8_t port2 = 0; + + JoystickOutput::instance().setRestPort2Persistent(0x5E); + JoystickOutput::instance().armRestPort2Overlay(0x0F, hold); + JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x7F, port1); + EXPECT_EQ(0x0E, port2); + + JoystickOutput::instance().tickOverlays(); + JoystickOutput::instance().snapshot(port1, port2); + EXPECT_EQ(0x5E, port2); +} + +TEST(RestJoystickStateTest, ReleaseAllClearsBothPorts) +{ + reset_joystick_output(); + uint8_t hold[7] = { 1, 0, 0, 0, 0, 1, 1 }; + uint8_t port1 = 0; + uint8_t port2 = 0; + + 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(0x80, pot2y); + + JoystickOutput::instance().setRestPort2Persistent(0x3F); + JoystickOutput::instance().outputSnapshot(port1, port2, pot1x, pot1y, pot2x, pot2y); + EXPECT_EQ(0x80, 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 new file mode 100644 index 000000000..b04bc0576 --- /dev/null +++ b/software/api/tests/input_api_validation_test.cpp @@ -0,0 +1,261 @@ +#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) +{ + return JSON::Obj()->add("events", events); +} + +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, 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 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", "fire2", "fire3" })) + ->add(make_release_all_event())); + + ASSERT_TRUE(validate(root, events, event_count, error_index, err)); + EXPECT_EQ(3, event_count); + 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) | (1 << 5) | (1 << 6), 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 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, error_index, err)); + EXPECT_EQ(-1, error_index); + EXPECT_EQ(std::string("Unknown field `extra`."), err); + + delete root; +} + +TEST(InputApiValidationTest, RejectsLateInvalidEventAndReportsIndex) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 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, 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 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, error_index, err)); + delete root; + } +} + +TEST(InputApiValidationTest, RejectsRestoreOutsideTapAloneRule) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 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, 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 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, 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 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, error_index, err)); + delete root; + } +} + +TEST(InputApiValidationTest, RejectsJoystickDuplicateAndUnknownInputs) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 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, 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, error_index, err)); + EXPECT_EQ(std::string("`jump` is not a valid joystick input."), err); + delete unknown; +} + +TEST(InputApiValidationTest, AcceptsAllSevenJoystickInputsInOneEvent) +{ + InputParsedEvent events[INPUT_API_MAX_EVENTS]; + int event_count = 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, 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]; + int event_count = 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, 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 new file mode 100644 index 000000000..dd6709887 --- /dev/null +++ b/software/io/c64/joystick_output.cc @@ -0,0 +1,259 @@ +#include "joystick_output.h" +#include + +#if U64 +#include "FreeRTOS.h" +#if !RECOVERYAPP +#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() +#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; +#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 = 0x80; +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; +} + +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) +{ + (void)timer; + JoystickOutput::instance().tickOverlays(); +} +#endif + +JoystickOutput :: JoystickOutput() +{ + 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 + 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; +} + +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; + // 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; + C64_PADDLE_2_Y = pot2y; + if (usb_hid_get_active_mouse_interfaces) { + mouse_port1_enabled = usb_hid_get_active_mouse_interfaces() > 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 +} + +void JoystickOutput :: setUsbPort1(uint8_t active_low_mask) +{ +#if U64 + portENTER_CRITICAL(); +#endif + usb_p1 = (active_low_mask & JOYSTICK_DIGITAL_MASK) | (JOYSTICK_INPUT_MASK & ~JOYSTICK_DIGITAL_MASK); + 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 & JOYSTICK_INPUT_MASK; + 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 & JOYSTICK_INPUT_MASK; + 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 & JOYSTICK_INPUT_MASK; + port2_active_low = rest_p2_persistent & JOYSTICK_INPUT_MASK; +} + +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 &= JOYSTICK_INPUT_MASK; + for (int i = 0; i < 7; 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[7]) +{ +#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[7]) +{ +#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[7]) +{ + bool changed = false; + for (int i = 0; i < 7; 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 = 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(); +#if U64 + portEXIT_CRITICAL(); +#endif +} + +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, + 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 new file mode 100644 index 000000000..b19f33812 --- /dev/null +++ b/software/io/c64/joystick_output.h @@ -0,0 +1,41 @@ +#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[7]; + uint8_t rest_p2_hold[7]; + + 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[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; + +}; + +#endif /* JOYSTICK_OUTPUT_H */ diff --git a/software/io/c64/keyboard_c64.cc b/software/io/c64/keyboard_c64.cc index 501480098..a9e614a20 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" +#if U64 && !RECOVERYAPP +#include "joystick_output.h" +#endif #ifndef NO_FILE_ACCESS #include "FreeRTOS.h" @@ -20,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, @@ -92,7 +94,7 @@ Keyboard_C64 :: Keyboard_C64(GenericHost *h, volatile uint8_t *row, volatile uin shift_prev = 0xFF; delay_count = first_delay; } - + Keyboard_C64 :: ~Keyboard_C64() { } @@ -131,10 +133,18 @@ 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) { @@ -145,7 +155,13 @@ void Keyboard_C64 :: scan(void) 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; } @@ -167,19 +183,30 @@ void Keyboard_C64 :: scan(void) row = *joy_register; row = *joy_register; +#if U64 && !RECOVERYAPP + JoystickOutput::instance().snapshot(injected_joy1, injected_joy2); +#endif 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 (!(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; + } } - - // If the joystick was not used, we can safely scan the keyboard + + // 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++) { @@ -202,7 +229,7 @@ void Keyboard_C64 :: scan(void) } col = (col << 1) | 1; } - } else { // no key pressed + } else if (!software_joy_only) { // no key pressed mtrx_prev = 0xFF; shift_prev = 0xFF; #if U64 == 2 @@ -212,7 +239,11 @@ void Keyboard_C64 :: scan(void) 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 @@ -227,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; @@ -243,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 ddaf9cd9c..3bce5928b 100644 --- a/software/io/c64/keyboard_c64.h +++ b/software/io/c64/keyboard_c64.h @@ -14,13 +14,13 @@ 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]; @@ -29,8 +29,9 @@ class Keyboard_C64 : public Keyboard 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); diff --git a/software/io/usb/keyboard_usb.cc b/software/io/usb/keyboard_usb.cc index da2fd299e..34c9b6db5 100644 --- a/software/io/usb/keyboard_usb.cc +++ b/software/io/usb/keyboard_usb.cc @@ -10,6 +10,13 @@ #include "task.h" #include +#if U64 && !RECOVERYAPP +#include "timers.h" +#endif +#if U64 == 2 +#include "u64.h" +#endif + #ifndef portENTER_CRITICAL #define portENTER_CRITICAL() #define portEXIT_CRITICAL() @@ -31,8 +38,17 @@ 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 +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', @@ -116,6 +132,24 @@ 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)); + 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; + 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 +168,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) @@ -152,12 +192,40 @@ void Keyboard_USB :: putch(uint8_t ch) void Keyboard_USB :: applyMatrixState(void) { + applyRestWasdGuard(); if (!matrix) { 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); +} + +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) @@ -170,6 +238,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)); @@ -269,8 +377,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 +396,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 +613,203 @@ 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); + rest_matrix_overlay[row] &= ~(1 << col_bit); + rest_overlay_hold[row][col_bit] = 0; + 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(); + 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(); + 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_tap_head = 0; + rest_tap_tail = 0; + rest_tap_gap_ticks = 0; + 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 +{ + 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) +{ + 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) { + 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; + } + } + } + if (rest_restore_hold > 0) { + 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; + } + } + if (changed) { + applyMatrixState(); + } + portEXIT_CRITICAL(); +} + void Keyboard_USB :: setMatrix(volatile uint8_t *matrix) { if (this->matrix) { @@ -513,6 +828,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..747f89175 100644 --- a/software/io/usb/keyboard_usb.h +++ b/software/io/usb/keyboard_usb.h @@ -11,20 +11,46 @@ #include "keyboard.h" #include "integer.h" +#if U64 && !RECOVERYAPP +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]; 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]; + 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]; + 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; @@ -38,9 +64,18 @@ class Keyboard_USB : public Keyboard int first_delay; int delay_count; int injected_matrix_hold; - void applyMatrixState(void); - void clearInjectedMatrixState(void); - void setInjectedMatrixKey(int key); + 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); +#if U64 && !RECOVERYAPP + static void S_rest_timer(TimerHandle_t a); +#endif public: Keyboard_USB(); ~Keyboard_USB(); @@ -58,6 +93,18 @@ 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); + 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; + 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/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/software/io/usb/usb_hid.cc b/software/io/usb/usb_hid.cc index 964fffa37..bdcdd32f2 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]; @@ -283,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 - C64_MOUSE_EN_1 = (usb_hid_active_mouse_interfaces > 0) ? 1 : 0; - if (usb_hid_active_mouse_interfaces == 0) { - C64_JOY1_SWOUT = 0x1F; - } -#endif -} - -} - -void usb_hid_get_status_snapshot(t_usb_hid_status_snapshot& snapshot) -{ +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 +} + +} + +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)); @@ -763,10 +780,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 +810,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 { @@ -1006,11 +1023,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; @@ -1096,10 +1113,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) { @@ -1382,16 +1399,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, @@ -1402,10 +1419,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/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/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..07277c333 --- /dev/null +++ b/tools/api/input_test.py @@ -0,0 +1,1185 @@ +#!/usr/bin/env python3 +import argparse +import http.client +import json +import os +import re +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)) +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), + "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 reset_to_basic(session: RestInputSession) -> None: + session.reset() + wait_for_basic_ready(session) + 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_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:02X} joy2=${actual_port2: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 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: + 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 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) -> str: + session.json_request( + "POST", + "/v1/machine:input", + 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)) + 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)}, + ) + 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)}, + ) + 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", + "Abab09", + "C64Z", + "qwertY", + "az09ZA", + ] + 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} 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]) + 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"}]) + 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("keyboard batch applies multiple presses atomically"): + reset_to_basic(session) + body = session.json_request( + "POST", + "/v1/machine:input", + payload={ + "events": [ + {"kind": "keyboard", "inputs": ["a"], "transition": "press"}, + {"kind": "keyboard", "inputs": ["left_shift"], "transition": "press"}, + ] + }, + ) + if body.get("keyboard", {}).get("inputs") != ["a", "left_shift"]: + 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) + + 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("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 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")}, + ) + 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")}) + 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}) + 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( + [ + {"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: + 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"}]) + 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 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( + [ + {"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, 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_joystick_tests(session) + run_keyboard_tests(session) + return 0 + + +def main() -> int: + 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: + soak_cycles = run_tests(session, soak_duration_seconds=soak_duration_seconds) + 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 + + 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 + + +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..d7405cd9e --- /dev/null +++ b/tools/api/input_tool.py @@ -0,0 +1,2384 @@ +#!/usr/bin/env python3 +import argparse +import errno +import fcntl +import glob +import http.client +import json +import os +import select +import shutil +import struct +import sys +import termios +import time +import tty +import urllib.error +from contextlib import contextmanager +from datetime import datetime +from typing import Any, 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": "pound", + "\b": "pound", + "`": "arrow_left", + ":": "colon", + ";": "colon", + "'": "semicolon", + ",": "comma", + ".": "period", + "/": "slash", + "+": "plus", + "-": "plus", + "=": "minus", + "[": "at", + "]": "star", + "\\": "arrow_up", + "@": "at", + "*": "star", + "^": "arrow_up", + "_": "arrow_left", + "£": "pound", +} + +ARROW_KEYS = { + "A": "up", + "B": "down", + "C": "right", + "D": "left", +} +TERMINAL_SPECIAL_KEY_HELP_LINES = ( + "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, F12=joy 1/2, 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": ["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~": ["equals"], +} +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"))) +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 +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"))) +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", "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 +ABS_RY = 0x04 +ABS_HAT0X = 0x10 +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_CAPSLOCK = 58 +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_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", + KEY_DOWN: "down", + KEY_LEFT: "left", + KEY_RIGHT: "right", +} +LOW_LEVEL_DIRECT_KEY_INPUTS: Dict[int, str] = { + **LETTER_KEYCODES, + **DIGIT_KEYCODES, + 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: "pound", + KEY_INSERT: "clr_home", + KEY_HOME: "inst_del", + KEY_CAPSLOCK: "run_stop", + KEY_END: "run_stop", + KEY_PAGEDOWN: "run_stop", + 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] = { + KEY_LEFT: "cursor_left_right", + KEY_UP: "cursor_up_down", + KEY_F2: "f1", + KEY_F4: "f3", + KEY_F6: "f5", + KEY_F8: "f7", +} + + +@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 = 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()) + or (len(seq) >= 2 and not (seq.startswith("\x1b[") or seq.startswith("\x1bO"))) + ): + break + return seq + + +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"} + + +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"]) + + +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 + if seq == "\x1b[24~": + return "toggle_joystick_port", 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): + max_items = len(events) if self.level >= 2 else 5 + 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 '-'}" + 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, + 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.fire2_pressed = False + self.fire3_pressed = False + 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") + 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 + 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 in (BTN_SOUTH, BTN_NORTH, BTN_TRIGGER, 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]]: + return None + + 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: + 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 InteractiveRestClient: + 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.logger = logger + + 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]]) -> Dict[str, object]: + payload: Dict[str, object] = {"events": events} + 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 = time.monotonic() + try: + connection.request("POST", "/v1/machine:input", body=body, headers=headers) + response = connection.getresponse() + data = response.read() + 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 {} + 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 + raise Failure(f"Interactive REST request failed: {format_exception(exc)}") from exc + 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 + self.signature: Optional[Tuple[object, object, Tuple[object, ...], object]] = 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_batch_at = 0.0 + self.fast_cadence_count = 0 + + def clear(self) -> None: + self.sequence = None + self.signature = None + self.event = None + self.count = 0 + self.confirmed = False + self.first_seen_at = 0.0 + self.last_seen_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 + + 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: + 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) -> Tuple[bool, List[Dict[str, object]]]: + if not self._repeatable(event): + self.clear() + 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 + 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_batch_at = now + REPEAT_BATCH_INTERVAL + return True, [self.event] * REPEAT_BATCH_EVENTS if self.event is not None else [] + return True, [] + + self.clear() + self._start(sequence, signature, event, now) + return False, [] + + def poll(self, now: float) -> List[Dict[str, object]]: + if not self._active(): + return [] + if (now - self.last_seen_at) > KEY_REPEAT_STALE_SECONDS: + self.clear() + 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]: + 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 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] + client.post_events(chunk) + 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.startswith("\x1b[") or seq.startswith("\x1bO")) and ";" not in seq and seq[-1:] in ARROW_KEYS: + direction = ARROW_KEYS[seq[-1]] + return cursor_keyboard_event(direction) + inputs = keyboard_inputs_for_char(seq) + if inputs: + return keyboard_event(inputs) + 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 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]]: + 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")] + + 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]]: + 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")] + + 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_F12: + return "toggle_joystick_port", [] + + 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() + 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]: + 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 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 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}") + if keyboard: + 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; 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") + else: + 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" + 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/X=fire; B=fire2; Y=fire3)") + else: + print("gamepad: not detected") + + +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, 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 == "toggle_joystick_port": + repeat_state.stop() + decoder.clear() + return "toggle_joystick_port", [] + if action == "quit": + repeat_state.stop() + decoder.clear() + return "quit", [] + if action == "release_all": + repeat_state.stop() + decoder.clear() + return "release_all", [release_all_event()] + if event is None: + 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 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, + logger: Optional[RestCallLogger], +) -> None: + stdin_fd = sys.stdin.fileno() + client = InteractiveRestClient(session, logger=logger) + repeat_state = RepeatState() + decoder = SequenceDecoder() + try: + while True: + now = time.monotonic() + 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, 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 == "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) + client.post_events([release_all_event()]) + return + pending_events.extend(repeat_state.poll(time.monotonic())) + if pending_events: + flush_event_batch(client, pending_events) + finally: + client.close() + + +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, 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), 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: + 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, 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 == "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) + client.post_events([release_all_event()]) + return + pending_events.extend(repeat_state.poll(time.monotonic())) + if pending_events: + flush_event_batch(client, pending_events) + finally: + client.close() + + +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 == "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) + 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) + 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: + 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 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() + 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"]), + ("\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}") + + 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")]), + ("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}") + + 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), + 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}") + + 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"]), + ("\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", ["inst_del"]), + ("\x1b[2~", ["clr_home"]), + ("\x1b[3~", ["restore"]), + ("\b", ["pound"]), + ("\x7f", ["pound"]), + ("\x1b[F", ["run_stop"]), + ("\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) + 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: + 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] 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("[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("[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( + [ + {"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) + 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="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 + 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, keyboard, gamepad, logger if verbosity > 0 else None, verbosity) + 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 keyboard: + keyboard.close() + if gamepad: + gamepad.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())