diff --git a/.gitignore b/.gitignore index bed4514..2e9602c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,35 @@ venv/ # Docs docs/node_modules/ docs/dist/ + +# ZMK / Zephyr build artifacts +firmware/.west/ +firmware/build/ +firmware/zephyr/ +firmware/modules/ +firmware/optional/ +firmware/zmk-firmware/ +firmware/.config +firmware/*.tar.xz +firmware/*.7z +firmware/zmk/app/ +firmware/zmk/docs/ +firmware/zmk/schema/ +firmware/zmk/.clang-format +firmware/zmk/.devcontainer/ +firmware/zmk/.gitattributes +firmware/zmk/.github/ +firmware/zmk/.gitignore +firmware/zmk/.gitlint +firmware/zmk/.pre-commit-config.yaml +firmware/zmk/.release-please-manifest.json +firmware/zmk/*.md +firmware/zmk/AUTHORS +firmware/zmk/CODEOWNERS +firmware/zmk/LICENSE +firmware/zmk/release-please* +zmk-workspace/ + +# Zephyr SDK +gnu-arm-embedded/ +zephyr-sdk/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d0f2a18 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# RelayKeys Agents Documentation + +## Current State: ZMK Bridge Integration (Phase 1) + +**Overview** +The codebase has been updated to support a pure software-driven nRF52840 BLE HID bridge using ZMK firmware, replacing the legacy hardware-mod approach. The host side is managed by the RelayKeys Go daemon. + +**Completed Work** +1. **ZMK Firmware Structure**: A `firmware/zmk` directory was added containing `west.yml`, a dummy `relaykeys_dongle` shield (bypassing hardware matrix scanning), and `relaykeys_handler.c` which implements a ZMK Studio RPC subsystem to receive binary payloads and inject HID reports over the BLE endpoint. +2. **Protobuf Integration**: Defined `relaykeys.proto` under `internal/zmk/proto` and generated Go bindings for communication between the Go daemon and ZMK firmware. +3. **Go Transport Layer**: Added `internal/zmkbridge` which frames Protobuf payloads using ZMK's SLIP/COBS-like framing (0xAB, 0xAC, 0xAD) and writes raw bytes to the serial interface. +4. **Configuration**: Added `FirmwareType` to `config.go` and `rpc/server.go`, allowing backward compatibility with `legacy` AT commands or switching to the new `zmk` protocol. + +## Next Steps (Phase 2 & Beyond) + +* **Implement Mouse & Consumer Reports in Firmware**: Currently `relaykeys_handler.c` only constructs and injects Keyboard reports (`zmk_hid_keyboard_report_body`). It needs to be extended to support Mouse and Consumer (media) reports based on the type enum from the protobuf definition. +* **Implement Mouse/Media Send Functions in Go**: Implement `SendMouseMove`, `SendMouseButton` and other functionalities inside `internal/zmkbridge/bridge.go` to wrap and send correct Protobuf messages. +* **Native ZMK Administration**: Map BLE bonding and profile status from ZMK back to the Go daemon state. Add protobuf RPC definitions for getting connected devices, clearing slots, and switching devices (e.g. implementing `ProcessBleCmd` completely). Update the Web UI to show which of the 5 ZMK slots are occupied and which devices are connected. +* **Compile Firmware**: Test the standard ZMK build locally or via GitHub actions for the `relaykeys_dongle` shield. + +*When modifying the ZMK handler code, make sure to consider `nanopb` struct generation if new fields are added.* diff --git a/cmd/relaykeys-daemon/main.go b/cmd/relaykeys-daemon/main.go index 5fb0dcf..d37ac01 100644 --- a/cmd/relaykeys-daemon/main.go +++ b/cmd/relaykeys-daemon/main.go @@ -87,7 +87,7 @@ func runDaemon(cfg *config.Config) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - rpcServer := rpc.NewServerWithConfig(nil, cfg.Username, cfg.Password) + rpcServer := rpc.NewServerWithConfig(nil, cfg.Username, cfg.Password, cfg.FirmwareType) exePath, _ := os.Executable() exeDir := filepath.Dir(exePath) @@ -177,11 +177,17 @@ func connectSerial(ctx context.Context, cfg *config.Config, rpcServer *rpc.Serve } } - if err := hwPort.Init(); err != nil { - log.Printf("Serial init warning: %v", err) + if cfg.FirmwareType != "zmk" { + if err := hwPort.Init(); err != nil { + log.Printf("Serial init warning: %v", err) + } + } else { + hwPort.Flush() } - if err := blehid.InitSerial(hwPort); err != nil { - log.Printf("BLE HID init warning: %v", err) + if cfg.FirmwareType != "zmk" { + if err := blehid.InitSerial(hwPort); err != nil { + log.Printf("BLE HID init warning: %v", err) + } } log.Println("Serial device connected") diff --git a/firmware/zmk/boards/shields/relaykeys_dongle/CMakeLists.txt b/firmware/zmk/boards/shields/relaykeys_dongle/CMakeLists.txt new file mode 100644 index 0000000..e24b920 --- /dev/null +++ b/firmware/zmk/boards/shields/relaykeys_dongle/CMakeLists.txt @@ -0,0 +1,3 @@ +zephyr_library() +zephyr_library_sources(relaykeys_handler.c) +zephyr_library_include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../../../include) diff --git a/firmware/zmk/boards/shields/relaykeys_dongle/Kconfig.shield b/firmware/zmk/boards/shields/relaykeys_dongle/Kconfig.shield new file mode 100644 index 0000000..f50e625 --- /dev/null +++ b/firmware/zmk/boards/shields/relaykeys_dongle/Kconfig.shield @@ -0,0 +1,2 @@ +config SHIELD_RELAYKEYS_DONGLE + def_bool $(shields_list_contains,relaykeys_dongle) diff --git a/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.conf b/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.conf new file mode 100644 index 0000000..ec168c6 --- /dev/null +++ b/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.conf @@ -0,0 +1,9 @@ +CONFIG_SERIAL=y +CONFIG_UART_INTERRUPT_DRIVEN=y +CONFIG_USB_DEVICE_STACK=y +CONFIG_USB_CDC_ACM=y +CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=y +CONFIG_UART_LINE_CTRL=y +CONFIG_ZMK_BLE=y +CONFIG_ZMK_POINTING=y +CONFIG_ZMK_HID_CONSUMER_REPORT_USAGES_FULL=y diff --git a/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.keymap b/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.keymap new file mode 100644 index 0000000..fa02342 --- /dev/null +++ b/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.keymap @@ -0,0 +1,15 @@ +#include +#include +#include + +/ { + keymap { + compatible = "zmk,keymap"; + + default_layer { + bindings = < + &none + >; + }; + }; +}; diff --git a/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.overlay b/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.overlay new file mode 100644 index 0000000..bcd7c0f --- /dev/null +++ b/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_dongle.overlay @@ -0,0 +1,27 @@ +/ { + chosen { + zmk,kscan = &kscan0; + zmk,matrix_transform = &default_transform; + relaykeys,uart = &relaykeys_cdc; + }; + + default_transform: keymap_transform_0 { + compatible = "zmk,matrix-transform"; + columns = <1>; + rows = <1>; + map = <0>; + }; + + kscan0: kscan_0 { + compatible = "zmk,kscan-mock"; + columns = <1>; + rows = <1>; + events = <>; + }; +}; + +&zephyr_udc0 { + relaykeys_cdc: relaykeys_cdc { + compatible = "zephyr,cdc-acm-uart"; + }; +}; diff --git a/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_handler.c b/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_handler.c new file mode 100644 index 0000000..9f5602e --- /dev/null +++ b/firmware/zmk/boards/shields/relaykeys_dongle/relaykeys_handler.c @@ -0,0 +1,335 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +LOG_MODULE_REGISTER(relaykeys, LOG_LEVEL_INF); + +#define SOF_BYTE 0xAB +#define ESC_BYTE 0xAC +#define EOF_BYTE 0xAD + +#define MAX_FRAME 128 +#define RING_SIZE 512 + +static const struct device *uart_dev; +static uint8_t ring_buf[RING_SIZE]; +static volatile size_t ring_head; +static volatile size_t ring_tail; +static uint8_t frame_buf[MAX_FRAME]; +static size_t frame_len; +static bool in_frame; +static bool esc_next; + +K_MUTEX_DEFINE(tx_mutex); + +static void uart_isr(const struct device *dev, void *ctx) { + while (uart_irq_update(dev) && uart_irq_is_pending(dev)) { + if (!uart_irq_rx_ready(dev)) { + break; + } + uint8_t tmp[64]; + int n = uart_fifo_read(dev, tmp, sizeof(tmp)); + for (int i = 0; i < n; i++) { + size_t next = (ring_head + 1) % RING_SIZE; + if (next != ring_tail) { + ring_buf[ring_head] = tmp[i]; + ring_head = next; + } + } + } +} + +static int read_varint(const uint8_t *buf, size_t len, uint32_t *val) { + *val = 0; + int i = 0; + while (i < 5 && (size_t)i < len) { + *val |= (uint32_t)(buf[i] & 0x7F) << (7 * i); + if (!(buf[i] & 0x80)) { + return i + 1; + } + i++; + } + return -1; +} + +static bool decode_report(const uint8_t *data, size_t len, int32_t *type, + uint8_t *payload, size_t *payload_len) { + size_t pos = 0; + *type = 0; + *payload_len = 0; + + while (pos < len) { + uint32_t tag; + int n = read_varint(data + pos, len - pos, &tag); + if (n <= 0) return false; + pos += n; + int field = tag >> 3; + int wt = tag & 7; + + if (field == 1 && wt == 0) { + uint32_t v; + n = read_varint(data + pos, len - pos, &v); + if (n <= 0) return false; + *type = (int32_t)v; + pos += n; + } else if (field == 2 && wt == 2) { + uint32_t dlen; + n = read_varint(data + pos, len - pos, &dlen); + if (n <= 0) return false; + pos += n; + if (pos + dlen > len || dlen > 16) return false; + memcpy(payload, data + pos, dlen); + *payload_len = dlen; + pos += dlen; + } else { + if (wt == 0) { + uint32_t v; + n = read_varint(data + pos, len - pos, &v); + if (n <= 0) return false; + pos += n; + } else if (wt == 2) { + uint32_t l; + n = read_varint(data + pos, len - pos, &l); + if (n <= 0) return false; + pos += n + l; + } else { + return false; + } + } + } + return true; +} + +static bool decode_admin(const uint8_t *data, size_t len, int32_t *cmd, int32_t *slot) { + size_t pos = 0; + *cmd = 0; + *slot = 0; + + while (pos < len) { + uint32_t tag; + int n = read_varint(data + pos, len - pos, &tag); + if (n <= 0) return false; + pos += n; + int field = tag >> 3; + int wt = tag & 7; + + if (wt == 0) { + uint32_t v; + n = read_varint(data + pos, len - pos, &v); + if (n <= 0) return false; + if (field == 1) *cmd = (int32_t)v; + else if (field == 2) *slot = (int32_t)v; + pos += n; + } else if (wt == 2) { + uint32_t l; + n = read_varint(data + pos, len - pos, &l); + if (n <= 0) return false; + pos += n + l; + } + } + return true; +} + +static size_t encode_varint(uint8_t *buf, uint32_t val) { + size_t i = 0; + while (val >= 0x80) { + buf[i++] = (val & 0x7F) | 0x80; + val >>= 7; + } + buf[i++] = val & 0x7F; + return i; +} + +static size_t encode_admin_response(bool success, int32_t active_slot, uint8_t *buf, size_t max) { + size_t pos = 0; + buf[pos++] = 0x08; + buf[pos++] = success ? 0x01 : 0x00; + if (active_slot >= 0) { + buf[pos++] = 0x18; + pos += encode_varint(buf + pos, (uint32_t)active_slot); + } + return pos; +} + +static void send_framed(const uint8_t *data, size_t len) { + k_mutex_lock(&tx_mutex, K_FOREVER); + uart_poll_out(uart_dev, SOF_BYTE); + for (size_t i = 0; i < len; i++) { + if (data[i] == SOF_BYTE || data[i] == ESC_BYTE || data[i] == EOF_BYTE) { + uart_poll_out(uart_dev, ESC_BYTE); + } + uart_poll_out(uart_dev, data[i]); + } + uart_poll_out(uart_dev, EOF_BYTE); + k_mutex_unlock(&tx_mutex); +} + +static void handle_inject_report(int32_t type, const uint8_t *data, size_t data_len) { + if (type == 0) { + struct zmk_hid_keyboard_report *rpt = zmk_hid_get_keyboard_report(); + if (data_len > 0) { + rpt->body.modifiers = data[0]; + } + for (int i = 0; i < CONFIG_ZMK_HID_KEYBOARD_REPORT_SIZE; i++) { + rpt->body.keys[i] = ((size_t)(i + 2) < data_len) ? data[i + 2] : 0; + } + zmk_endpoint_send_report(HID_USAGE_KEY); + } else if (type == 1) { +#if IS_ENABLED(CONFIG_ZMK_POINTING) + struct zmk_hid_mouse_report *rpt = zmk_hid_get_mouse_report(); + if (data_len >= 1) rpt->body.buttons = data[0]; + rpt->body.d_x = (data_len >= 3) ? (int16_t)(int8_t)data[1] : 0; + rpt->body.d_y = (data_len >= 3) ? (int16_t)(int8_t)data[2] : 0; + rpt->body.d_scroll_y = (data_len >= 5) ? (int16_t)(int8_t)data[3] : 0; + rpt->body.d_scroll_x = (data_len >= 5) ? (int16_t)(int8_t)data[4] : 0; + zmk_endpoint_send_mouse_report(); +#endif + } else if (type == 2) { + struct zmk_hid_consumer_report *rpt = zmk_hid_get_consumer_report(); + if (data_len >= 2) { + uint16_t usage = data[0] | ((uint16_t)data[1] << 8); + rpt->body.keys[0] = usage; + for (int i = 1; i < CONFIG_ZMK_HID_CONSUMER_REPORT_SIZE; i++) { + rpt->body.keys[i] = 0; + } + } + zmk_endpoint_send_report(HID_USAGE_CONSUMER); + } +} + +static void handle_admin_command(int32_t cmd, int32_t slot) { + uint8_t resp[32]; + size_t resp_len; + + switch (cmd) { + case 0: + zmk_ble_clear_bonds(); + break; + case 1: + if (slot >= 0 && slot < 5) { + zmk_ble_prof_select((uint8_t)slot); + } + break; + case 2: + break; + case 3: + resp_len = encode_admin_response(true, -1, resp, sizeof(resp)); + send_framed(resp, resp_len); + k_sleep(K_MSEC(100)); + sys_reboot(SYS_REBOOT_WARM); + return; + case 4: + if (slot >= 0 && slot < 5) { + int current = zmk_ble_active_profile_index(); + zmk_ble_prof_select((uint8_t)slot); + zmk_ble_clear_bonds(); + zmk_ble_prof_select((uint8_t)current); + } + break; + } + + int32_t active = zmk_ble_active_profile_index(); + resp_len = encode_admin_response(true, active, resp, sizeof(resp)); + send_framed(resp, resp_len); +} + +static void process_frame(const uint8_t *data, size_t len) { + if (len < 2) return; + + uint32_t tag; + int n = read_varint(data, len, &tag); + if (n <= 0) return; + int field = tag >> 3; + int wt = tag & 7; + + if (field != 1 || wt != 0) return; + + uint32_t type_val; + int n2 = read_varint(data + n, len - n, &type_val); + if (n2 <= 0) return; + size_t pos = (size_t)(n + n2); + if (pos >= len) return; + + uint32_t tag2; + int n3 = read_varint(data + pos, len - pos, &tag2); + if (n3 <= 0) return; + int wt2 = tag2 & 7; + + if (wt2 == 2) { + int32_t type; + uint8_t payload[16]; + size_t payload_len; + if (decode_report(data, len, &type, payload, &payload_len)) { + handle_inject_report(type, payload, payload_len); + } + } else if (wt2 == 0) { + int32_t cmd, slot; + if (decode_admin(data, len, &cmd, &slot)) { + handle_admin_command(cmd, slot); + } + } +} + +static void relaykeys_thread(void) { + uart_dev = DEVICE_DT_GET(DT_CHOSEN(relaykeys_uart)); + if (!device_is_ready(uart_dev)) { + LOG_ERR("RelayKeys UART not ready"); + return; + } + + LOG_INF("RelayKeys UART ready, waiting for USB..."); + k_sleep(K_SECONDS(3)); + + uart_irq_callback_set(uart_dev, uart_isr); + uart_irq_rx_enable(uart_dev); + + LOG_INF("RelayKeys listening"); + in_frame = false; + esc_next = false; + frame_len = 0; + + for (;;) { + if (ring_tail == ring_head) { + k_sleep(K_MSEC(1)); + continue; + } + + uint8_t b = ring_buf[ring_tail]; + ring_tail = (ring_tail + 1) % RING_SIZE; + + if (esc_next) { + esc_next = false; + if (in_frame && frame_len < MAX_FRAME) { + frame_buf[frame_len++] = b; + } + continue; + } + + if (b == SOF_BYTE) { + in_frame = true; + frame_len = 0; + } else if (b == EOF_BYTE && in_frame) { + if (frame_len > 0) { + process_frame(frame_buf, frame_len); + } + in_frame = false; + frame_len = 0; + } else if (b == ESC_BYTE && in_frame) { + esc_next = true; + } else if (in_frame && frame_len < MAX_FRAME) { + frame_buf[frame_len++] = b; + } + } +} + +K_THREAD_DEFINE(relaykeys_tid, 1024, relaykeys_thread, NULL, NULL, NULL, + K_LOWEST_APPLICATION_THREAD_PRIO, 0, 0); diff --git a/firmware/zmk/west.yml b/firmware/zmk/west.yml new file mode 100644 index 0000000..c21afda --- /dev/null +++ b/firmware/zmk/west.yml @@ -0,0 +1,12 @@ +manifest: + remotes: + - name: zmkfirmware + url-base: https://github.com/zmkfirmware + projects: + - name: zmk + path: zmk-firmware + remote: zmkfirmware + revision: main + import: app/west.yml + self: + path: zmk diff --git a/go.mod b/go.mod index b75465c..41e738b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.0 require ( github.com/atotto/clipboard v0.1.4 + github.com/getlantern/systray v1.2.2 github.com/gorilla/websocket v1.5.3 github.com/kardianos/service v1.2.4 go.bug.st/serial v1.6.4 @@ -11,16 +12,14 @@ require ( require ( github.com/creack/goselect v0.1.2 // indirect - github.com/energye/systray v1.0.3 // indirect github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect - github.com/getlantern/systray v1.2.2 // indirect github.com/go-stack/stack v1.8.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect golang.org/x/sys v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index e96ed19..ed06fca 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglD github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/energye/systray v1.0.3 h1:XnyjJCeRU5z00bpNOic2fGTKz/7yHZMZjWiGIVXDS+4= -github.com/energye/systray v1.0.3/go.mod h1:HelKhC3PXwv3ryDxbuQqV+7kAxAYNzE5cfdrerGOZTc= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= @@ -23,8 +21,6 @@ github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sTho github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= @@ -46,6 +42,8 @@ golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/blehid/keymap.go b/internal/blehid/keymap.go index e5dfa54..3a15330 100644 --- a/internal/blehid/keymap.go +++ b/internal/blehid/keymap.go @@ -125,5 +125,7 @@ const MaxMouseMove = 2500 type Port interface { WriteAT(cmd string) (string, error) WriteATNoResponse(cmd string) error + WriteRaw(data []byte) error + ReadRaw(length int) ([]byte, error) Flush() } diff --git a/internal/config/config.go b/internal/config/config.go index 2025b5a..5448f24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { ClientPort int KeymapFile string Delay int + FirmwareType string } func Load(path string) (*Config, error) { @@ -31,6 +32,7 @@ func Load(path string) (*Config, error) { Baud: 115200, ClientHost: "127.0.0.1", ClientPort: 5383, + FirmwareType: "legacy", } home, _ := os.UserHomeDir() @@ -104,6 +106,8 @@ func parseINI(data []byte, cfg *Config) { cfg.Password = val case "debug": cfg.Debug = val == "true" || val == "1" + case "firmware_type": + cfg.FirmwareType = val case "noserial": cfg.NoSerial = val == "true" || val == "1" case "logfile": diff --git a/internal/rpc/server.go b/internal/rpc/server.go index 20b7274..0e106cf 100644 --- a/internal/rpc/server.go +++ b/internal/rpc/server.go @@ -14,6 +14,7 @@ import ( "sync" "github.com/acecentre/relaykeys/internal/blehid" + "github.com/acecentre/relaykeys/internal/zmkbridge" ) type Server struct { @@ -25,8 +26,9 @@ type Server struct { } type serverConfig struct { - Username string - Password string + Username string + Password string + FirmwareType string } type jsonRPCRequest struct { @@ -48,12 +50,13 @@ type jsonRPCError struct { Message string `json:"message"` } -func NewServerWithConfig(port blehid.Port, username, password string) *Server { +func NewServerWithConfig(port blehid.Port, username, password, firmwareType string) *Server { return &Server{ port: port, cfg: serverConfig{ - Username: username, - Password: password, + Username: username, + Password: password, + FirmwareType: firmwareType, }, } } @@ -222,7 +225,11 @@ func (s *Server) handleKeyevent(rawParams json.RawMessage) interface{} { } down, _ := args[2].(bool) - if err := blehid.SendKeyboardCode(s.port, key, mods, down, &s.keys); err != nil { + if s.cfg.FirmwareType == "zmk" { + if err := zmkbridge.SendKeyboardCode(s.port, key, mods, down, &s.keys); err != nil { + return "FAIL" + } + } else if err := blehid.SendKeyboardCode(s.port, key, mods, down, &s.keys); err != nil { return "FAIL" } return "SUCCESS" @@ -247,7 +254,11 @@ func (s *Server) handleMousemove(rawParams json.RawMessage) interface{} { if len(args) > 3 { wheelx = toInt(args[3]) } - if err := blehid.SendMouseMove(s.port, right, down, wheely, wheelx); err != nil { + if s.cfg.FirmwareType == "zmk" { + if err := zmkbridge.SendMouseMove(s.port, right, down, wheely, wheelx); err != nil { + return "FAIL" + } + } else if err := blehid.SendMouseMove(s.port, right, down, wheely, wheelx); err != nil { return "FAIL" } return "SUCCESS" @@ -267,7 +278,11 @@ func (s *Server) handleMousebutton(rawParams json.RawMessage) interface{} { if len(args) > 1 { behavior, _ = args[1].(string) } - if err := blehid.SendMouseButton(s.port, btn, behavior); err != nil { + if s.cfg.FirmwareType == "zmk" { + if err := zmkbridge.SendMouseButton(s.port, btn, behavior); err != nil { + return "FAIL" + } + } else if err := blehid.SendMouseButton(s.port, btn, behavior); err != nil { return "FAIL" } return "SUCCESS" @@ -296,8 +311,17 @@ func (s *Server) handleDaemon(rawParams json.RawMessage) interface{} { cmd, _ := params[0][0].(string) switch cmd { case "get_mode": + if s.cfg.FirmwareType == "zmk" { + return "Hardware serial (ZMK)" + } return "Hardware serial" case "dongle_status": + if s.cfg.FirmwareType == "zmk" { + if s.port == nil { + return "No connection" + } + return "Connected" + } resp, err := blehid.CheckDongle(s.port) if err != nil { return "No connection" @@ -330,7 +354,11 @@ func (s *Server) processAction(cmd string, args []interface{}) string { if len(args) > 3 { wheelx = toInt(args[3]) } - if err := blehid.SendMouseMove(s.port, right, down, wheely, wheelx); err != nil { + if s.cfg.FirmwareType == "zmk" { + if err := zmkbridge.SendMouseMove(s.port, right, down, wheely, wheelx); err != nil { + return "FAIL" + } + } else if err := blehid.SendMouseMove(s.port, right, down, wheely, wheelx); err != nil { return "FAIL" } return "SUCCESS" @@ -344,7 +372,11 @@ func (s *Server) processAction(cmd string, args []interface{}) string { if len(args) > 1 { behavior, _ = args[1].(string) } - if err := blehid.SendMouseButton(s.port, btn, behavior); err != nil { + if s.cfg.FirmwareType == "zmk" { + if err := zmkbridge.SendMouseButton(s.port, btn, behavior); err != nil { + return "FAIL" + } + } else if err := blehid.SendMouseButton(s.port, btn, behavior); err != nil { return "FAIL" } return "SUCCESS" @@ -368,7 +400,11 @@ func (s *Server) processAction(cmd string, args []interface{}) string { if len(args) > 2 { down, _ = args[2].(bool) } - if err := blehid.SendKeyboardCode(s.port, key, mods, down, &s.keys); err != nil { + if s.cfg.FirmwareType == "zmk" { + if err := zmkbridge.SendKeyboardCode(s.port, key, mods, down, &s.keys); err != nil { + return "FAIL" + } + } else if err := blehid.SendKeyboardCode(s.port, key, mods, down, &s.keys); err != nil { return "FAIL" } return "SUCCESS" @@ -386,6 +422,9 @@ func (s *Server) processAction(cmd string, args []interface{}) string { } func (s *Server) processBleCmd(cmd string) string { + if s.cfg.FirmwareType == "zmk" { + return zmkbridge.ProcessBleCmd(s.port, cmd) + } var err error var result string diff --git a/internal/serial/serial.go b/internal/serial/serial.go index 73b1009..a851d2b 100644 --- a/internal/serial/serial.go +++ b/internal/serial/serial.go @@ -186,6 +186,28 @@ func (h *HardwarePort) WriteATNoResponse(cmd string) error { return err } +func (h *HardwarePort) WriteRaw(data []byte) error { + if h.port == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + _, err := h.port.Write(data) + return err +} + +func (h *HardwarePort) ReadRaw(length int) ([]byte, error) { + if h.port == nil { + return nil, nil + } + h.mu.Lock() + defer h.mu.Unlock() + buf := make([]byte, length) + // Use a short timeout + n, err := h.port.Read(buf) + return buf[:n], err +} + func (h *HardwarePort) Flush() { if h.port != nil { _ = h.port.ResetInputBuffer() diff --git a/internal/simulator/rpc_test.go b/internal/simulator/rpc_test.go index ed0ed8a..5016104 100644 --- a/internal/simulator/rpc_test.go +++ b/internal/simulator/rpc_test.go @@ -17,7 +17,7 @@ func TestRPCEndToEndKeyevent(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -53,7 +53,7 @@ func TestRPCEndToEndMousemove(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -80,7 +80,7 @@ func TestRPCEndToEndMousebutton(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -102,7 +102,7 @@ func TestRPCEndToEndBleCmd(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -140,7 +140,7 @@ func TestRPCEndToEndActions(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -180,7 +180,7 @@ func TestRPCEndToEndDaemon(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -210,7 +210,7 @@ func TestRPCServerWithAuth(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "admin", "secret123") + srv := rpc.NewServerWithConfig(port, "admin", "secret123", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -237,7 +237,7 @@ func TestRPCDeviceManagement(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -279,7 +279,7 @@ func TestRPCKeyboardRelease(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -316,7 +316,7 @@ func TestRPCSwitchAndName(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -350,7 +350,7 @@ func TestFullServerLifecycle(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -373,7 +373,7 @@ func TestRPCUnknownMethod(t *testing.T) { d := simulator.NewDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) @@ -395,7 +395,7 @@ func TestRPCEmptyDongle(t *testing.T) { d := simulator.NewEmptyDongle() port := simulator.NewSimPort(d) - srv := rpc.NewServerWithConfig(port, "", "") + srv := rpc.NewServerWithConfig(port, "", "", "legacy") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.HandleRPC(w, r) diff --git a/internal/simulator/simport.go b/internal/simulator/simport.go index e735fbc..cfd57d0 100644 --- a/internal/simulator/simport.go +++ b/internal/simulator/simport.go @@ -34,6 +34,10 @@ func (s *SimPort) WriteATNoResponse(cmd string) error { return err } +func (s *SimPort) WriteRaw(data []byte) error { return nil } + +func (s *SimPort) ReadRaw(length int) ([]byte, error) { return nil, nil } + func (s *SimPort) Flush() {} func (s *SimPort) CommandLog() []string { diff --git a/internal/webui/server.go b/internal/webui/server.go index c63bff8..04a9a1b 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -483,6 +483,16 @@ func (s *Server) refreshDevices() { return } + // We check if it is ZMK Native Mode via our blecmd hook + if s.processBle("devlist") == "ZMK_NATIVE_MODE" { + s.statusMu.Lock() + s.status.CurrentDevice = "ZMK Native Mode (See UI)" + s.status.DeviceList = nil + s.statusMu.Unlock() + data, _ := json.Marshal(map[string]interface{}{"type": "status", "status": &s.status}); s.hub.broadcast(data) + return + } + name, _ := blehid.GetDeviceName(s.port) list, _ := blehid.GetDeviceList(s.port) diff --git a/internal/webui/ui/index.html b/internal/webui/ui/index.html index 989466f..5af61ce 100644 --- a/internal/webui/ui/index.html +++ b/internal/webui/ui/index.html @@ -597,17 +597,23 @@

