feat: complete device serial api implementation

This commit is contained in:
2023-07-24 21:04:42 +02:00
parent 21dbfa48de
commit e64082d578
8 changed files with 219 additions and 49 deletions

View File

@@ -1,9 +1,11 @@
import {describe, it, expect} from "vitest"
import {
chordAsCommandCompatible,
chordFromCommandCompatible,
deserializeActions,
parseChordActions,
parsePhrase,
serializeActions,
stringifyChordActions,
stringifyPhrase,
} from "./chord"
describe("chords", function () {
@@ -24,18 +26,23 @@ describe("chords", function () {
}
})
describe("commands", function () {
it("should convert to a command", function () {
expect(chordAsCommandCompatible({actions: [32, 51], phrase: [0x01, 0x68, 0x72, 0xd4, 0x65]})).toEqual(
"000CC200000000000000000000000000 016872D465",
)
describe("phrase", function () {
it("should stringify", function () {
expect(stringifyPhrase([0x01, 0x68, 0x72, 0xd4, 0x65])).toEqual("016872D465")
})
it("should parse a command", function () {
expect(chordFromCommandCompatible("000CC200000000000000000000000000 016872D465")).toEqual({
actions: [32, 51],
phrase: [0x01, 0x68, 0x72, 0xd4, 0x65],
})
it("should parse", function () {
expect(parsePhrase("016872D465")).toEqual([0x01, 0x68, 0x72, 0xd4, 0x65])
})
})
describe("chord actions", function () {
it("should stringify", function () {
expect(stringifyChordActions([32, 51])).toEqual("000CC200000000000000000000000000")
})
it("should parse", function () {
expect(parseChordActions("000CC200000000000000000000000000")).toEqual([32, 51])
})
})
})

View File

@@ -3,30 +3,25 @@ export interface Chord {
phrase: number[]
}
/**
* Turns a chord into a serial-command-compatible string
*
* @example "000CC200000000000000000000000000 7468726565"
*/
export function chordAsCommandCompatible(chord: Chord): string {
return `${serializeActions(chord.actions).toString(16).padStart(32, "0")} ${chord.phrase
.map(it => it.toString(16).padStart(2, "0"))
.join("")}`.toUpperCase()
export function parsePhrase(phrase: string): number[] {
return Array.from({length: phrase.length / 2}).map((_, i) =>
Number.parseInt(phrase.slice(i * 2, i * 2 + 2), 16),
)
}
/**
* Turns a command response into a chord
*
* @see {chordAsCommandCompatible}
*/
export function chordFromCommandCompatible(command: string): Chord {
const [actions, phrase] = command.split(" ")
return {
actions: deserializeActions(BigInt(`0x${actions}`)),
phrase: Array.from({length: phrase.length / 2}).map((_, i) =>
Number.parseInt(phrase.slice(i * 2, i * 2 + 2), 16),
),
}
export function stringifyPhrase(phrase: number[]): string {
return phrase
.map(it => it.toString(16).padStart(2, "0"))
.join("")
.toUpperCase()
}
export function parseChordActions(actions: string): number[] {
return deserializeActions(BigInt(`0x${actions}`))
}
export function stringifyChordActions(actions: number[]): string {
return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase()
}
/**

View File

@@ -17,14 +17,14 @@ export const chords = writable<Chord[]>([])
export const layout = writable<CharaLayout>([[], [], []])
export const settings = writable({})
export const unsavedChanges = writable(0)
export const highlightActions: Writable<number[]> = writable([])
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
let device: CharaDevice // @hmr:keep
export async function initSerial(manual = false) {
const device = get(serialPort) ?? new CharaDevice()
await device.init(manual)

View File

@@ -1,7 +1,7 @@
import {LineBreakTransformer} from "$lib/serial/line-break-transformer"
import {serialLog} from "$lib/serial/connection"
import type {Chord} from "$lib/serial/chord"
import {chordFromCommandCompatible} from "$lib/serial/chord"
import {parseChordActions, parsePhrase, stringifyChordActions, stringifyPhrase} from "$lib/serial/chord"
export const VENDOR_ID = 0x239a
@@ -58,8 +58,8 @@ export class CharaDevice {
})
.getReader()
this.version = await this.send("VERSION")
this.deviceId = await this.send("ID")
this.version = await this.send("VERSION").then(it => it[0])
this.deviceId = await this.send("ID").then(it => it[0])
}
private async internalRead() {
@@ -138,21 +138,128 @@ export class CharaDevice {
return this.runWith(async (send, read) => {
await send(...command)
const commandString = command.join(" ").replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")
return read().then(it => it.replace(new RegExp(`^${commandString} `), ""))
return read().then(it => it.replace(new RegExp(`^${commandString} `), "").split(" "))
})
}
async getChordCount(): Promise<number> {
return Number.parseInt(await this.send("CML C0"))
const [count] = await this.send("CML C0")
return Number.parseInt(count)
}
async getChord(index: number): Promise<Chord> {
return chordFromCommandCompatible(await this.send(`CML C1 ${index}`))
/**
* Retrieves a chord by index
*/
async getChord(index: number | number[]): Promise<Chord> {
const [actions, phrase] = await this.send(`CML C1 ${index}`)
return {
actions: parseChordActions(actions),
phrase: parsePhrase(phrase),
}
}
/**
* Retrieves the phrase for a set of actions
*/
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send(`CML C2 ${stringifyChordActions(actions)}`)
return phrase === "0" ? undefined : parsePhrase(phrase)
}
async setChord(chord: Chord) {
const [status] = await this.send(
"CML",
"C3",
stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase),
)
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
async deleteChord(chord: Chord) {
const status = await this.send(`CML C4 ${stringifyChordActions(chord.actions)}`)
if (status.at(-1) !== "0") throw new Error(`Failed with status ${status}`)
}
/**
* Sets an action to the layout
* @param layer the layer (usually 1-3)
* @param id id of the key, refer to the individual device for where each key is
* @param action the assigned action id
*/
async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(`VAR B3 A${layer} ${id} ${action}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
/**
* Gets the assigned action from the layout
* @param layer the layer (usually 1-3)
* @param id id of the key, refer to the individual device for where each key is
* @returns the assigned action id
*/
async getLayoutKey(layer: number, id: number) {
const layout = await this.send(`VAR B3 A${layer} ${id}`)
const [position] = layout.split(" ").map(Number)
return position
const [position, status] = await this.send(`VAR B3 A${layer} ${id}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
return Number(position)
}
/**
* Permanently stores settings and layout to the device.
*
* CAUTION: Device may degrade prematurely above 10,000-25,000 commits.
*
* **This does not need to be called for chords**
*/
async commit() {
const [status] = await this.send("VAR B0")
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
/**
* Sets a setting on the device.
*
* Settings are applied until the next reboot or loss of power.
* To permanently store the settings, you *must* call commit.
*/
async setSetting(id: number, value: number) {
const [status] = await this.send(`VAR B2 ${id.toString(16).padStart(2, "0").toUpperCase()} ${value}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
/**
* Retrieves a setting from the device
*/
async getSetting(id: number): Promise<number> {
const [value, status] = await this.send(`VAR B1 ${id.toString(16).padStart(2, "0").toUpperCase()}`)
if (status !== "0") throw new Error(`Setting "${id}" doesn't exist (Status code ${status})`)
return Number(value)
}
/**
* Reboots the device
*/
async reboot() {
await this.send("RST")
await this.disconnect()
// TODO: reconnect
}
/**
* Reboots the device to the bootloader
*/
async bootloader() {
await this.send("RST BOOTLOADER")
await this.disconnect()
// TODO: more...
}
/**
* Returns the current number of bytes available in SRAM.
*
* This is useful for debugging when there is a suspected heap or stack issue.
*/
async getRamBytesAvailable(): Promise<number> {
return Number(await this.send("RAM"))
}
}

View File

@@ -0,0 +1,41 @@
export interface DeviceSettings {
enableSerialLog: boolean
enableSerialRaw: boolean
enableSerialChord: boolean
enableSerialKeyboard: boolean
enableSerialMouse: boolean
enableSerialDebug: boolean
enableSerialHeader: boolean
enableHidKeyboard: boolean
pressThreshold: number
releaseThreshold: number
enableHidMouse: number
scrollDelay: number
enableSpurring: boolean
spurKillerToggle: number
spurKiller: number
enableChording: boolean
charKillerToggle: number
charCounterKiller: number
}
export const SETTING_IDS: Record<keyof DeviceSettings, number> = {
enableSerialLog: 0x01,
enableSerialRaw: 0x02,
enableSerialChord: 0x03,
enableSerialKeyboard: 0x04,
enableSerialMouse: 0x05,
enableSerialDebug: 0x06,
enableSerialHeader: 0x07,
enableHidKeyboard: 0x0a,
pressThreshold: 0x0b,
releaseThreshold: 0x0c,
enableHidMouse: 0x14,
scrollDelay: 0x15,
enableSpurring: 0x1e,
spurKillerToggle: 0x1f,
spurKiller: 0x20,
enableChording: 0x28,
charKillerToggle: 0x29,
charCounterKiller: 0x2a,
}

View File

@@ -56,8 +56,18 @@
<div class="backdrop" transition:fade={{duration: 250}} on:click={() => (powerDialog = !powerDialog)} />
<dialog open transition:slide={{duration: 250}}>
<h3>Boot Menu</h3>
<button><span class="icon">restart_alt</span>Reboot WIP</button>
<button><span class="icon">rule_settings</span>Bootloader WIP</button>
<button
on:click={() => {
$serialPort.reboot()
$serialPort = undefined
}}><span class="icon">restart_alt</span>Reboot</button
>
<button
on:click={() => {
$serialPort.bootloader()
$serialPort = undefined
}}><span class="icon">rule_settings</span>Bootloader</button
>
</dialog>
{/if}
{/if}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import {chords} from "$lib/serial/connection"
import {chords, serialPort} from "$lib/serial/connection"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import FlexSearch from "flexsearch"
import type {Index} from "flexsearch"
@@ -7,6 +7,7 @@
import type {Chord} from "$lib/serial/chord"
import tippy from "tippy.js"
import {calculateChordCoverage} from "$lib/chords/coverage"
import {SETTING_IDS} from "$lib/serial/settings"
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
@@ -44,6 +45,11 @@
{/if}
<button class="icon" on:click={sort}>sort</button>
<button class="icon">filter_list</button>
{#if $serialPort}
{#await $serialPort.getSetting(SETTING_IDS.enableChording) then enableChording}
<label><input type="checkbox" checked={enableChording !== 0} /> Enable Chording</label>
{/await}
{/if}
<section>
<table>

View File

@@ -0,0 +1,4 @@
<script>
</script>
<label><input type="checkbox" />Serial Log</label>