Skip to content

qmk/qmk_xap

Repository files navigation

QMK XAP Client

This repository contains the (experimental) QMK XAP protocol client. It is build using the following base technologies:

Architecture/Design

flowchart TD
    subgraph QMK XAP UI
        subgraph Frontend
            vue[Vue.js]
        end

        subgraph Backend
            Tauri
            client[XAP Client]
        end
    end
    
    subgraph Devices
        dev1[XAP Device 1]
        dev2[XAP Device 2]
    end

    vue <-->|JSON RPC - <b><i>*1</i></b>| Tauri

    Tauri <--> client

    client <-->|Usb Hid - <b><i>*2</i></b>|dev1
    client <-->|Usb Hid - <b><i>*2</i></b>|dev2

(1) JSON RPC:

frontend and backend communicate over remote procedure calls using JSON as it's data exchange format. These calls come in two flavors:

  • Commands: are synchronous and follow a request and response model. Only the frontend can initiate these commands and the backend responds with the help of pre-defined Command handlers. The XAP client makes heavy use of these commands to provide well defined endpoints that either query data from the attached XAP devices or run actions on these devices.
  • Events: are asynchronous and do not provide any feedback from the event listeners. Both the frontend and backend can listen to and emit events. The XAP clients backend signals state changes e.g. Newly attached devices or Removed devices to the frontend with these.

All serialization from Rust structs into JSON objects is done automatically with the help of the Serde framework and it's JSON serializer implementation serde_json. The structure of the JSON objects is derived from the layout of the Rust structs.

In order to keep the backend structs and frontend TS types in synchronization and automatically propagate changes in the datatype ts-rs is used to generate matching Typescript interface definitions from the Rust structs.

As an example the RGBMatrixConfig struct is annotated with the derive attribute and specifically derive serde's Serialize and ts-rs's TS Trait.

#[derive(BinWrite, BinRead, Debug, TS, Serialize, Deserialize)]
#[ts(export)]
pub struct RGBMatrixConfig {
    pub enable: u8,
    pub mode: u8,
    pub hue: u8,
    pub sat: u8,
    pub val: u8,
    pub speed: u8,
    pub flags: u8,
}

With these, the following Typescript Interface are automatically generated:

export interface RGBMatrixConfig {
    enable: number,
    mode: number,
    hue: number,
    sat: number,
    val: number,
    speed: number,
    flags: number,
}

...which can then directly be used in the for backend for Tauri Command and Events:

#[tauri::command]
pub(crate) async fn rgbmatrix_config_get(
    id: Uuid,
    state: State<'_, Arc<Mutex<XAPClient>>>,
) -> XAPResult<RGBMatrixConfig> {
    state.lock().query(id, RGBMatrixConfigGet {})
}

and in the frontend to invoke said handler (two already wrapped in a helper function):

export async function getConfig(id: string): Promise<RGBMatrixConfig> {
  return await querybackend('rgbmatrix_config_get', id, null)
}

Both the backend and frontend handler have to be written manually at this point, but should ideally be auto generated where it makes sense e.g. whenever data is passed directly from the XAP device to the frontend. Other handlers that involve aggregation and processing of data from the XAP device at least need to be implemented by hand in the backend.

(2) USB HID:

To communicate with attached XAP devices over USB RAW HID the backend uses the hidapi-rs library. The client supports multiple simultaneous connected devices and distinguishes these by attaching an UUID when opening a new device.

The two main structs two mention here are:

  • XAPDevice represents exactly one physically attached XAP device. Every device spawns a capturing thread that reacts to incoming responses for requests or passes broadcast messages to an event loop.
  • XAPClient detects new XAP devices and manages existing ones. It also forwards requests to the individual devices with the help of an UUID identifier.

The parsing of the RAW XAP HID packets into Rust structs is done with the help of the binrw crate which derives C-ABI compatible binary readers and writers from the Rust struct layout or can be implemented by hand for special cases like the XAP Token and String types.

For example the ResponseRaw and again RGBMatrixConfig struct, the later is constructed by the XAPDevice out of the payload member of the ResponseRaw struct:

#[binread]
#[derive(Debug)]
pub struct ResponseRaw {
    token: Token,
    flags: ResponseFlags,
    #[br(temp)]
    payload_len: u8,
    #[br(count = payload_len)]
    payload: Vec<u8>,
}

#[derive(BinWrite, BinRead, Debug, TS, Serialize, Deserialize)]
#[ts(export)]
pub struct RGBMatrixConfig {
    pub enable: u8,
    pub mode: u8,
    pub hue: u8,
    pub sat: u8,
    pub val: u8,
    pub speed: u8,
    pub flags: u8,
}

Project Structure

.
├── bindings (*autogenerated* JSON RPC types)
├── public
├── src **frontend**
│  ├── assets
│  ├── commands (*(not yet) autogenerated* JSON RPC handlers - TS)
│  │  └── lighting
│  ├── layouts (base UI layout)
│  ├── pages (individual XAP subsystem as pages)
│  ├── router (routing for pages)
│  ├── stores (available XAP devices as global state)
│  └── utils
├── src-tauri **backend**
│  ├── icons
│  └── src
│     ├── commands (*(not yet) autogenerated* JSON RPC handlers - Rust)
│     │  └── lighting
│     └── xap
│        ├── hid (XAP Client and Device abstractions)
│        └── protocol 
│           └── subsystems
└── xap-specs
   ├── specs (XAP specifications, to be used for autogeneration - HJSON)
   └── src
      ├── constants
      └── protocol (*(not yet) autogenerated* XAP protocol handlers - Rust)
         ├── broadcast.rs
         └── subsystems
            └── lighting

Design "Rules"

General:

  • Robust error handling, errors must not bring the application into an invalid state
  • Leverage types and design APIs that are hard to miss-use
  • All inter-component communication must provide log/tracing messages to gain easy introspection into the system. E.g. the frontend logs if it has received a new event or issues a command - including the payload.

The frontend:

  • Is as dumb as possible - it presents data and prepares data to be sent to the backend.
  • Holds as little state as possible and rather relies on fetching data from the backend again.
  • Reacts to asynchronous backend events and syncs its internal state accordingly:
    • A new device was found - add it to available devices store
    • A device was removed - remove it from available devices store
    • The secure state of a changed - update secure state of device in the devices store

The backend:

  • Handles all low-level USB communication
  • Implements and abstracts the XAP protocol
  • Handles raw data aggregation and provides normalized abstractions for consumption.
    • e.g. when a new Device connects, all static information about the device is retrieved and put into the XAPDeviceInfo struct. The frontend works with this struct and e.g. never initiates a query to ask the device about its enabled XAP subsystems or config JSON blobs.

Painpoints

  • frontend backend barrier across different languages is an overhead in leads to code duplication (Handlers, Exchanged Data). This should be reduced as much as possible with code generation.

Outlook

  • Leverage code generation as much as possible
  • The XAP protocol, client and device implementation can be compiled to WebAssembly and leverage web_sys crate for WebHID compatibility. This could allow a WebApp without Tauri from the same codebase in the Future(tm).