diff --git a/firmware-bluetooth/prj.conf b/firmware-bluetooth/prj.conf index 99e32201..1b915f2a 100644 --- a/firmware-bluetooth/prj.conf +++ b/firmware-bluetooth/prj.conf @@ -26,6 +26,7 @@ CONFIG_GPIO=y CONFIG_BT=y CONFIG_BT_DEBUG_LOG=y CONFIG_BT_CENTRAL=y +CONFIG_BT_PERIPHERAL=y CONFIG_BT_SMP=y CONFIG_BT_L2CAP_TX_BUF_COUNT=5 CONFIG_BT_GATT_CLIENT=y @@ -45,6 +46,10 @@ CONFIG_BT_HOGP_REPORTS_MAX=32 CONFIG_BT_DEVICE_NAME="HID Remapper Bluetooth" CONFIG_ENABLE_HID_INT_OUT_EP=y +# Peripheral mode support for UART service +CONFIG_BT_GATT_SERVICE_CHANGED=y +CONFIG_BT_GATT_DYNAMIC_DB=y + CONFIG_USB_DEVICE_STACK=y CONFIG_USB_DEVICE_HID=y CONFIG_USB_HID_DEVICE_COUNT=2 diff --git a/firmware-bluetooth/src/main.cc b/firmware-bluetooth/src/main.cc index 913618aa..74d17dc2 100644 --- a/firmware-bluetooth/src/main.cc +++ b/firmware-bluetooth/src/main.cc @@ -26,11 +26,91 @@ #include "our_descriptor.h" #include "platform.h" #include "remapper.h" +#include "crc.h" // If you have this from your receiver project LOG_MODULE_REGISTER(remapper, LOG_LEVEL_DBG); #define CHK(X) ({ int err = X; if (err != 0) { LOG_ERR("%s returned %d (%s:%d)", #X, err, __FILE__, __LINE__); } err == 0; }) +// Peripheral mode support +enum operation_mode { + MODE_HOST = 0, + MODE_PERIPHERAL = 1 +}; + +static enum operation_mode current_mode = MODE_PERIPHERAL; + +// Nordic UART Service UUID - using standard Nordic UUIDs for Web Bluetooth compatibility +static struct bt_uuid_128 uart_service_uuid = BT_UUID_INIT_128( + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e); + +static struct bt_uuid_128 uart_rx_uuid = BT_UUID_INIT_128( + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x02, 0x00, 0x40, 0x6e); + +static struct bt_uuid_128 uart_tx_uuid = BT_UUID_INIT_128( + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x03, 0x00, 0x40, 0x6e); + +// Add packet structure definitions similar to the receiver +#define PROTOCOL_VERSION 1 +#define SERIAL_MAX_PACKET_SIZE 512 + +#define END 0300 /* indicates end of packet */ +#define ESC 0333 /* indicates byte stuffing */ +#define ESC_END 0334 /* ESC ESC_END means END data byte */ +#define ESC_ESC 0335 /* ESC ESC_ESC means ESC data byte */ + +typedef struct __attribute__((packed)) { + uint8_t protocol_version; + uint8_t our_descriptor_number; + uint8_t len; + uint8_t report_id; + uint8_t data[0]; +} packet_t; + +// Add after the packet structure definitions +typedef struct { + uint8_t report_id; + uint8_t len; + uint8_t data[64]; +} outgoing_report_t; + +#define OR_BUFSIZE 8 +static outgoing_report_t outgoing_reports[OR_BUFSIZE]; +static uint8_t or_head = 0; +static uint8_t or_tail = 0; +static uint8_t or_items = 0; + +static void queue_outgoing_report(uint8_t report_id, uint8_t* data, uint8_t len) { + if (or_items == OR_BUFSIZE) { + LOG_WRN("Report queue overflow!"); + return; + } + outgoing_reports[or_tail].report_id = report_id; + outgoing_reports[or_tail].len = len; + memcpy(outgoing_reports[or_tail].data, data, len); + or_tail = (or_tail + 1) % OR_BUFSIZE; + or_items++; +} + +// Packet buffer and state for SLIP-like framing +static uint8_t packet_buffer[SERIAL_MAX_PACKET_SIZE]; +static uint16_t bytes_read = 0; +static bool escaped = false; + +static void peripheral_mode_init(void); +static void process_peripheral_command(uint8_t* buf, int count); +static void start_peripheral_advertising(void); +static bool do_send_report(uint8_t interface, const uint8_t* report_with_id, uint8_t len); + +static ssize_t uart_write_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr, + const void *buf, uint16_t len, uint16_t offset, uint8_t flags); +static void uart_ccc_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value); +static void peripheral_connected(struct bt_conn *conn, uint8_t err); +static void peripheral_disconnected(struct bt_conn *conn, uint8_t reason); + static const int SCAN_DELAY_MS = 1000; static const int CLEAR_BONDS_BUTTON_PRESS_MS = 3000; @@ -87,6 +167,23 @@ K_MSGQ_DEFINE(disconnected_q, sizeof(struct disconnected_type), CONFIG_BT_MAX_CO K_MSGQ_DEFINE(set_report_q, sizeof(struct set_report_type), 8, 4); ATOMIC_DEFINE(tick_pending, 1); +// Peripheral mode GATT service definitions +static struct bt_conn *peripheral_conn; + +// Use Zephyr's simplified GATT service macro +BT_GATT_SERVICE_DEFINE(uart_service, + BT_GATT_PRIMARY_SERVICE(&uart_service_uuid), + BT_GATT_CHARACTERISTIC(&uart_rx_uuid.uuid, + BT_GATT_CHRC_WRITE | BT_GATT_CHRC_WRITE_WITHOUT_RESP, + BT_GATT_PERM_WRITE, NULL, uart_write_cb, NULL), + BT_GATT_CHARACTERISTIC(&uart_tx_uuid.uuid, + BT_GATT_CHRC_NOTIFY, + BT_GATT_PERM_NONE, NULL, NULL, NULL), + BT_GATT_CCC(uart_ccc_cfg_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), +); + + + #define SW0_NODE DT_ALIAS(sw0) #if !DT_NODE_HAS_STATUS(SW0_NODE, okay) #error "Unsupported board: sw0 devicetree alias is not defined" @@ -112,7 +209,7 @@ static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios); static bool scanning = false; static bool peers_only = true; -static struct bt_le_conn_param* conn_param = BT_LE_CONN_PARAM(6, 6, 44, 400); +static struct bt_le_conn_param* conn_param = BT_LE_CONN_PARAM(12, 24, 0, 3000); static void activity_led_off_work_fn(struct k_work* work) { gpio_pin_set_dt(&led0, false); @@ -172,6 +269,69 @@ static void set_led_mode(LedMode led_mode_) { } } +// Peripheral mode callbacks +static ssize_t uart_write_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr, + const void *buf, uint16_t len, uint16_t offset, uint8_t flags) +{ + if (current_mode == MODE_PERIPHERAL) { + process_peripheral_command((uint8_t*)buf, len); + } + return len; +} + +static void uart_ccc_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value) +{ + LOG_DBG("UART CCC changed: %d", value); + // Handle notification enable/disable +} + +static void peripheral_connected(struct bt_conn *conn, uint8_t err) +{ + if (err) { + LOG_ERR("Peripheral connection failed (err %u)", err); + return; + } + + if (current_mode == MODE_PERIPHERAL) { + peripheral_conn = bt_conn_ref(conn); + LOG_INF("Peripheral connected"); + set_led_mode(LedMode::ON); + + // Request stable connection parameters for peripheral mode + struct bt_le_conn_param param = { + .interval_min = 12, // 15ms minimum + .interval_max = 24, // 30ms maximum + .latency = 0, // No latency for real-time data + .timeout = 3000 // 30 second timeout + }; + + int ret = bt_conn_le_param_update(conn, ¶m); + if (ret) { + LOG_WRN("Failed to update connection parameters: %d", ret); + } else { + LOG_INF("Requested stable connection parameters"); + } + } +} + +static void peripheral_disconnected(struct bt_conn *conn, uint8_t reason) +{ + if (current_mode == MODE_PERIPHERAL && conn == peripheral_conn) { + LOG_INF("Peripheral disconnected (reason %u)", reason); + bt_conn_unref(peripheral_conn); + peripheral_conn = NULL; + set_led_mode(LedMode::BLINK); + + // Restart advertising + start_peripheral_advertising(); + } +} + +BT_CONN_CB_DEFINE(peripheral_conn_callbacks) = { + .connected = peripheral_connected, + .disconnected = peripheral_disconnected, +}; + static void scan_start() { if (CHK(bt_scan_start(BT_SCAN_TYPE_SCAN_PASSIVE))) { LOG_DBG("Scanning started."); @@ -240,6 +400,12 @@ static bool scan_setup_filters() { } static void scan_start_work_fn(struct k_work* work) { + // Don't scan aggressively when in peripheral mode to avoid interference + if (current_mode == MODE_PERIPHERAL && peripheral_conn) { + LOG_DBG("Skipping scan - peripheral mode with active connection"); + return; + } + if (scanning) { scan_stop(); } @@ -336,7 +502,7 @@ static void patch_broken_uuids(struct bt_gatt_dm* dm) { bt_uuid_to_str(attr->uuid, str1, sizeof(str2)); *((bt_uuid_16*) attr->uuid) = { .uuid = { BT_UUID_TYPE_16 }, - .val = (BT_UUID_128(attr->uuid)->val[13] << 8 | BT_UUID_128(attr->uuid)->val[12]) + .val = (uint16_t)(BT_UUID_128(attr->uuid)->val[13] << 8 | BT_UUID_128(attr->uuid)->val[12]) }; bt_uuid_to_str(attr->uuid, str2, sizeof(str2)); LOG_INF("%s -> %s", str1, str2); @@ -386,11 +552,256 @@ static void button_cb(const struct device* dev, struct gpio_callback* cb, uint32 if (k_uptime_get() - button_pressed_at > CLEAR_BONDS_BUTTON_PRESS_MS) { clear_bonds(); } else { - pair_new_device(); + // Toggle between host and peripheral mode + if (current_mode == MODE_HOST) { + current_mode = MODE_PERIPHERAL; + LOG_INF("Switching to peripheral mode"); + peripheral_mode_init(); + } else { + current_mode = MODE_HOST; + LOG_INF("Switching to host mode"); + pair_new_device(); + } + } + } +} + +// Virtual gamepad neutral state following horipad pattern +static const uint8_t virtual_gamepad_neutral[] = { 0x00, 0x00, 0x0F, 0x80, 0x80, 0x80, 0x80, 0x00 }; + +// Virtual gamepad helper functions following horipad pattern +static void virtual_gamepad_clear_report(uint8_t* report, uint8_t report_id, uint16_t len) { + memcpy(report, virtual_gamepad_neutral, sizeof(virtual_gamepad_neutral)); +} + +static int32_t virtual_gamepad_default_value(uint32_t usage) { + switch (usage) { + case 0x00010039: // Hat switch + return 15; // Neutral hat switch position (matches horipad) + case 0x00010030: // X axis + case 0x00010031: // Y axis + case 0x00010032: // Z axis + case 0x00010035: // Rz axis + return 0x80; // Center position for analog sticks (matches horipad) + default: + return 0; + } +} + +// Peripheral mode implementation functions +static void peripheral_mode_init(void) { + // Stop host mode scanning + if (scanning) { + scan_stop(); + } + + // Disconnect any existing host connections + bt_conn_foreach(BT_CONN_TYPE_LE, disconnect_conn, NULL); + + // Initialize packet reception state + bytes_read = 0; + escaped = false; + + // Create a virtual transmitter device that represents the input source + // This should match the descriptor of the device that's transmitting to us + uint16_t virtual_interface = 0xFF00; // Will be changed to actual interface after parsing + + // For peripheral mode, we need to set up a virtual input device descriptor + // that matches what the transmitter is sending. Use a standard gamepad descriptor + // that should work with most common input formats. + + // Virtual gamepad descriptor with Report ID to match firmware format expectations + static const uint8_t virtual_gamepad_descriptor[] = { + 0x05, 0x01, // Usage Page (Generic Desktop Ctrls) + 0x09, 0x05, // Usage (Game Pad) + 0xA1, 0x01, // Collection (Application) + 0x85, 0x01, // Report ID (1) - Add report ID to match firmware format + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x35, 0x00, // Physical Minimum (0) + 0x45, 0x01, // Physical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x0E, // Report Count (14) - 14 buttons like horipad + 0x05, 0x09, // Usage Page (Button) + 0x19, 0x01, // Usage Minimum (0x01) + 0x29, 0x0E, // Usage Maximum (0x0E) + 0x81, 0x02, // Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0x95, 0x02, // Report Count (2) - 2-bit padding like horipad + 0x81, 0x01, // Input (Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0x05, 0x01, // Usage Page (Generic Desktop Ctrls) + 0x25, 0x0F, // Logical Maximum (15) - to match transmitter dpad_lut + 0x46, 0x3B, 0x01, // Physical Maximum (315) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x65, 0x14, // Unit (System: English Rotation, Length: Centimeter) + 0x09, 0x39, // Usage (Hat switch) + 0x81, 0x42, // Input (Data,Var,Abs,No Wrap,Linear,Preferred State,Null State) + 0x65, 0x00, // Unit (None) + 0x95, 0x01, // Report Count (1) - 4-bit padding + 0x81, 0x01, // Input (Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x46, 0xFF, 0x00, // Physical Maximum (255) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x09, 0x32, // Usage (Z) + 0x09, 0x35, // Usage (Rz) + 0x75, 0x08, // Report Size (8) + 0x95, 0x04, // Report Count (4) + 0x81, 0x02, // Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0x75, 0x08, // Report Size (8) - like horipad + 0x95, 0x01, // Report Count (1) - 1 padding byte + 0x81, 0x01, // Input (Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position) + 0xC0, // End Collection + }; + + // Parse the virtual transmitter descriptor following horipad pattern + // Use interface 0 (like first real HID interface in host mode) + virtual_interface = 0x0000; // Use proper interface 0x0000 + + // CRITICAL: Clear any existing interface indexes to ensure virtual device gets index 0 + // This prevents button offset issues where virtual device gets assigned index 8 + // and buttons appear as 9,10,11,12 instead of 1,2,3,4 + interface_index_in_use = 0; + interface_index.clear(); + + parse_descriptor(0x0F0D, 0x00C1, // Use horipad VID/PID as reference + virtual_gamepad_descriptor, + sizeof(virtual_gamepad_descriptor), + virtual_interface, 0); + device_connected_callback(virtual_interface, 0x0F0D, 0x00C1, 0); + + // Force update of their descriptor derivates for the virtual device + their_descriptor_updated = true; + + // Set LED to indicate peripheral mode (blinking = advertising/waiting for connection) + set_led_mode(LedMode::BLINK); + + // Start advertising + start_peripheral_advertising(); + + LOG_INF("Peripheral mode initialized with virtual gamepad descriptor"); +} + +static void start_peripheral_advertising(void) { + struct bt_le_adv_param adv_param = { + .id = BT_ID_DEFAULT, + .sid = 0, + .secondary_max_skip = 0, + .options = BT_LE_ADV_OPT_CONNECTABLE | BT_LE_ADV_OPT_USE_NAME, + .interval_min = BT_GAP_ADV_FAST_INT_MIN_2, + .interval_max = BT_GAP_ADV_FAST_INT_MAX_2, + .peer = NULL, + }; + + static const struct bt_data ad[] = { + BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), + BT_DATA(BT_DATA_NAME_COMPLETE, "HID Remapper", 12), + BT_DATA_BYTES(BT_DATA_UUID128_ALL, + 0x9e, 0xca, 0xdc, 0x24, 0x0e, 0xe5, 0xa9, 0xe0, + 0x93, 0xf3, 0xa3, 0xb5, 0x01, 0x00, 0x40, 0x6e), + }; + + CHK(bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), NULL, 0)); + LOG_INF("Peripheral advertising started as 'HID Remapper'"); +} + +static void handle_received_packet(const uint8_t* data, uint16_t len) { + if (len < sizeof(packet_t)) { + LOG_WRN("Packet too small: %d < %d", len, sizeof(packet_t)); + return; + } + + packet_t* msg = (packet_t*) data; + uint16_t payload_len = len - sizeof(packet_t); + + LOG_DBG("Packet validation: proto=%d (expect %d), len=%d, payload=%d, desc=%d, report_id=%d", + msg->protocol_version, PROTOCOL_VERSION, msg->len, payload_len, + msg->our_descriptor_number, msg->report_id); + + if ((msg->protocol_version != PROTOCOL_VERSION) || + (msg->len != payload_len) || + (payload_len > 64) || + (msg->our_descriptor_number >= NOUR_DESCRIPTORS) || + ((msg->report_id == 0) && (payload_len >= 64))) { + LOG_WRN("Invalid packet: proto=%d (expect %d), len=%d vs %d, desc=%d (max %d), report_id=%d", + msg->protocol_version, PROTOCOL_VERSION, msg->len, payload_len, + msg->our_descriptor_number, NOUR_DESCRIPTORS, msg->report_id); + return; + } + + // Handle descriptor change + if (msg->our_descriptor_number != our_descriptor_number) { + our_descriptor_number = msg->our_descriptor_number; + + // Signal that configuration needs to be updated + config_updated = true; + + LOG_INF("Descriptor number changed to %d", our_descriptor_number); + } + + // Pass the raw gamepad data with external report ID + // Don't prepend report_id since it's passed as external_report_id parameter + handle_received_report(msg->data, len, 0x0000, msg->report_id); + + LOG_DBG("Packet processed: proto=%d, desc=%d, report_id=%d, len=%d", + msg->protocol_version, msg->our_descriptor_number, msg->report_id, len); +} + +static void process_byte_with_framing(uint8_t c) { + bytes_read %= sizeof(packet_buffer); + + if (escaped) { + switch (c) { + case ESC_END: + packet_buffer[bytes_read++] = END; + break; + case ESC_ESC: + packet_buffer[bytes_read++] = ESC; + break; + default: + // this shouldn't happen + packet_buffer[bytes_read++] = c; + break; + } + escaped = false; + } else { + switch (c) { + case END: + if (bytes_read > 4) { + uint32_t crc = crc32(packet_buffer, bytes_read - 4); + // Read CRC in little-endian format (matches transmitter) + uint32_t received_crc = + (packet_buffer[bytes_read - 4] << 0) | + (packet_buffer[bytes_read - 3] << 8) | + (packet_buffer[bytes_read - 2] << 16) | + (packet_buffer[bytes_read - 1] << 24); + if (crc == received_crc) { + handle_received_packet(packet_buffer, bytes_read - 4); + LOG_DBG("Packet received successfully, CRC: 0x%08X", crc); + } else { + LOG_WRN("CRC error: expected 0x%08X, got 0x%08X", crc, received_crc); + } + } + bytes_read = 0; + break; + case ESC: + escaped = true; + break; + default: + packet_buffer[bytes_read++] = c; + break; } } } +static void process_peripheral_command(uint8_t* buf, int count) { + // Process each byte with SLIP-like framing + LOG_DBG("Received %d bytes from BLE", count); + for (int i = 0; i < count; i++) { + process_byte_with_framing(buf[i]); + } +} + static void connected(struct bt_conn* conn, uint8_t conn_err) { char addr[BT_ADDR_LE_STR_LEN]; @@ -402,8 +813,10 @@ static void connected(struct bt_conn* conn, uint8_t conn_err) { if (conn_err) { LOG_ERR("Failed to connect to %s (conn_err=%u).", addr, conn_err); - k_work_reschedule(&scan_start_work, K_MSEC(SCAN_DELAY_MS)); - + // Only restart scanning if we're in host mode + if (current_mode == MODE_HOST) { + k_work_reschedule(&scan_start_work, K_MSEC(SCAN_DELAY_MS)); + } return; } @@ -430,7 +843,10 @@ static void disconnected(struct bt_conn* conn, uint8_t reason) { count_connections(); - k_work_reschedule(&scan_start_work, K_MSEC(SCAN_DELAY_MS)); + // Only restart scanning if we're in host mode + if (current_mode == MODE_HOST) { + k_work_reschedule(&scan_start_work, K_MSEC(SCAN_DELAY_MS)); + } } static void security_changed(struct bt_conn* conn, bt_security_t level, enum bt_security_err err) { @@ -441,7 +857,10 @@ static void security_changed(struct bt_conn* conn, bt_security_t level, enum bt_ if (!err) { LOG_INF("%s, level=%u.", addr, level); peers_only = true; - gatt_discover(conn); + // Only start discovery if we're in host mode + if (current_mode == MODE_HOST) { + gatt_discover(conn); + } } else { LOG_ERR("security failed: %s, level=%u, err=%d", addr, level, err); } @@ -452,8 +871,15 @@ static void le_param_updated(struct bt_conn* conn, uint16_t interval, uint16_t l } static bool le_param_req(struct bt_conn* conn, struct bt_le_conn_param* param) { - LOG_INF("interval_min=%d, interval_max=%d, latency=%d, timeout=%d", param->interval_min, param->interval_max, param->latency, param->timeout); - param->interval_max = param->interval_min; + LOG_INF("interval_min=%d, interval_max=%d, latency=%d, timeout=%d", + param->interval_min, param->interval_max, param->latency, param->timeout); + + // Accept wider range of parameters for better compatibility + if (param->interval_min < 6) param->interval_min = 6; // Minimum 7.5ms + if (param->interval_max > 800) param->interval_max = 800; // Maximum 1000ms + if (param->timeout < 100) param->timeout = 100; // Minimum 1s timeout + if (param->timeout > 3200) param->timeout = 3200; // Maximum 32s timeout + return true; } @@ -495,10 +921,11 @@ static uint8_t hogp_notify_cb(struct bt_hogp* hogp, struct bt_hogp_rep_info* rep return BT_GATT_ITER_STOP; } - if (scanning) { + // Don't aggressively restart scanning if we're in peripheral mode + if (scanning && current_mode != MODE_PERIPHERAL) { scanning = false; // more reports can come in before we actually stop scanning; there's probably a scenario where this causes trouble though k_work_submit(&scan_stop_work); - } else { + } else if (current_mode != MODE_PERIPHERAL) { k_work_reschedule(&scan_start_work, K_MSEC(SCAN_DELAY_MS)); } @@ -713,11 +1140,21 @@ static bool do_send_report(uint8_t interface, const uint8_t* report_with_id, uin len--; } if (interface == 0) { - return CHK(hid_int_ep_write(hid_dev0, report_with_id, len, NULL)); + // Try to send immediately + if (CHK(hid_int_ep_write(hid_dev0, report_with_id, len, NULL))) { + return true; + } else { + // Failed to send, queue it + if (len > 0) { + queue_outgoing_report(report_with_id[0], (uint8_t*)(report_with_id + 1), len - 1); + } + return false; + } } if (interface == 1) { return CHK(hid_int_ep_write(hid_dev1, report_with_id, len, NULL)); } + return false; // Default case - interface not supported } static void button_init() { @@ -915,7 +1352,12 @@ int main() { parse_our_descriptor(); set_mapping_from_config(); - k_work_reschedule(&scan_start_work, K_MSEC(SCAN_DELAY_MS)); + // Start in the appropriate mode + if (current_mode == MODE_HOST) { + k_work_reschedule(&scan_start_work, K_MSEC(SCAN_DELAY_MS)); + } else { + peripheral_mode_init(); + } struct report_type incoming_report; struct descriptor_type incoming_descriptor; @@ -926,13 +1368,40 @@ int main() { bool get_report_response_pending = false; while (true) { - if (!process_pending && !k_msgq_get(&report_q, &incoming_report, K_NO_WAIT)) { - handle_received_report(incoming_report.data, incoming_report.len, (uint16_t) incoming_report.interface); - process_pending = true; + // Host mode functionality + if (current_mode == MODE_HOST) { + if (!process_pending && !k_msgq_get(&report_q, &incoming_report, K_NO_WAIT)) { + handle_received_report(incoming_report.data, incoming_report.len, (uint16_t) incoming_report.interface); + process_pending = true; + } + if (atomic_test_and_clear_bit(tick_pending, 0)) { + process_mapping(true); + process_pending = false; + } + + while (!k_msgq_get(&disconnected_q, &disconnected_item, K_NO_WAIT)) { + LOG_INF("device_disconnected_callback conn_idx=%d", disconnected_item.conn_idx); + device_disconnected_callback(disconnected_item.conn_idx); + } + + while (!k_msgq_get(&descriptor_q, &incoming_descriptor, K_NO_WAIT)) { + LOG_HEXDUMP_DBG(incoming_descriptor.data, incoming_descriptor.size, "incoming_descriptor"); + parse_descriptor(1, 1, incoming_descriptor.data, incoming_descriptor.size, incoming_descriptor.conn_idx << 8, 0); + } + + if (their_descriptor_updated) { + update_their_descriptor_derivates(); + their_descriptor_updated = false; + } } - if (atomic_test_and_clear_bit(tick_pending, 0)) { - process_mapping(true); - process_pending = false; + + // Peripheral mode functionality - process reports from BLE transmitter + if (current_mode == MODE_PERIPHERAL) { + // In peripheral mode, we also need to process periodic ticks for remapping + if (atomic_test_and_clear_bit(tick_pending, 0)) { + process_mapping(true); + process_pending = false; + } } if (!k_sem_take(&usb_sem0, K_NO_WAIT)) { if (!send_report(do_send_report)) { @@ -945,6 +1414,23 @@ int main() { } } + // Process queued outgoing reports when USB HID is ready + if ((or_items > 0) && (k_sem_take(&usb_sem0, K_NO_WAIT) == 0)) { + uint8_t report_with_id[65]; + report_with_id[0] = outgoing_reports[or_head].report_id; + memcpy(report_with_id + 1, outgoing_reports[or_head].data, outgoing_reports[or_head].len); + + if (do_send_report(0, report_with_id, outgoing_reports[or_head].len + 1)) { + // Successfully sent, remove from queue + // Semaphore will be released by USB callback + or_head = (or_head + 1) % OR_BUFSIZE; + or_items--; + } else { + // Failed to send, give back semaphore + k_sem_give(&usb_sem0); + } + } + if (!k_msgq_get(&set_report_q, &set_report_item, K_NO_WAIT)) { if (set_report_item.interface == 0) { handle_set_report0(set_report_item.report_id, set_report_item.data, set_report_item.len); diff --git a/transmitter-ble-web/code.js b/transmitter-ble-web/code.js new file mode 100644 index 00000000..a3934e9c --- /dev/null +++ b/transmitter-ble-web/code.js @@ -0,0 +1,490 @@ +import crc32 from './crc.js'; + +const dpad_lut = [15, 6, 2, 15, 0, 7, 1, 0, 4, 5, 3, 4, 15, 6, 2, 15]; + +// BLE Service and Characteristic UUIDs +// Using Nordic UART Service UUIDs as they're commonly supported +const UART_SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; +const UART_TX_CHARACTERISTIC_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'; // Write +const UART_RX_CHARACTERISTIC_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'; // Notify + +const END = 0o300; /* indicates end of packet */ +const ESC = 0o333; /* indicates byte stuffing */ +const ESC_END = 0o334; /* ESC ESC_END means END data byte */ +const ESC_ESC = 0o335; /* ESC ESC_ESC means ESC data byte */ + +// Virtual button states +let virtualButtons = { + a: false, + b: false, + x: false, + y: false, + l: false, + r: false, + zl: false, + zr: false, + minus: false, + plus: false, + home: false, + ls: false, + rs: false, + dpad_up: false, + dpad_down: false, + dpad_left: false, + dpad_right: false +}; + +// Virtual analog stick states +let virtualSticks = { + lx: 128, + ly: 128, + rx: 128, + ry: 128 +}; + +document.addEventListener("DOMContentLoaded", function () { + document.getElementById("connect_ble").addEventListener("click", connect_ble); + document.getElementById("disconnect_ble").addEventListener("click", disconnect_ble); + + // Add event handlers for all virtual buttons + const buttonMappings = { + 'button_a': 'a', + 'button_b': 'b', + 'button_x': 'x', + 'button_y': 'y', + 'button_l': 'l', + 'button_r': 'r', + 'button_zl': 'zl', + 'button_zr': 'zr', + 'button_minus': 'minus', + 'button_plus': 'plus', + 'button_home': 'home', + 'button_ls': 'ls', + 'button_rs': 'rs', + 'dpad_up': 'dpad_up', + 'dpad_down': 'dpad_down', + 'dpad_left': 'dpad_left', + 'dpad_right': 'dpad_right' + }; + + // Set up event handlers for each button + for (const [elementId, buttonKey] of Object.entries(buttonMappings)) { + const button = document.getElementById(elementId); + if (button) { + button.addEventListener("click", function(e) { + e.preventDefault(); + virtualButtons[buttonKey] = !virtualButtons[buttonKey]; // Toggle the state + + if (virtualButtons[buttonKey]) { + button.classList.add("pressed"); + } else { + button.classList.remove("pressed"); + } + }); + } + } + + // Add event handlers for analog stick sliders + const stickMappings = { + 'left_stick_x': 'lx', + 'left_stick_y': 'ly', + 'right_stick_x': 'rx', + 'right_stick_y': 'ry' + }; + + for (const [elementId, stickKey] of Object.entries(stickMappings)) { + const slider = document.getElementById(elementId); + if (slider) { + slider.addEventListener("input", function(e) { + virtualSticks[stickKey] = parseInt(e.target.value); + }); + } + } + + output = document.getElementById("output"); + setInterval(loop, 8); +}); + +// Check if Web Bluetooth is supported +if (!navigator.bluetooth) { + document.body.innerHTML = '

