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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 3 additions & 39 deletions src-tauri/src/transport/ble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use uuid::Uuid;

use super::{ConnectResponse, DeviceDescriptor, SessionCmd, Sessions, insert_session};
use super::{SessionCmd, Sessions, insert_session};

#[derive(Serialize)]
pub struct BleDeviceInfo {
Expand All @@ -23,9 +23,6 @@ const RYNK_OUTPUT_CHAR_UUID: Uuid = Uuid::from_u128(0x19802524_6f90_4346_93c2_63
const BLE_SAFE_WRITE: usize = 20;
const RYNK_BLE_CHUNK_SIZE: usize = 244;

const DIS_PNP_ID_UUID: Uuid = Uuid::from_u128(0x00002a50_0000_1000_8000_00805f9b34fb);
const DIS_SERIAL_UUID: Uuid = Uuid::from_u128(0x00002a25_0000_1000_8000_00805f9b34fb);
const DIS_MANUFACTURER_UUID: Uuid = Uuid::from_u128(0x00002a29_0000_1000_8000_00805f9b34fb);

async fn get_adapter() -> Result<Adapter, String> {
let manager = Manager::new().await.map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -53,37 +50,8 @@ pub async fn rynk_discover_ble() -> Result<Vec<BleDeviceInfo>, String> {
Ok(out)
}

async fn read_dis_descriptor(
peripheral: &btleplug::platform::Peripheral,
product_name: String,
) -> DeviceDescriptor {
let chars = peripheral.characteristics();
let mut desc = DeviceDescriptor { product_name, ..Default::default() };

// PnP ID (0x2A50): 7 bytes = vid_source(1) + vendor_id(2 LE) + product_id(2 LE) + version(2 LE)
if let Some(c) = chars.iter().find(|c| c.uuid == DIS_PNP_ID_UUID) {
if let Ok(data) = peripheral.read(c).await {
if data.len() >= 7 {
desc.vendor_id = u16::from_le_bytes([data[1], data[2]]);
desc.product_id = u16::from_le_bytes([data[3], data[4]]);
}
}
}
if let Some(c) = chars.iter().find(|c| c.uuid == DIS_SERIAL_UUID) {
if let Ok(data) = peripheral.read(c).await {
desc.serial_number = String::from_utf8_lossy(&data).into_owned();
}
}
if let Some(c) = chars.iter().find(|c| c.uuid == DIS_MANUFACTURER_UUID) {
if let Ok(data) = peripheral.read(c).await {
desc.manufacturer = String::from_utf8_lossy(&data).into_owned();
}
}
desc
}

#[tauri::command]
pub async fn rynk_connect_ble(id: String, sessions: State<'_, Sessions>) -> Result<ConnectResponse, String> {
pub async fn rynk_connect_ble(id: String, sessions: State<'_, Sessions>) -> Result<String, String> {
let adapter = get_adapter().await?;
let peripherals = adapter.peripherals().await.map_err(|e| e.to_string())?;
let peripheral = peripherals.into_iter()
Expand All @@ -93,10 +61,6 @@ pub async fn rynk_connect_ble(id: String, sessions: State<'_, Sessions>) -> Resu
peripheral.connect().await.map_err(|e| e.to_string())?;
peripheral.discover_services().await.map_err(|e| e.to_string())?;

let product_name = peripheral.properties().await.ok().flatten()
.and_then(|p| p.local_name).unwrap_or_default();
let descriptor = read_dis_descriptor(&peripheral, product_name).await;

let chars = peripheral.characteristics();
let input = chars.iter().find(|c| c.uuid == RYNK_INPUT_CHAR_UUID)
.ok_or("input characteristic not found")?.clone();
Expand Down Expand Up @@ -142,5 +106,5 @@ pub async fn rynk_connect_ble(id: String, sessions: State<'_, Sessions>) -> Resu
});

let session = insert_session(&sessions, cmd_tx, data_rx).await;
Ok(ConnectResponse { session, descriptor })
Ok(session)
}
16 changes: 0 additions & 16 deletions src-tauri/src/transport/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,11 @@ pub mod tcp;
use std::collections::HashMap;
use std::sync::Arc;

use serde::Serialize;
use tauri::State;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, Mutex, oneshot};
use uuid::Uuid;

#[derive(Serialize, Clone, Default)]
pub struct DeviceDescriptor {
pub vendor_id: u16,
pub product_id: u16,
pub manufacturer: String,
pub product_name: String,
pub serial_number: String,
}

#[derive(Serialize)]
pub struct ConnectResponse {
pub session: String,
pub descriptor: DeviceDescriptor,
}

pub enum SessionCmd {
Send(Vec<u8>, oneshot::Sender<()>),
Close,
Expand Down
28 changes: 7 additions & 21 deletions src-tauri/src/transport/serial.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use serde::Serialize;
use tauri::State;
use tokio_serial::{SerialPortBuilderExt, SerialPortType, available_ports};
use tokio_serial::SerialPortBuilderExt;

use super::{ConnectResponse, DeviceDescriptor, Sessions, spawn_tokio_io};
use super::{Sessions, spawn_tokio_io};

#[derive(Serialize)]
pub struct SerialDeviceInfo {
Expand Down Expand Up @@ -34,24 +34,10 @@ pub async fn rynk_discover_serial() -> Result<Vec<SerialDeviceInfo>, String> {
}

#[tauri::command]
pub async fn rynk_connect_serial(path: String, sessions: State<'_, Sessions>) -> Result<ConnectResponse, String> {
let stream = tokio_serial::new(&path, 115_200).open_native_async().map_err(|e| e.to_string())?;
pub async fn rynk_connect_serial(path: String, sessions: State<'_, Sessions>) -> Result<String, String> {
let stream = tokio_serial::new(&path, 115_200)
.open_native_async()
.map_err(|e| e.to_string())?;
let (read, write) = tokio::io::split(stream);
let session = spawn_tokio_io(sessions, read, write).await;

let descriptor = available_ports().map_err(|e| e.to_string())?
.into_iter().find(|p| p.port_name == path)
.and_then(|p| match p.port_type {
SerialPortType::UsbPort(info) => Some(DeviceDescriptor {
vendor_id: info.vid,
product_id: info.pid,
manufacturer: info.manufacturer.unwrap_or_default(),
product_name: info.product.unwrap_or_default(),
serial_number: info.serial_number.unwrap_or_default(),
}),
_ => None,
})
.unwrap_or_default();

Ok(ConnectResponse { session, descriptor })
Ok(spawn_tokio_io(sessions, read, write).await)
}
6 changes: 3 additions & 3 deletions src-tauri/src/transport/tcp.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use serde::Serialize;
use tauri::State;

use super::{ConnectResponse, Sessions, spawn_tokio_io};
use super::{Sessions, spawn_tokio_io};

#[derive(Serialize)]
pub struct TcpDeviceInfo {
Expand All @@ -25,9 +25,9 @@ pub async fn rynk_discover_tcp() -> Vec<TcpDeviceInfo> {
}

#[tauri::command]
pub async fn rynk_connect_tcp(addr: String, sessions: State<'_, Sessions>) -> Result<ConnectResponse, String> {
pub async fn rynk_connect_tcp(addr: String, sessions: State<'_, Sessions>) -> Result<String, String> {
let stream = tokio::net::TcpStream::connect(&addr).await.map_err(|e| e.to_string())?;
let (read, write) = tokio::io::split(stream);
let session = spawn_tokio_io(sessions, read, write).await;
Ok(ConnectResponse { session, descriptor: Default::default() })
Ok(session)
}
127 changes: 127 additions & 0 deletions src/lib/keycatalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { DeviceCapabilities, HidKeyCode } from '../rynk'
import { describe, expect, it } from 'vitest'
import { actionCatalog, asAction, withModifiers } from './keycatalog'
import { NO_MODIFIERS } from './keycode'

/// A stand-in for the firmware table: one code from each family the groups match.
const HID = [
'A',
'Kc1',
'Minus',
'Enter',
'F13',
'Home',
'Kp7',
'LShift',
'KbVolumeUp',
'MouseBtn1',
'MouseBtn8',
'WwwHome',
'Execute',
'KbPower',
] satisfies HidKeyCode[]

const CAPS = {
num_layers: 2,
max_morse: 4,
macro_space_size: 256,
lighting_enabled: true,
} as DeviceCapabilities

describe('actionCatalog', () => {
it('names every group distinctly', () => {
const names = actionCatalog(CAPS, HID).map(g => g.name)
expect(new Set(names).size).toBe(names.length)
})

it('gives every entry in a group a distinct id', () => {
// Labels are not distinct — `Backslash` and `NonusBackslash` both print `\`
// — and the picker keys its grid on the id.
for (const group of actionCatalog(CAPS, HID)) {
const ids = group.entries.map(e => e.id)
expect(new Set(ids).size, `${group.name} has duplicate ids`).toBe(ids.length)
}
})

it('drops what the firmware left out', () => {
const bare = actionCatalog({ ...CAPS, max_morse: 0, macro_space_size: 0, lighting_enabled: false }, HID)
expect(bare.map(g => g.name)).not.toContain('Light')
const advanced = bare.find(g => g.name === 'Advanced')!.entries.map(e => e.id)
expect(advanced.some(id => id.startsWith('morse'))).toBe(false)
expect(advanced.some(id => id.startsWith('Macro'))).toBe(false)
// The one-shots and special behaviours need no capability.
expect(advanced).toContain('OSM LCtrl')
})

it('offers one layer action per layer', () => {
const layer = actionCatalog(CAPS, HID).find(g => g.name === 'Layer')!
expect(layer.entries.filter(e => e.label.startsWith('MO '))).toHaveLength(2)
})

it('lays the eight groups out in one order', () => {
const names = actionCatalog(CAPS, ['A', 'KbVolumeUp', 'MouseBtn1', 'WwwHome', 'Execute']).map(g => g.name)
expect(names).toEqual(['Basic', 'Media', 'Layer', 'Control', 'Mouse', 'Advanced', 'Light', 'Other'])
})

it('keeps the whole keyboard page in Basic, clearing included', () => {
const basic = actionCatalog(CAPS, ['A', 'Kc1', 'Minus', 'Enter', 'F13', 'Home', 'Kp7', 'LShift'])
.find(g => g.name === 'Basic')!
.entries
.map(e => e.id)
expect(basic.slice(0, 2)).toEqual(['no', 'transparent'])
for (const code of ['A', 'Kc1', 'Minus', 'Enter', 'F13', 'Home', 'Kp7', 'LShift'])
expect(basic, code).toContain(code)
})

it('does not let a family regex overreach', () => {
const groups = actionCatalog(CAPS, ['NumLock', 'SystemPower', 'SystemRequest'])
const basic = groups.find(g => g.name === 'Basic')!.entries.map(e => e.id)
const control = groups.find(g => g.name === 'Control')!.entries.map(e => e.id)
// NumLock lives with the keypad it locks; SysRq is a keyboard key, not a
// way to control the computer.
expect(basic).toContain('NumLock')
expect(control).toContain('SystemPower')
expect(control).not.toContain('SystemRequest')
})

it('matches families by name, so a new one needs no edit', () => {
// MouseBtn6..8 exist in the firmware table but were never hand-listed.
const mouse = actionCatalog(CAPS, ['MouseBtn1', 'MouseBtn8', 'MouseWheelUp'])
.find(g => g.name === 'Mouse')!
expect(mouse.entries.map(e => e.id)).toEqual(['MouseBtn1', 'MouseBtn8', 'MouseWheelUp'])
})

it('carries a keycode no group claims through to Other', () => {
const other = actionCatalog(CAPS, ['A', 'Kp7', 'KbPower', 'Execute'])
.find(g => g.name === 'Other')
expect(other?.entries.map(e => e.id)).toEqual(['KbPower', 'Execute'])
})

it('keeps the error codes off the board', () => {
const groups = actionCatalog(CAPS, ['No', 'ErrorRollover', 'PostFail', 'ErrorUndefined'])
expect(groups.find(g => g.name === 'Other')).toBeUndefined()
})
})

describe('withModifiers', () => {
it('rewrites a plain key as a modified one', () => {
const entry = actionCatalog(CAPS, HID)[0]!.entries.find(e => e.hid === 'A')!
expect(withModifiers(entry, NO_MODIFIERS)).toEqual(entry.action)
expect(withModifiers(entry, { ...NO_MODIFIERS, left_ctrl: true }))
.toEqual({ Single: { KeyWithModifier: ['A', { ...NO_MODIFIERS, left_ctrl: true }] } })
})

it('leaves an entry that carries no keycode alone', () => {
const layerEntry = actionCatalog(CAPS, HID).find(g => g.name === 'Layer')!.entries[0]!
expect(withModifiers(layerEntry, { ...NO_MODIFIERS, left_ctrl: true })).toEqual(layerEntry.action)
})
})

describe('asAction', () => {
it('unwraps what a tap-hold can hold, and refuses the rest', () => {
expect(asAction({ Single: { LayerOn: 1 } })).toEqual({ LayerOn: 1 })
expect(asAction('No')).toBe('No')
expect(asAction({ Morse: 0 })).toBeNull()
expect(asAction('Transparent')).toBeNull()
})
})
Loading