mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-09 03:22:49 +00:00
feat: complete device serial api implementation
This commit is contained in:
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
41
src/lib/serial/settings.ts
Normal file
41
src/lib/serial/settings.ts
Normal 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,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<label><input type="checkbox" />Serial Log</label>
|
||||
|
||||
Reference in New Issue
Block a user