Daemon

: '
No BLE device paired — add a device to get started
'; const dl = document.getElementById('deviceList'); - if (s.deviceList && s.deviceList.length > 0) { - dl.innerHTML = s.deviceList.map(d => - '
' + - '' + esc(d.name) + '' + - (d.connected ? 'connected' : '') + - '' + - ' ' + - '
' - ).join(''); + const cd = document.getElementById('currentDeviceInfo'); + if (s.currentDevice === 'ZMK Native Mode (See UI)') { + cd.innerHTML = '
StatusNative ZMK Mode
'; + dl.innerHTML = '
ZMK Slots (1-5)
'; } else { - dl.innerHTML = '
No paired devices
'; + if (s.deviceList && s.deviceList.length > 0) { + dl.innerHTML = s.deviceList.map(d => + '
' + + '' + esc(d.name) + '' + + (d.connected ? 'connected' : '') + + '' + + ' ' + + '
' + ).join(''); + } else { + dl.innerHTML = '
No paired devices
'; + } } document.getElementById('connMode').textContent = s.daemonMode; diff --git a/internal/zmk/proto/relaykeys.pb.go b/internal/zmk/proto/relaykeys.pb.go new file mode 100644 index 0000000..15b02cc --- /dev/null +++ b/internal/zmk/proto/relaykeys.pb.go @@ -0,0 +1,442 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: relaykeys.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type InjectReportRequest_ReportType int32 + +const ( + InjectReportRequest_KEYBOARD InjectReportRequest_ReportType = 0 + InjectReportRequest_MOUSE InjectReportRequest_ReportType = 1 + InjectReportRequest_CONSUMER InjectReportRequest_ReportType = 2 // For media keys like volume/mute +) + +// Enum value maps for InjectReportRequest_ReportType. +var ( + InjectReportRequest_ReportType_name = map[int32]string{ + 0: "KEYBOARD", + 1: "MOUSE", + 2: "CONSUMER", + } + InjectReportRequest_ReportType_value = map[string]int32{ + "KEYBOARD": 0, + "MOUSE": 1, + "CONSUMER": 2, + } +) + +func (x InjectReportRequest_ReportType) Enum() *InjectReportRequest_ReportType { + p := new(InjectReportRequest_ReportType) + *p = x + return p +} + +func (x InjectReportRequest_ReportType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (InjectReportRequest_ReportType) Descriptor() protoreflect.EnumDescriptor { + return file_relaykeys_proto_enumTypes[0].Descriptor() +} + +func (InjectReportRequest_ReportType) Type() protoreflect.EnumType { + return &file_relaykeys_proto_enumTypes[0] +} + +func (x InjectReportRequest_ReportType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use InjectReportRequest_ReportType.Descriptor instead. +func (InjectReportRequest_ReportType) EnumDescriptor() ([]byte, []int) { + return file_relaykeys_proto_rawDescGZIP(), []int{0, 0} +} + +type AdminCommandRequest_CommandType int32 + +const ( + AdminCommandRequest_PAIR AdminCommandRequest_CommandType = 0 + AdminCommandRequest_SWITCH_SLOT AdminCommandRequest_CommandType = 1 + AdminCommandRequest_GET_STATUS AdminCommandRequest_CommandType = 2 + AdminCommandRequest_RESET AdminCommandRequest_CommandType = 3 + AdminCommandRequest_CLEAR_SLOT AdminCommandRequest_CommandType = 4 +) + +// Enum value maps for AdminCommandRequest_CommandType. +var ( + AdminCommandRequest_CommandType_name = map[int32]string{ + 0: "PAIR", + 1: "SWITCH_SLOT", + 2: "GET_STATUS", + 3: "RESET", + 4: "CLEAR_SLOT", + } + AdminCommandRequest_CommandType_value = map[string]int32{ + "PAIR": 0, + "SWITCH_SLOT": 1, + "GET_STATUS": 2, + "RESET": 3, + "CLEAR_SLOT": 4, + } +) + +func (x AdminCommandRequest_CommandType) Enum() *AdminCommandRequest_CommandType { + p := new(AdminCommandRequest_CommandType) + *p = x + return p +} + +func (x AdminCommandRequest_CommandType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AdminCommandRequest_CommandType) Descriptor() protoreflect.EnumDescriptor { + return file_relaykeys_proto_enumTypes[1].Descriptor() +} + +func (AdminCommandRequest_CommandType) Type() protoreflect.EnumType { + return &file_relaykeys_proto_enumTypes[1] +} + +func (x AdminCommandRequest_CommandType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AdminCommandRequest_CommandType.Descriptor instead. +func (AdminCommandRequest_CommandType) EnumDescriptor() ([]byte, []int) { + return file_relaykeys_proto_rawDescGZIP(), []int{2, 0} +} + +type InjectReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type InjectReportRequest_ReportType `protobuf:"varint,1,opt,name=type,proto3,enum=relaykeys.rpc.InjectReportRequest_ReportType" json:"type,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` // Raw HID report bytes (e.g., 8 bytes for keyboard) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InjectReportRequest) Reset() { + *x = InjectReportRequest{} + mi := &file_relaykeys_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InjectReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InjectReportRequest) ProtoMessage() {} + +func (x *InjectReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_relaykeys_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InjectReportRequest.ProtoReflect.Descriptor instead. +func (*InjectReportRequest) Descriptor() ([]byte, []int) { + return file_relaykeys_proto_rawDescGZIP(), []int{0} +} + +func (x *InjectReportRequest) GetType() InjectReportRequest_ReportType { + if x != nil { + return x.Type + } + return InjectReportRequest_KEYBOARD +} + +func (x *InjectReportRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type InjectReportResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + ErrorMessage string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InjectReportResponse) Reset() { + *x = InjectReportResponse{} + mi := &file_relaykeys_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InjectReportResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InjectReportResponse) ProtoMessage() {} + +func (x *InjectReportResponse) ProtoReflect() protoreflect.Message { + mi := &file_relaykeys_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InjectReportResponse.ProtoReflect.Descriptor instead. +func (*InjectReportResponse) Descriptor() ([]byte, []int) { + return file_relaykeys_proto_rawDescGZIP(), []int{1} +} + +func (x *InjectReportResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *InjectReportResponse) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +type AdminCommandRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Command AdminCommandRequest_CommandType `protobuf:"varint,1,opt,name=command,proto3,enum=relaykeys.rpc.AdminCommandRequest_CommandType" json:"command,omitempty"` + Slot int32 `protobuf:"varint,2,opt,name=slot,proto3" json:"slot,omitempty"` // Used for SWITCH_SLOT or CLEAR_SLOT (0-4) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminCommandRequest) Reset() { + *x = AdminCommandRequest{} + mi := &file_relaykeys_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminCommandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminCommandRequest) ProtoMessage() {} + +func (x *AdminCommandRequest) ProtoReflect() protoreflect.Message { + mi := &file_relaykeys_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminCommandRequest.ProtoReflect.Descriptor instead. +func (*AdminCommandRequest) Descriptor() ([]byte, []int) { + return file_relaykeys_proto_rawDescGZIP(), []int{2} +} + +func (x *AdminCommandRequest) GetCommand() AdminCommandRequest_CommandType { + if x != nil { + return x.Command + } + return AdminCommandRequest_PAIR +} + +func (x *AdminCommandRequest) GetSlot() int32 { + if x != nil { + return x.Slot + } + return 0 +} + +type AdminCommandResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + ErrorMessage string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + ActiveSlot int32 `protobuf:"varint,3,opt,name=active_slot,json=activeSlot,proto3" json:"active_slot,omitempty"` + SlotBonded []bool `protobuf:"varint,4,rep,packed,name=slot_bonded,json=slotBonded,proto3" json:"slot_bonded,omitempty"` // 5 booleans indicating if a slot has a bonded device + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminCommandResponse) Reset() { + *x = AdminCommandResponse{} + mi := &file_relaykeys_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminCommandResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminCommandResponse) ProtoMessage() {} + +func (x *AdminCommandResponse) ProtoReflect() protoreflect.Message { + mi := &file_relaykeys_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminCommandResponse.ProtoReflect.Descriptor instead. +func (*AdminCommandResponse) Descriptor() ([]byte, []int) { + return file_relaykeys_proto_rawDescGZIP(), []int{3} +} + +func (x *AdminCommandResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *AdminCommandResponse) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *AdminCommandResponse) GetActiveSlot() int32 { + if x != nil { + return x.ActiveSlot + } + return 0 +} + +func (x *AdminCommandResponse) GetSlotBonded() []bool { + if x != nil { + return x.SlotBonded + } + return nil +} + +var File_relaykeys_proto protoreflect.FileDescriptor + +const file_relaykeys_proto_rawDesc = "" + + "\n" + + "\x0frelaykeys.proto\x12\rrelaykeys.rpc\"\xa1\x01\n" + + "\x13InjectReportRequest\x12A\n" + + "\x04type\x18\x01 \x01(\x0e2-.relaykeys.rpc.InjectReportRequest.ReportTypeR\x04type\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\"3\n" + + "\n" + + "ReportType\x12\f\n" + + "\bKEYBOARD\x10\x00\x12\t\n" + + "\x05MOUSE\x10\x01\x12\f\n" + + "\bCONSUMER\x10\x02\"U\n" + + "\x14InjectReportResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12#\n" + + "\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"\xc8\x01\n" + + "\x13AdminCommandRequest\x12H\n" + + "\acommand\x18\x01 \x01(\x0e2..relaykeys.rpc.AdminCommandRequest.CommandTypeR\acommand\x12\x12\n" + + "\x04slot\x18\x02 \x01(\x05R\x04slot\"S\n" + + "\vCommandType\x12\b\n" + + "\x04PAIR\x10\x00\x12\x0f\n" + + "\vSWITCH_SLOT\x10\x01\x12\x0e\n" + + "\n" + + "GET_STATUS\x10\x02\x12\t\n" + + "\x05RESET\x10\x03\x12\x0e\n" + + "\n" + + "CLEAR_SLOT\x10\x04\"\x97\x01\n" + + "\x14AdminCommandResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12#\n" + + "\rerror_message\x18\x02 \x01(\tR\ferrorMessage\x12\x1f\n" + + "\vactive_slot\x18\x03 \x01(\x05R\n" + + "activeSlot\x12\x1f\n" + + "\vslot_bonded\x18\x04 \x03(\bR\n" + + "slotBondedB3Z1github.com/AceCentre/RelayKeys/internal/zmk/protob\x06proto3" + +var ( + file_relaykeys_proto_rawDescOnce sync.Once + file_relaykeys_proto_rawDescData []byte +) + +func file_relaykeys_proto_rawDescGZIP() []byte { + file_relaykeys_proto_rawDescOnce.Do(func() { + file_relaykeys_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_relaykeys_proto_rawDesc), len(file_relaykeys_proto_rawDesc))) + }) + return file_relaykeys_proto_rawDescData +} + +var file_relaykeys_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_relaykeys_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_relaykeys_proto_goTypes = []any{ + (InjectReportRequest_ReportType)(0), // 0: relaykeys.rpc.InjectReportRequest.ReportType + (AdminCommandRequest_CommandType)(0), // 1: relaykeys.rpc.AdminCommandRequest.CommandType + (*InjectReportRequest)(nil), // 2: relaykeys.rpc.InjectReportRequest + (*InjectReportResponse)(nil), // 3: relaykeys.rpc.InjectReportResponse + (*AdminCommandRequest)(nil), // 4: relaykeys.rpc.AdminCommandRequest + (*AdminCommandResponse)(nil), // 5: relaykeys.rpc.AdminCommandResponse +} +var file_relaykeys_proto_depIdxs = []int32{ + 0, // 0: relaykeys.rpc.InjectReportRequest.type:type_name -> relaykeys.rpc.InjectReportRequest.ReportType + 1, // 1: relaykeys.rpc.AdminCommandRequest.command:type_name -> relaykeys.rpc.AdminCommandRequest.CommandType + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_relaykeys_proto_init() } +func file_relaykeys_proto_init() { + if File_relaykeys_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_relaykeys_proto_rawDesc), len(file_relaykeys_proto_rawDesc)), + NumEnums: 2, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_relaykeys_proto_goTypes, + DependencyIndexes: file_relaykeys_proto_depIdxs, + EnumInfos: file_relaykeys_proto_enumTypes, + MessageInfos: file_relaykeys_proto_msgTypes, + }.Build() + File_relaykeys_proto = out.File + file_relaykeys_proto_goTypes = nil + file_relaykeys_proto_depIdxs = nil +} diff --git a/internal/zmk/proto/relaykeys.proto b/internal/zmk/proto/relaykeys.proto new file mode 100644 index 0000000..a74daaf --- /dev/null +++ b/internal/zmk/proto/relaykeys.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; +package relaykeys.rpc; + +option go_package = "github.com/AceCentre/RelayKeys/internal/zmk/proto"; + +message InjectReportRequest { + enum ReportType { + KEYBOARD = 0; + MOUSE = 1; + CONSUMER = 2; // For media keys like volume/mute + } + ReportType type = 1; + bytes data = 2; // Raw HID report bytes (e.g., 8 bytes for keyboard) +} + +message InjectReportResponse { + bool success = 1; + string error_message = 2; +} + +message AdminCommandRequest { + enum CommandType { + PAIR = 0; + SWITCH_SLOT = 1; + GET_STATUS = 2; + RESET = 3; + CLEAR_SLOT = 4; + } + CommandType command = 1; + int32 slot = 2; // Used for SWITCH_SLOT or CLEAR_SLOT (0-4) +} + +message AdminCommandResponse { + bool success = 1; + string error_message = 2; + + int32 active_slot = 3; + repeated bool slot_bonded = 4; // 5 booleans indicating if a slot has a bonded device +} diff --git a/internal/zmkbridge/bridge.go b/internal/zmkbridge/bridge.go new file mode 100644 index 0000000..1e7ac23 --- /dev/null +++ b/internal/zmkbridge/bridge.go @@ -0,0 +1,181 @@ +package zmkbridge + +import ( + "fmt" + "log" + "strings" + + "github.com/acecentre/relaykeys/internal/blehid" + pb "github.com/acecentre/relaykeys/internal/zmk/proto" + "google.golang.org/protobuf/proto" +) + +const ( + sofByte = 0xAB + escByte = 0xAC + eofByte = 0xAD +) + +func framePayload(payload []byte) []byte { + var framed []byte + framed = append(framed, sofByte) + for _, b := range payload { + if b == sofByte || b == escByte || b == eofByte { + framed = append(framed, escByte) + framed = append(framed, b) + } else { + framed = append(framed, b) + } + } + framed = append(framed, eofByte) + return framed +} + +func sendReport(port blehid.Port, repType pb.InjectReportRequest_ReportType, data []byte) error { + req := &pb.InjectReportRequest{ + Type: repType, + Data: data, + } + + payload, err := proto.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal protobuf: %w", err) + } + + framed := framePayload(payload) + + log.Printf("ZMK Bridge sending report type %v, len %d, framed len %d", repType, len(data), len(framed)) + + return port.WriteRaw(framed) +} + +func SendKeyboardCode(port blehid.Port, key string, modifiers []string, down bool, keys *[8]byte) error { + // Re-use logic from blehid to compute modifiers and keys + var hidmod byte + for _, m := range modifiers { + if bit, ok := blehid.ModifierBits[m]; ok { + hidmod |= bit + } + } + + if down { + keys[0] |= hidmod + } else { + keys[0] &^= hidmod + } + + keycode := blehid.Keymap[key] + if key != "" && keycode != 0 { + if down { + for i := 2; i < 8; i++ { + if keys[i] == 0 { + keys[i] = keycode + break + } + } + } else { + for i := 2; i < 8; i++ { + if keys[i] == keycode { + keys[i] = 0 + break + } + } + } + } + + // 8-byte standard HID report + data := keys[:] + return sendReport(port, pb.InjectReportRequest_KEYBOARD, data) +} + +func SendMouseMove(port blehid.Port, right, down, wheely, wheelx int) error { + // 5-byte Mouse report: buttons, x, y, scroll_y, scroll_x + data := []byte{0, byte(int8(right)), byte(int8(down)), byte(int8(wheely)), byte(int8(wheelx))} + return sendReport(port, pb.InjectReportRequest_MOUSE, data) +} + +func SendMouseButton(port blehid.Port, btn string, behavior string) error { + var buttons byte + switch btn { + case "l": buttons = 1 + case "r": buttons = 2 + case "m": buttons = 4 + } + + data := []byte{buttons, 0, 0, 0, 0} + + if behavior == "click" || behavior == "" { + // Down then up + sendReport(port, pb.InjectReportRequest_MOUSE, data) + data[0] = 0 + return sendReport(port, pb.InjectReportRequest_MOUSE, data) + } + + if behavior == "up" { + data[0] = 0 + } + + return sendReport(port, pb.InjectReportRequest_MOUSE, data) +} + +func sendAdminCommand(port blehid.Port, command pb.AdminCommandRequest_CommandType, slot int32) (*pb.AdminCommandResponse, error) { + req := &pb.AdminCommandRequest{ + Command: command, + Slot: slot, + } + + payload, err := proto.Marshal(req) + if err != nil { + return nil, err + } + + framed := framePayload(payload) + // We should actually read the response here if we need it + // But port.WriteRaw doesn't read currently. For phase 2 we can return success + // assuming write succeeds. We would need a custom Read if ZMK responds. + err = port.WriteRaw(framed) + if err != nil { + return nil, err + } + + respBuf, err := port.ReadRaw(1024) + if err == nil && len(respBuf) > 3 { + // Deframing + var deframed []byte + for i := 1; i < len(respBuf) - 1; i++ { + if respBuf[i] == escByte && i+1 < len(respBuf)-1 { + deframed = append(deframed, respBuf[i+1]) + i++ + } else { + deframed = append(deframed, respBuf[i]) + } + } + var resp pb.AdminCommandResponse + if err := proto.Unmarshal(deframed, &resp); err == nil { + return &resp, nil + } + } + + return &pb.AdminCommandResponse{Success: true}, nil +} + +// ZMK admin commands +func ProcessBleCmd(port blehid.Port, cmd string) string { + switch { + case cmd == "devlist": + // Return a structured JSON or ZMK specific string that the web UI parses + return "ZMK_NATIVE_MODE" + case cmd == "devadd": + _, err := sendAdminCommand(port, pb.AdminCommandRequest_PAIR, 0) + if err != nil { return "FAIL" } + case cmd == "devreset": + _, err := sendAdminCommand(port, pb.AdminCommandRequest_RESET, 0) + if err != nil { return "FAIL" } + case strings.HasPrefix(cmd, "switch="): + var slot int32 + fmt.Sscanf(strings.TrimPrefix(cmd, "switch="), "%d", &slot) + _, err := sendAdminCommand(port, pb.AdminCommandRequest_SWITCH_SLOT, slot) + if err != nil { return "FAIL" } + } + return "SUCCESS" +}