diff --git a/Scripts/lab/pico-hid-fixture-client b/Scripts/lab/pico-hid-fixture-client index daed23c79..01e25cd86 100755 --- a/Scripts/lab/pico-hid-fixture-client +++ b/Scripts/lab/pico-hid-fixture-client @@ -79,6 +79,48 @@ def compile_text(run_id: str, text: str, interval_ms: int, hold_ms: int, return f"KPHID1 {run_id} {len(events)} {repeat_count} {cycle_us} {crc:08x}\n{event_payload}" + +# Consumer-page usages the fixture can press as media keys (the same ten keys +# KeyPath's driverless session posts and reads; see driverless-media-keys.md). +MEDIA_USAGE = { + "volume-up": 0xE9, "volume-down": 0xEA, "mute": 0xE2, + "brightness-up": 0x6F, "brightness-down": 0x70, + "play-pause": 0xCD, "next": 0xB5, "previous": 0xB6, + "fast-forward": 0xB3, "rewind": 0xB4, +} + + +def compile_media(run_id: str, keys: list[str], interval_ms: int, hold_ms: int, + repeat_count: int, cycle_gap_ms: int) -> str: + """Press and release each media key in order, as consumer-control reports. + + Each event line carries the optional ninth field (the consumer usage held), + with the keyboard fields all released, so each event is one USB report. + """ + if not run_id or len(run_id) > 48 or any(not (ch.isalnum() or ch in "-_.") for ch in run_id): + raise ValueError("run id must contain 1-48 letters, digits, dots, underscores, or hyphens") + if not keys: + raise ValueError("no media keys given") + if not 1 <= repeat_count <= 10_000: + raise ValueError("repeat count must be between 1 and 10000") + if interval_ms < 4 or not 2 <= hold_ms < interval_ms: + raise ValueError("interval must be at least 4 ms and hold shorter than the interval") + events: list[str] = [] + at_us = 0 + for name in keys: + try: + usage = MEDIA_USAGE[name] + except KeyError as error: + raise ValueError(f"unknown media key {name!r}; known: {', '.join(sorted(MEDIA_USAGE))}") from error + events.append(f"{at_us} 0 0 0 0 0 0 0 {usage}") + events.append(f"{at_us + hold_ms * 1000} 0 0 0 0 0 0 0 0") + at_us += interval_ms * 1000 + cycle_us = at_us + cycle_gap_ms * 1000 + event_payload = "\n".join(events) + "\n" + crc = zlib.crc32(event_payload.encode("ascii")) & 0xFFFFFFFF + return f"KPHID1 {run_id} {len(events)} {repeat_count} {cycle_us} {crc:08x}\n{event_payload}" + + class FixtureClient: def __init__(self, host: str, token: str, port: int = 8080, timeout: float = 10.0): if not token: @@ -267,6 +309,14 @@ def parser() -> argparse.ArgumentParser: compile_command.add_argument("--shift-lead-ms", type=int, default=0) compile_command.add_argument("--shift-release-lag-ms", type=int, default=0) compile_command.add_argument("--output", type=pathlib.Path) + media_command = commands.add_parser("compile-media") + media_command.add_argument("--run-id", required=True) + media_command.add_argument("--keys", required=True, help="comma-separated, e.g. mute,volume-up") + media_command.add_argument("--interval-ms", type=int, default=300) + media_command.add_argument("--hold-ms", type=int, default=60) + media_command.add_argument("--repeat", type=int, default=1) + media_command.add_argument("--cycle-gap-ms", type=int, default=500) + media_command.add_argument("--output", type=pathlib.Path) load_command = commands.add_parser("load-script") load_command.add_argument("script", type=pathlib.Path) arm_command = commands.add_parser("arm") @@ -331,6 +381,15 @@ def main() -> int: else: sys.stdout.write(script) return 0 + if arguments.command == "compile-media": + script = compile_media(arguments.run_id, [key.strip() for key in arguments.keys.split(",") if key.strip()], + arguments.interval_ms, arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms) + if arguments.output: + arguments.output.write_text(script) + print(arguments.output) + else: + sys.stdout.write(script) + return 0 client = FixtureClient(arguments.host, token_from_environment(), arguments.port, arguments.timeout) if arguments.command == "status": print_json(client.status()) diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md index ac5e41354..77291908a 100644 --- a/Scripts/lab/pico-hid-fixture/README.md +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -352,13 +352,22 @@ The first line is: KPHID1 RUN_ID EVENT_COUNT REPEAT_COUNT CYCLE_US CRC32 ``` -Each remaining line is one complete boot-keyboard report: +Each remaining line is one complete keyboard state, with an optional media-key state: ```text -AT_US MODIFIERS KEY1 KEY2 KEY3 KEY4 KEY5 KEY6 +AT_US MODIFIERS KEY1 KEY2 KEY3 KEY4 KEY5 KEY6 [CONSUMER] ``` -Times are absolute within one cycle and must increase. The last report must contain seven zeros. +`CONSUMER` is the consumer-page usage held (0 = none; `226` Mute, `233` Volume Up, `234` Volume +Down, `205` Play/Pause, `111`/`112` Brightness Up/Down, `181`/`182` Next/Previous). It is sent as a +separate consumer-control report (report ID 2), which macOS turns into the same system-defined media +events a real keyboard's media keys produce. Omitting it means 0, so every eight-field script is +unchanged. An event may change the keyboard state or the consumer state, not both, so each event is +exactly one USB report. `pico-hid-fixture-client compile-media --keys mute,volume-up` writes one. + +Times are absolute within one cycle and must increase. The last event must release everything +(seven zeros, and a zero or absent consumer field). An abort or unmount releases a held media key +before the keyboard. The event payload, including final newlines, is protected by the header CRC32. A short cycle can be repeated many times without storing tens of thousands of reports in Pico RAM. diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.c b/Scripts/lab/pico-hid-fixture/src/fixture_core.c index 48f0bde1c..37c499d42 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_core.c +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.c @@ -22,8 +22,26 @@ static bool valid_run_id(const char *value) { return true; } +static bool keyboard_state_equal(const fixture_event_t *left, const fixture_event_t *right) { + return left->modifiers == right->modifiers && memcmp(left->keys, right->keys, 6u) == 0; +} + +/// Each event is one USB report: it may change the keyboard state or the +/// consumer state from the event before it (all released before the first), not both. +static bool events_change_one_report(const fixture_event_t *events, uint32_t count) { + fixture_event_t previous = {0}; + for (uint32_t index = 0u; index < count; ++index) { + bool keyboard_changed = !keyboard_state_equal(&previous, &events[index]); + bool consumer_changed = previous.consumer != events[index].consumer; + if (keyboard_changed && consumer_changed) return false; + if (events[index].consumer > FIXTURE_MAX_CONSUMER_USAGE) return false; + previous = events[index]; + } + return true; +} + static bool report_is_empty(const fixture_event_t *event) { - if (event->modifiers != 0u) return false; + if (event->modifiers != 0u || event->consumer != 0u) return false; for (size_t index = 0; index < 6u; ++index) { if (event->keys[index] != 0u) return false; } @@ -178,12 +196,21 @@ bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, line[line_length] = '\0'; fixture_event_t event = {0}; unsigned int values[7] = {0}; - fields = sscanf(line, "%" SCNu32 " %u %u %u %u %u %u %u %c", &event.at_us, - &values[0], &values[1], &values[2], &values[3], &values[4], &values[5], &values[6], &extra); - if (fields != 8) { - set_error(error, error_capacity, "event must contain time, modifiers, and six key usages"); + unsigned int consumer = 0u; + // An optional ninth field is the consumer usage held (0 = none), so + // every eight-field script written before it still parses unchanged. + fields = sscanf(line, "%" SCNu32 " %u %u %u %u %u %u %u %u %c", &event.at_us, + &values[0], &values[1], &values[2], &values[3], &values[4], &values[5], &values[6], + &consumer, &extra); + if (fields != 8 && fields != 9) { + set_error(error, error_capacity, "event must contain time, modifiers, six key usages, and an optional consumer usage"); return false; } + if (consumer > FIXTURE_MAX_CONSUMER_USAGE) { + set_error(error, error_capacity, "consumer usage is outside the consumer page"); + return false; + } + event.consumer = (uint16_t)consumer; if ((index > 0u && event.at_us <= previous_at) || event.at_us >= cycle_us) { set_error(error, error_capacity, "event times must increase and remain inside the cycle"); return false; @@ -209,6 +236,10 @@ bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, set_error(error, error_capacity, "script contains more event lines than declared"); return false; } + if (!events_change_one_report(fixture->events, event_count)) { + set_error(error, error_capacity, "an event may change the keyboard or the consumer report, not both"); + return false; + } if (!report_is_empty(&fixture->events[event_count - 1u])) { set_error(error, error_capacity, "each cycle must end with an all-keys-released report"); return false; @@ -261,6 +292,10 @@ bool fixture_load_events(fixture_t *fixture, const char *run_id, } previous_at = events[index].at_us; } + if (!events_change_one_report(events, event_count)) { + set_error(error, error_capacity, "an event may change the keyboard or the consumer report, not both"); + return false; + } if (!report_is_empty(&events[event_count - 1u])) { set_error(error, error_capacity, "each cycle must end with an all-keys-released report"); return false; @@ -342,7 +377,7 @@ uint32_t fixture_time_until_next_action_us(const fixture_t *fixture, uint64_t no } static void trace_report(fixture_t *fixture, uint64_t scheduled, uint64_t submitted, - uint8_t modifiers, const uint8_t keys[6]) { + uint8_t modifiers, const uint8_t keys[6], uint16_t consumer, bool consumer_report) { uint32_t slot = fixture->trace_head; fixture_trace_t *trace = &fixture->trace[slot]; trace->sequence = fixture->reports_submitted; @@ -351,21 +386,30 @@ static void trace_report(fixture_t *fixture, uint64_t scheduled, uint64_t submit trace->lateness_us = (int64_t)(submitted - scheduled); trace->modifiers = modifiers; memcpy(trace->keys, keys, 6u); + trace->consumer = consumer; + trace->consumer_report = consumer_report; fixture->trace_head = (slot + 1u) % FIXTURE_TRACE_CAPACITY; if (fixture->trace_count < FIXTURE_TRACE_CAPACITY) fixture->trace_count++; } static bool submit_report(fixture_t *fixture, uint64_t scheduled, uint64_t now_us, - uint8_t modifiers, const uint8_t keys[6], + uint8_t modifiers, const uint8_t keys[6], uint16_t consumer, fixture_send_report_fn send_report, void *send_context) { - if (!send_report(modifiers, keys, send_context)) return false; + bool consumer_report = consumer != fixture->last_consumer; + if (!send_report(modifiers, keys, consumer, consumer_report, send_context)) return false; + if (consumer_report) fixture->last_consumer = consumer; int64_t lateness = (int64_t)(now_us - scheduled); fixture->reports_submitted++; if (lateness > 1000) fixture->late_reports++; if (lateness > fixture->maximum_lateness_us) fixture->maximum_lateness_us = lateness; - uint8_t report[7] = {modifiers, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}; - fixture->submitted_crc32 ^= fixture_crc32(report, sizeof(report)) + (uint32_t)fixture->reports_submitted; - trace_report(fixture, scheduled, now_us, modifiers, keys); + if (consumer_report) { + uint8_t report[3] = {0x0cu, (uint8_t)(consumer & 0xffu), (uint8_t)(consumer >> 8u)}; + fixture->submitted_crc32 ^= fixture_crc32(report, sizeof(report)) + (uint32_t)fixture->reports_submitted; + } else { + uint8_t report[7] = {modifiers, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}; + fixture->submitted_crc32 ^= fixture_crc32(report, sizeof(report)) + (uint32_t)fixture->reports_submitted; + } + trace_report(fixture, scheduled, now_us, modifiers, keys, consumer, consumer_report); return true; } @@ -382,7 +426,12 @@ void fixture_poll(fixture_t *fixture, uint64_t now_us, bool usb_mounted, bool us static const uint8_t empty_keys[6] = {0}; if (fixture->pending_release) { - if (send_report(0u, empty_keys, send_context)) fixture->pending_release = false; + // Release a held media key first, then the keyboard; one report per poll. + if (fixture->last_consumer != 0u) { + if (send_report(0u, empty_keys, 0u, true, send_context)) fixture->last_consumer = 0u; + return; + } + if (send_report(0u, empty_keys, 0u, false, send_context)) fixture->pending_release = false; return; } if (fixture->state != FIXTURE_RUNNING || now_us < fixture->start_at_us) return; @@ -391,7 +440,8 @@ void fixture_poll(fixture_t *fixture, uint64_t now_us, bool usb_mounted, bool us fixture->events[fixture->next_event].at_us; if (now_us < scheduled) return; fixture_event_t *event = &fixture->events[fixture->next_event]; - if (!submit_report(fixture, scheduled, now_us, event->modifiers, event->keys, send_report, send_context)) return; + if (!submit_report(fixture, scheduled, now_us, event->modifiers, event->keys, event->consumer, + send_report, send_context)) return; fixture->next_event++; if (fixture->next_event == fixture->event_count) { diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.h b/Scripts/lab/pico-hid-fixture/src/fixture_core.h index b82bf7a2d..558ca64c3 100644 --- a/Scripts/lab/pico-hid-fixture/src/fixture_core.h +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.h @@ -22,10 +22,15 @@ typedef enum { FIXTURE_ERROR, } fixture_state_t; +#define FIXTURE_MAX_CONSUMER_USAGE 0x3ffu + +/// One state of the keyboard and of the consumer-control (media) report. +/// An event changes one of the two, so each event is exactly one USB report. typedef struct { uint32_t at_us; uint8_t modifiers; uint8_t keys[6]; + uint16_t consumer; /* consumer-page usage held, 0 = none (0xE2 Mute, 0xE9 Volume Up, ...) */ } fixture_event_t; typedef struct { @@ -35,6 +40,8 @@ typedef struct { int64_t lateness_us; uint8_t modifiers; uint8_t keys[6]; + uint16_t consumer; + bool consumer_report; /* true: this entry is the consumer report, not the keyboard report */ } fixture_trace_t; typedef struct { @@ -56,6 +63,7 @@ typedef struct { uint32_t submitted_crc32; bool pending_release; bool usb_mounted; + uint16_t last_consumer; /* consumer usage the host currently sees as held */ fixture_trace_t trace[FIXTURE_TRACE_CAPACITY]; uint32_t trace_head; @@ -63,7 +71,10 @@ typedef struct { char error[128]; } fixture_t; -typedef bool (*fixture_send_report_fn)(uint8_t modifiers, const uint8_t keys[6], void *context); +/// Sends the keyboard report, or with `consumer_report` the consumer-control +/// report carrying `consumer` (keyboard fields unchanged and ignored). +typedef bool (*fixture_send_report_fn)(uint8_t modifiers, const uint8_t keys[6], uint16_t consumer, + bool consumer_report, void *context); void fixture_init(fixture_t *fixture); const char *fixture_state_name(fixture_state_t state); diff --git a/Scripts/lab/pico-hid-fixture/src/http_server.c b/Scripts/lab/pico-hid-fixture/src/http_server.c index 24f014947..9e18a5706 100644 --- a/Scripts/lab/pico-hid-fixture/src/http_server.c +++ b/Scripts/lab/pico-hid-fixture/src/http_server.c @@ -139,10 +139,12 @@ static void trace_response(http_client_t *client, const char *path) { int count = snprintf(body + used, sizeof(body) - used, "{\"sequence\":%" PRIu64 ",\"scheduledUs\":%" PRIu64 "," "\"submittedUs\":%" PRIu64 ",\"latenessUs\":%" PRId64 "," - "\"modifiers\":%u,\"keys\":[%u,%u,%u,%u,%u,%u]}\n", + "\"modifiers\":%u,\"keys\":[%u,%u,%u,%u,%u,%u]," + "\"consumer\":%u,\"consumerReport\":%s}\n", trace->sequence, trace->scheduled_us, trace->submitted_us, trace->lateness_us, trace->modifiers, trace->keys[0], trace->keys[1], - trace->keys[2], trace->keys[3], trace->keys[4], trace->keys[5]); + trace->keys[2], trace->keys[3], trace->keys[4], trace->keys[5], + trace->consumer, trace->consumer_report ? "true" : "false"); if (count < 0 || (size_t)count >= sizeof(body) - used) break; used += (size_t)count; } diff --git a/Scripts/lab/pico-hid-fixture/src/main.c b/Scripts/lab/pico-hid-fixture/src/main.c index da5cf54cb..735707fbb 100644 --- a/Scripts/lab/pico-hid-fixture/src/main.c +++ b/Scripts/lab/pico-hid-fixture/src/main.c @@ -17,9 +17,11 @@ static fixture_t fixture; static bool mdns_started; -static bool send_keyboard_report(uint8_t modifiers, const uint8_t keys[6], void *context) { +static bool send_keyboard_report(uint8_t modifiers, const uint8_t keys[6], uint16_t consumer, + bool consumer_report, void *context) { (void)context; if (!tud_hid_ready()) return false; + if (consumer_report) return tud_hid_report(REPORT_ID_CONSUMER, &consumer, sizeof(consumer)); return tud_hid_keyboard_report(REPORT_ID_KEYBOARD, modifiers, keys); } diff --git a/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c index 06a19660b..49ed88979 100644 --- a/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c +++ b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c @@ -31,7 +31,8 @@ uint8_t const *tud_descriptor_device_cb(void) { } uint8_t const hid_report_descriptor[] = { - TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)) + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), + TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(REPORT_ID_CONSUMER)) }; uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) { diff --git a/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h index 9358ed7c9..e0cae6497 100644 --- a/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h +++ b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h @@ -1,6 +1,6 @@ #ifndef KEYPATH_USB_DESCRIPTORS_H #define KEYPATH_USB_DESCRIPTORS_H -enum { REPORT_ID_KEYBOARD = 1 }; +enum { REPORT_ID_KEYBOARD = 1, REPORT_ID_CONSUMER = 2 }; #endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c index a5a6c9c04..04b8586dd 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -514,10 +514,12 @@ static esp_err_t trace_handler(httpd_req_t *request) { snprintf(line, sizeof(line), "{\"sequence\":%" PRIu64 ",\"scheduledUs\":%" PRIu64 "," "\"submittedUs\":%" PRIu64 ",\"latenessUs\":%" PRId64 "," - "\"modifiers\":%u,\"keys\":[%u,%u,%u,%u,%u,%u]}\n", + "\"modifiers\":%u,\"keys\":[%u,%u,%u,%u,%u,%u]," + "\"consumer\":%u,\"consumerReport\":%s}\n", trace.sequence, trace.scheduled_us, trace.submitted_us, trace.lateness_us, trace.modifiers, trace.keys[0], trace.keys[1], trace.keys[2], - trace.keys[3], trace.keys[4], trace.keys[5]); + trace.keys[3], trace.keys[4], trace.keys[5], + trace.consumer, trace.consumer_report ? "true" : "false"); httpd_resp_send_chunk(request, line, HTTPD_RESP_USE_STRLEN); } return httpd_resp_send_chunk(request, NULL, 0u); diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c index d3e005f73..e73740b3a 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c @@ -16,8 +16,11 @@ typedef struct { uint8_t final_key; } smoke_reports_t; -static bool capture_report(uint8_t modifiers, const uint8_t keys[6], void *context) { +static bool capture_report(uint8_t modifiers, const uint8_t keys[6], uint16_t consumer, + bool consumer_report, void *context) { (void)modifiers; + (void)consumer; + (void)consumer_report; smoke_reports_t *reports = context; if (reports->count == 0u) reports->first_key = keys[0]; reports->final_key = keys[0]; diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c index 9ca5ba82f..e18f90b48 100644 --- a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c @@ -15,6 +15,7 @@ #include "tinyusb_default_config.h" #define REPORT_ID_KEYBOARD 1u +#define REPORT_ID_CONSUMER 2u #define HID_ENDPOINT 0x81u #define CONFIGURATION_LENGTH (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN) @@ -55,6 +56,9 @@ static const tusb_desc_device_t device_descriptor = { static const uint8_t hid_report_descriptor[] = { TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), + // Media keys (Mute, Volume, Play/Pause...) are consumer-page usages and + // need their own report; macOS turns them into system-defined events. + TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(REPORT_ID_CONSUMER)), }; static const uint8_t configuration_descriptor[] = { @@ -108,9 +112,12 @@ void tud_hid_report_complete_cb(uint8_t instance, uint8_t const *report, uint16_ runtime_unlock(); } -static bool send_keyboard_report(uint8_t modifiers, const uint8_t keys[6], void *context) { +static bool send_keyboard_report(uint8_t modifiers, const uint8_t keys[6], uint16_t consumer, + bool consumer_report, void *context) { (void)context; - return tud_hid_ready() && tud_hid_keyboard_report(REPORT_ID_KEYBOARD, modifiers, keys); + if (!tud_hid_ready()) return false; + if (consumer_report) return tud_hid_report(REPORT_ID_CONSUMER, &consumer, sizeof(consumer)); + return tud_hid_keyboard_report(REPORT_ID_KEYBOARD, modifiers, keys); } static void executor_task(void *context) { diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c index c2851255b..d8c58755c 100644 --- a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -16,13 +16,18 @@ typedef struct { uint32_t count; uint8_t modifiers[32]; uint8_t keys[32][6]; + uint16_t consumer[32]; + bool consumer_report[32]; } reports_t; -static bool capture_report(uint8_t modifiers, const uint8_t keys[6], void *context) { +static bool capture_report(uint8_t modifiers, const uint8_t keys[6], uint16_t consumer, + bool consumer_report, void *context) { reports_t *reports = context; assert(reports->count < 32u); reports->modifiers[reports->count] = modifiers; memcpy(reports->keys[reports->count], keys, 6u); + reports->consumer[reports->count] = consumer; + reports->consumer_report[reports->count] = consumer_report; reports->count++; return true; } @@ -505,6 +510,89 @@ static void test_sound_mode_defaults_silent_and_toggles(void) { assert(sound.silent); } +/* Media keys: an optional ninth field holds a consumer-page usage. */ +static void test_consumer_report_press_release_and_trace(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + const char *events = "0 0 0 0 0 0 0 0 226\n50000 0 0 0 0 0 0 0 0\n"; /* Mute down, up */ + char script[512], error[128]; + make_script(script, sizeof(script), "media", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture.events[0].consumer == 226u); + assert(fixture_arm(&fixture, "media", error, sizeof(error))); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + assert(fixture_start(&fixture, "media", 100u, 1000u, error, sizeof(error))); + fixture_poll(&fixture, 101000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 151000u, true, true, capture_report, &reports); + assert(fixture.state == FIXTURE_COMPLETE); + uint32_t press = reports.count - 2u, release = reports.count - 1u; + assert(reports.consumer_report[press] && reports.consumer[press] == 226u); + assert(reports.consumer_report[release] && reports.consumer[release] == 0u); + assert(fixture.last_consumer == 0u); + assert(fixture_trace_at(&fixture, 0u)->consumer_report); + assert(fixture_trace_at(&fixture, 0u)->consumer == 226u); +} + +static void test_eight_field_scripts_never_send_consumer_reports(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "plain", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture_arm(&fixture, "plain", error, sizeof(error))); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + assert(fixture_start(&fixture, "plain", 100u, 1000u, error, sizeof(error))); + fixture_poll(&fixture, 101000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 151000u, true, true, capture_report, &reports); + for (uint32_t index = 0u; index < reports.count; ++index) assert(!reports.consumer_report[index]); +} + +static void test_rejects_unsafe_consumer_scripts(void) { + fixture_t fixture; + fixture_init(&fixture); + char script[512], error[128]; + const char *both = "0 0 4 0 0 0 0 0 226\n50000 0 0 0 0 0 0 0 0\n"; + make_script(script, sizeof(script), "both", 1u, both, 2u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "not both")); + const char *held = "0 0 0 0 0 0 0 0 226\n"; + make_script(script, sizeof(script), "held", 1u, held, 1u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "all-keys-released")); + const char *range = "0 0 0 0 0 0 0 0 1024\n50000 0 0 0 0 0 0 0 0\n"; + make_script(script, sizeof(script), "range", 1u, range, 2u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "consumer page")); +} + +static void test_abort_releases_a_held_media_key_before_the_keyboard(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + const char *events = "0 0 0 0 0 0 0 0 233\n50000 0 0 0 0 0 0 0 0\n"; /* Volume Up held */ + char script[512], error[128]; + make_script(script, sizeof(script), "held", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture_arm(&fixture, "held", error, sizeof(error))); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + assert(fixture_start(&fixture, "held", 100u, 1000u, error, sizeof(error))); + fixture_poll(&fixture, 101000u, true, true, capture_report, &reports); + assert(fixture.last_consumer == 233u); + fixture_abort(&fixture, "operator abort"); + fixture_poll(&fixture, 101001u, true, true, capture_report, &reports); + assert(reports.consumer_report[reports.count - 1u] && reports.consumer[reports.count - 1u] == 0u); + assert(fixture.pending_release); + fixture_poll(&fixture, 101002u, true, true, capture_report, &reports); + assert(!reports.consumer_report[reports.count - 1u]); + assert(!fixture.pending_release && fixture.last_consumer == 0u); +} + int main(void) { test_load_arm_run_and_repeat(); test_rejects_corrupt_and_unsafe_scripts(); @@ -523,6 +611,10 @@ int main(void) { test_splash_reveals_holds_and_fades_without_blocking_boot(); test_button_feedback_identifies_physical_positions_and_expires(); test_sound_mode_defaults_silent_and_toggles(); + test_consumer_report_press_release_and_trace(); + test_eight_field_scripts_never_send_consumer_reports(); + test_rejects_unsafe_consumer_scripts(); + test_abort_releases_a_held_media_key_before_the_keyboard(); puts("physical HID fixture core tests passed"); return 0; } diff --git a/Scripts/lab/tests/pico-hid-fixture-client-tests.py b/Scripts/lab/tests/pico-hid-fixture-client-tests.py index 4440f5568..cea2d1cf7 100755 --- a/Scripts/lab/tests/pico-hid-fixture-client-tests.py +++ b/Scripts/lab/tests/pico-hid-fixture-client-tests.py @@ -185,5 +185,24 @@ def test_firmware_update_rejects_empty_images_and_ambiguous_builds(self): self.client.update_firmware(b"image", "not-a-build") + +class MediaScriptTests(unittest.TestCase): + def test_media_keys_compile_to_consumer_events_with_a_valid_crc(self): + script = CLIENT.compile_media("media-1", ["mute", "volume-up"], 300, 60, 1, 500) + header, *lines = script.rstrip("\n").split("\n") + fields = header.split() + self.assertEqual(fields[:5], ["KPHID1", "media-1", "4", "1", str(600_000 + 500_000)]) + payload = "\n".join(lines) + "\n" + self.assertEqual(int(fields[5], 16), CLIENT.zlib.crc32(payload.encode()) & 0xFFFFFFFF) + self.assertEqual(lines, ["0 0 0 0 0 0 0 0 226", "60000 0 0 0 0 0 0 0 0", + "300000 0 0 0 0 0 0 0 233", "360000 0 0 0 0 0 0 0 0"]) + + def test_unknown_media_key_and_bad_timing_are_refused(self): + with self.assertRaisesRegex(ValueError, "unknown media key"): + CLIENT.compile_media("m", ["calculator"], 300, 60, 1, 0) + with self.assertRaises(ValueError): + CLIENT.compile_media("m", ["mute"], 60, 60, 1, 0) + + if __name__ == "__main__": unittest.main()