Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions Scripts/lab/pico-hid-fixture-client
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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())
Expand Down
15 changes: 12 additions & 3 deletions Scripts/lab/pico-hid-fixture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
76 changes: 63 additions & 13 deletions Scripts/lab/pico-hid-fixture/src/fixture_core.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Comment on lines +202 to 207

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject trailing garbage on legacy event lines

When an eight-field event is followed by nonnumeric text, such as 0 0 4 0 0 0 0 0 garbage, the optional consumer conversion fails after eight successful assignments, so fields == 8 accepts the line and silently ignores the suffix. Because the CRC covers that malformed payload, it does not protect against this case, allowing a mistyped deterministic fixture script to run with different contents than intended. Treat the consumer field as absent only when the eighth value is followed by end-of-line; otherwise reject the line.

Useful? React with 👍 / 👎.

}
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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;
Expand All @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion Scripts/lab/pico-hid-fixture/src/fixture_core.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -56,14 +63,18 @@ 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;
uint32_t trace_count;
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);
Expand Down
6 changes: 4 additions & 2 deletions Scripts/lab/pico-hid-fixture/src/http_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion Scripts/lab/pico-hid-fixture/src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
3 changes: 2 additions & 1 deletion Scripts/lab/pico-hid-fixture/src/usb_descriptors.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion Scripts/lab/pico-hid-fixture/src/usb_descriptors.h
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading
Loading