Web Bluetooth API not supported

Please use Chrome/Edge with HTTPS or enable experimental features.

'; +} + +let device = null; +let server = null; +let service = null; +let txCharacteristic = null; +let rxCharacteristic = null; +let prev_report = new Uint8Array([0, 0, 15, 0, 0, 0, 0, 0, 0]); +let output; + +async function connect_ble() { + try { + write("Searching for BLE devices...\n"); + + // Request a device with more flexible filtering + // This allows connecting to devices like "playAbility" that might not advertise the service UUID + device = await navigator.bluetooth.requestDevice({ + // Accept all devices but prefer those with UART service or specific names + acceptAllDevices: true, + optionalServices: [UART_SERVICE_UUID] + }); + + write(`Selected device: ${device.name || 'Unknown'}\n`); + + // Connect to GATT server + server = await device.gatt.connect(); + write("Connected to GATT server\n"); + + // Try to get the UART service + try { + service = await server.getPrimaryService(UART_SERVICE_UUID); + write("Found UART service\n"); + } catch (serviceError) { + write(`UART service not found: ${serviceError.message}\n`); + throw new Error("Device doesn't have Nordic UART Service. Please select a compatible device."); + } + + // Get the TX characteristic (for writing data to device) + try { + txCharacteristic = await service.getCharacteristic(UART_TX_CHARACTERISTIC_UUID); + write("Got TX characteristic\n"); + } catch (txError) { + write(`TX characteristic error: ${txError.message}\n`); + throw new Error("Cannot find TX characteristic"); + } + + // Get the RX characteristic (for reading data from device) + try { + rxCharacteristic = await service.getCharacteristic(UART_RX_CHARACTERISTIC_UUID); + await rxCharacteristic.startNotifications(); + rxCharacteristic.addEventListener('characteristicvaluechanged', handleNotification); + write("Got RX characteristic with notifications\n"); + } catch (rxError) { + write("RX characteristic not available (write-only mode)\n"); + // This is OK, the device might be write-only + } + + // Update UI + document.getElementById("connect_ble").style.display = "none"; + document.getElementById("disconnect_ble").style.display = "inline"; + + write("BLE connection established!\n\n"); + + } catch (error) { + write(`Error: ${error.message}\n`); + console.error('BLE connection error:', error); + + // Clean up on error + if (server && server.connected) { + server.disconnect(); + } + device = null; + server = null; + service = null; + txCharacteristic = null; + rxCharacteristic = null; + } +} + +async function disconnect_ble() { + try { + if (rxCharacteristic) { + await rxCharacteristic.stopNotifications(); + rxCharacteristic.removeEventListener('characteristicvaluechanged', handleNotification); + } + if (server && server.connected) { + server.disconnect(); + } + + device = null; + server = null; + service = null; + txCharacteristic = null; + rxCharacteristic = null; + + // Update UI + document.getElementById("connect_ble").style.display = "inline"; + document.getElementById("disconnect_ble").style.display = "none"; + + write("Disconnected from BLE device\n\n"); + + } catch (error) { + write(`Disconnect error: ${error.message}\n`); + console.error('BLE disconnect error:', error); + } +} + +function handleNotification(event) { + const value = event.target.value; + const decoder = new TextDecoder(); + const data = decoder.decode(value); + write(`Received: ${data}\n`); +} + +let transmit_buffer = []; + +function ble_write(c) { + transmit_buffer.push(c); +} + +async function flush() { + if (!txCharacteristic || transmit_buffer.length === 0) { + return; + } + + try { + // BLE characteristics typically have a 20-byte MTU limit + const MTU_SIZE = 20; + const data = new Uint8Array(transmit_buffer); + + // Send data in chunks if it exceeds MTU + for (let i = 0; i < data.length; i += MTU_SIZE) { + const chunk = data.slice(i, i + MTU_SIZE); + await txCharacteristic.writeValue(chunk); + } + + transmit_buffer = []; + } catch (error) { + write(`Write error: ${error.message}\n`); + console.error('BLE write error:', error); + } +} + +function send_escaped_byte(b) { + switch (b) { + case END: + ble_write(ESC); + ble_write(ESC_END); + break; + + case ESC: + ble_write(ESC); + ble_write(ESC_ESC); + break; + + default: + ble_write(b); + } +} + +async function send_report(report) { + if (!server || !server.connected || !txCharacteristic) { + return; + } + + let data = new Uint8Array(4 + 8 + 4); + data[0] = 1; // protocol version + data[1] = 2; // descriptor number + data[2] = 8; // length + data[3] = 1; // report_id (now 1 to match virtual gamepad descriptor) + data.set(report, 4); + const crc = crc32(new DataView(data.buffer), 12); + data[12] = (crc >> 0) & 0xFF; + data[13] = (crc >> 8) & 0xFF; + data[14] = (crc >> 16) & 0xFF; + data[15] = (crc >> 24) & 0xFF; + + ble_write(END); + for (let i = 0; i < 16; i++) { + send_escaped_byte(data[i]); + } + ble_write(END); + await flush(); +} + +async function loop() { + try { + clear_output(); + if (server && server.connected) { + write(`BLE CONNECTED (${device.name || 'Unknown'})\n\n`); + } else { + write("BLE NOT CONNECTED\n\n"); + } + + let b = false; + let a = false; + let y = false; + let x = false; + let l = false; + let r = false; + let zl = false; + let zr = false; + let minus = false; + let plus = false; + let ls = false; + let rs = false; + let home = false; + let capture = false; + let dpad_left = false; + let dpad_right = false; + let dpad_up = false; + let dpad_down = false; + let lx = 128; + let ly = 128; + let rx = 128; + let ry = 128; + + // Process physical gamepads + for (const gamepad of navigator.getGamepads()) { + if (!gamepad) { + continue; + } + write(gamepad.id); + write("\n"); + if ((gamepad.mapping == 'standard') && !gamepad.id.includes('HID Receiver')) { + for (const b of gamepad.buttons) { + write(b.value); + write(" "); + } + for (const b of gamepad.axes) { + write(b); + write(" "); + } + write("\n"); + b |= gamepad.buttons[0].pressed; + a |= gamepad.buttons[1].pressed; + y |= gamepad.buttons[2].pressed; + x |= gamepad.buttons[3].pressed; + l |= gamepad.buttons[4].pressed; + r |= gamepad.buttons[5].pressed; + zl |= gamepad.buttons[6].value > 0.25; + zr |= gamepad.buttons[7].value > 0.25; + minus |= gamepad.buttons[8].pressed; + plus |= gamepad.buttons[9].pressed; + ls |= gamepad.buttons[10].pressed; + rs |= gamepad.buttons[11].pressed; + home |= gamepad.buttons[16].pressed; + dpad_up |= gamepad.buttons[12].pressed; + dpad_down |= gamepad.buttons[13].pressed; + dpad_left |= gamepad.buttons[14].pressed; + dpad_right |= gamepad.buttons[15].pressed; + lx = Math.max(0, Math.min(255, 128 + gamepad.axes[0] * 128)); + ly = Math.max(0, Math.min(255, 128 + gamepad.axes[1] * 128)); + rx = Math.max(0, Math.min(255, 128 + gamepad.axes[2] * 128)); + ry = Math.max(0, Math.min(255, 128 + gamepad.axes[3] * 128)); + } else { + write("IGNORED\n"); + } + write("\n"); + } + + // Include virtual button states + a |= virtualButtons.a; + b |= virtualButtons.b; + x |= virtualButtons.x; + y |= virtualButtons.y; + l |= virtualButtons.l; + r |= virtualButtons.r; + zl |= virtualButtons.zl; + zr |= virtualButtons.zr; + minus |= virtualButtons.minus; + plus |= virtualButtons.plus; + home |= virtualButtons.home; + ls |= virtualButtons.ls; + rs |= virtualButtons.rs; + dpad_up |= virtualButtons.dpad_up; + dpad_down |= virtualButtons.dpad_down; + dpad_left |= virtualButtons.dpad_left; + dpad_right |= virtualButtons.dpad_right; + + // Include virtual analog stick values (only if no physical gamepad is moving them) + if (navigator.getGamepads().every(gamepad => !gamepad || gamepad.mapping !== 'standard' || gamepad.id.includes('HID Receiver'))) { + lx = virtualSticks.lx; + ly = virtualSticks.ly; + rx = virtualSticks.rx; + ry = virtualSticks.ry; + } + + // Show virtual button status + if (virtualButtons.a) { + write("VIRTUAL: Button A PRESSED\n"); + } + if (virtualButtons.b) { + write("VIRTUAL: Button B PRESSED\n"); + } + if (virtualButtons.x) { + write("VIRTUAL: Button X PRESSED\n"); + } + if (virtualButtons.y) { + write("VIRTUAL: Button Y PRESSED\n"); + } + if (virtualButtons.l) { + write("VIRTUAL: Button L PRESSED\n"); + } + if (virtualButtons.r) { + write("VIRTUAL: Button R PRESSED\n"); + } + if (virtualButtons.zl) { + write("VIRTUAL: Button ZL PRESSED\n"); + } + if (virtualButtons.zr) { + write("VIRTUAL: Button ZR PRESSED\n"); + } + if (virtualButtons.minus) { + write("VIRTUAL: Button MINUS PRESSED\n"); + } + if (virtualButtons.plus) { + write("VIRTUAL: Button PLUS PRESSED\n"); + } + if (virtualButtons.home) { + write("VIRTUAL: Button HOME PRESSED\n"); + } + if (virtualButtons.dpad_up) { + write("VIRTUAL: D-Pad UP PRESSED\n"); + } + if (virtualButtons.dpad_down) { + write("VIRTUAL: D-Pad DOWN PRESSED\n"); + } + if (virtualButtons.dpad_left) { + write("VIRTUAL: D-Pad LEFT PRESSED\n"); + } + if (virtualButtons.dpad_right) { + write("VIRTUAL: D-Pad RIGHT PRESSED\n"); + } + + let report = new Uint8Array(8); + + report[0] = (y << 0) | (b << 1) | (a << 2) | (x << 3) | (l << 4) | (r << 5) | (zl << 6) | (zr << 7); + report[1] = (minus << 0) | (plus << 1) | (ls << 2) | (rs << 3) | (home << 4) | (capture << 5); + report[2] = dpad_lut[(dpad_left << 0) | (dpad_right << 1) | (dpad_up << 2) | (dpad_down << 3)]; + report[3] = Math.max(0, Math.min(255, lx)); + report[4] = Math.max(0, Math.min(255, ly)); + report[5] = Math.max(0, Math.min(255, rx)); + report[6] = Math.max(0, Math.min(255, ry)); + + write("OUTPUT\n"); + for (let i = 0; i < 8; i++) { + write(report[i].toString(16).padStart(2, '0')); + write(" "); + } + write("\n"); + + if (!reports_equal(prev_report, report)) { + await send_report(report); + prev_report = report; + } + } catch (e) { + console.log(e); + } +} + +function write(s) { + output.innerText += s; +} + +function clear_output() { + output.innerHTML = ''; +} + +function reports_equal(a, b) { + if (a.length != b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; +} \ No newline at end of file diff --git a/transmitter-ble-web/crc.js b/transmitter-ble-web/crc.js new file mode 100644 index 00000000..61ae74ba --- /dev/null +++ b/transmitter-ble-web/crc.js @@ -0,0 +1,53 @@ +const crc_table = [ + 0x0, 0x77073096, 0xEE0E612C, 0x990951BA, 0x76DC419, 0x706AF48F, 0xE963A535, + 0x9E6495A3, 0xEDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, 0x9B64C2B, + 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, 0x1DB71064, 0x6AB020F2, 0xF3B97148, + 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, 0x136C9856, + 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, 0xFA0F3D63, + 0x8D080DF5, 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, 0x3C03E4D1, + 0x4B04D447, 0xD20D85FD, 0xA50AB56B, 0x35B5A8FA, 0x42B2986C, 0xDBBBC9D6, + 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, 0x26D930AC, + 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, 0xCFBA9599, + 0xB8BDA50F, 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, 0x2F6F7C87, + 0x58684C11, 0xC1611DAB, 0xB6662D3D, 0x76DC4190, 0x1DB7106, 0x98D220BC, + 0xEFD5102A, 0x71B18589, 0x6B6B51F, 0x9FBFE4A5, 0xE8B8D433, 0x7807C9A2, + 0xF00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x86D3D2D, 0x91646C97, + 0xE6635C01, 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, 0x6C0695ED, + 0x1B01A57B, 0x8208F4C1, 0xF50FC457, 0x65B0D9C6, 0x12B7E950, 0x8BBEB8EA, + 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, 0x4DB26158, + 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, 0xA4D1C46D, + 0xD3D6F4FB, 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, 0x44042D73, + 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, 0x5005713C, 0x270241AA, 0xBE0B1010, + 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, 0x5EDEF90E, + 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, 0xB7BD5C3B, + 0xC0BA6CAD, 0xEDB88320, 0x9ABFB3B6, 0x3B6E20C, 0x74B1D29A, 0xEAD54739, + 0x9DD277AF, 0x4DB2615, 0x73DC1683, 0xE3630B12, 0x94643B84, 0xD6D6A3E, + 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0xA00AE27, 0x7D079EB1, 0xF00F9344, + 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, 0x196C3671, + 0x6E6B06E7, 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, 0xF9B9DF6F, + 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, 0xD6D6A3E8, 0xA1D1937E, 0x38D8C2C4, + 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, 0xD80D2BDA, + 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, 0x316E8EEF, + 0x4669BE79, 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, 0xCC0C7795, + 0xBB0B4703, 0x220216B9, 0x5505262F, 0xC5BA3BBE, 0xB2BD0B28, 0x2BB45A92, + 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, 0x9B64C2B0, + 0xEC63F226, 0x756AA39C, 0x26D930A, 0x9C0906A9, 0xEB0E363F, 0x72076785, + 0x5005713, 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0xCB61B38, 0x92D28E9B, + 0xE5D5BE0D, 0x7CDCEFB7, 0xBDBDF21, 0x86D3D2D4, 0xF1D4E242, 0x68DDB3F8, + 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, 0x88085AE6, + 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, 0x616BFFD3, + 0x166CCF45, 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, 0xA7672661, + 0xD06016F7, 0x4969474D, 0x3E6E77DB, 0xAED16A4A, 0xD9D65ADC, 0x40DF0B66, + 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, 0xBDBDF21C, + 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, 0x54DE5729, + 0x23D967BF, 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, 0xB40BBE37, + 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D +]; + +export default function crc32(buf, length) { + let c = 0xffffffff; + for (let n = 0; n < length; n++) { + c = crc_table[(c ^ buf.getUint8(n)) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} diff --git a/transmitter-ble-web/index.html b/transmitter-ble-web/index.html new file mode 100644 index 00000000..5f088504 --- /dev/null +++ b/transmitter-ble-web/index.html @@ -0,0 +1,166 @@ + + + + + + + + + +

HID Transmitter (BLE) - Switch Gamepad

+

+ + +

+ +
+
+

Face Buttons

+
+
+ + + +
+ +
+
+ +
+

D-Pad

+
+
+ +
+ +
+ +
+ +
+
+
+ +
+

Shoulder/System

+
+ + + + +
+
+ + + +
+
+
+ +
+
+

Analog Sticks

+
+
Left Stick
+ + +
+ +
+
+ +
+

Right Stick

+
+
Right Stick
+ + +
+ +
+
+ +
+

Instructions

+

Virtual buttons work in toggle mode - click to turn on/off.

+

Physical gamepads are also supported.

+

Note: Web Bluetooth requires HTTPS in Chrome.

+
+
+
+    
+ + + \ No newline at end of file