mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-22 18:02:42 +00:00
feat: complete device serial api implementation
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import {describe, it, expect} from "vitest"
|
import {describe, it, expect} from "vitest"
|
||||||
import {
|
import {
|
||||||
chordAsCommandCompatible,
|
|
||||||
chordFromCommandCompatible,
|
|
||||||
deserializeActions,
|
deserializeActions,
|
||||||
|
parseChordActions,
|
||||||
|
parsePhrase,
|
||||||
serializeActions,
|
serializeActions,
|
||||||
|
stringifyChordActions,
|
||||||
|
stringifyPhrase,
|
||||||
} from "./chord"
|
} from "./chord"
|
||||||
|
|
||||||
describe("chords", function () {
|
describe("chords", function () {
|
||||||
@@ -24,18 +26,23 @@ describe("chords", function () {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("commands", function () {
|
describe("phrase", function () {
|
||||||
it("should convert to a command", function () {
|
it("should stringify", function () {
|
||||||
expect(chordAsCommandCompatible({actions: [32, 51], phrase: [0x01, 0x68, 0x72, 0xd4, 0x65]})).toEqual(
|
expect(stringifyPhrase([0x01, 0x68, 0x72, 0xd4, 0x65])).toEqual("016872D465")
|
||||||
"000CC200000000000000000000000000 016872D465",
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should parse a command", function () {
|
it("should parse", function () {
|
||||||
expect(chordFromCommandCompatible("000CC200000000000000000000000000 016872D465")).toEqual({
|
expect(parsePhrase("016872D465")).toEqual([0x01, 0x68, 0x72, 0xd4, 0x65])
|
||||||
actions: [32, 51],
|
})
|
||||||
phrase: [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[]
|
phrase: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function parsePhrase(phrase: string): number[] {
|
||||||
* Turns a chord into a serial-command-compatible string
|
return Array.from({length: phrase.length / 2}).map((_, i) =>
|
||||||
*
|
Number.parseInt(phrase.slice(i * 2, i * 2 + 2), 16),
|
||||||
* @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 stringifyPhrase(phrase: number[]): string {
|
||||||
* Turns a command response into a chord
|
return phrase
|
||||||
*
|
.map(it => it.toString(16).padStart(2, "0"))
|
||||||
* @see {chordAsCommandCompatible}
|
.join("")
|
||||||
*/
|
.toUpperCase()
|
||||||
export function chordFromCommandCompatible(command: string): Chord {
|
}
|
||||||
const [actions, phrase] = command.split(" ")
|
|
||||||
return {
|
export function parseChordActions(actions: string): number[] {
|
||||||
actions: deserializeActions(BigInt(`0x${actions}`)),
|
return 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 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 layout = writable<CharaLayout>([[], [], []])
|
||||||
|
|
||||||
|
export const settings = writable({})
|
||||||
|
|
||||||
export const unsavedChanges = writable(0)
|
export const unsavedChanges = writable(0)
|
||||||
|
|
||||||
export const highlightActions: Writable<number[]> = writable([])
|
export const highlightActions: Writable<number[]> = writable([])
|
||||||
|
|
||||||
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
|
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
|
||||||
|
|
||||||
let device: CharaDevice // @hmr:keep
|
|
||||||
|
|
||||||
export async function initSerial(manual = false) {
|
export async function initSerial(manual = false) {
|
||||||
const device = get(serialPort) ?? new CharaDevice()
|
const device = get(serialPort) ?? new CharaDevice()
|
||||||
await device.init(manual)
|
await device.init(manual)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {LineBreakTransformer} from "$lib/serial/line-break-transformer"
|
import {LineBreakTransformer} from "$lib/serial/line-break-transformer"
|
||||||
import {serialLog} from "$lib/serial/connection"
|
import {serialLog} from "$lib/serial/connection"
|
||||||
import type {Chord} from "$lib/serial/chord"
|
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
|
export const VENDOR_ID = 0x239a
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@ export class CharaDevice {
|
|||||||
})
|
})
|
||||||
.getReader()
|
.getReader()
|
||||||
|
|
||||||
this.version = await this.send("VERSION")
|
this.version = await this.send("VERSION").then(it => it[0])
|
||||||
this.deviceId = await this.send("ID")
|
this.deviceId = await this.send("ID").then(it => it[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
private async internalRead() {
|
private async internalRead() {
|
||||||
@@ -138,21 +138,128 @@ export class CharaDevice {
|
|||||||
return this.runWith(async (send, read) => {
|
return this.runWith(async (send, read) => {
|
||||||
await send(...command)
|
await send(...command)
|
||||||
const commandString = command.join(" ").replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")
|
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> {
|
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) {
|
async getLayoutKey(layer: number, id: number) {
|
||||||
const layout = await this.send(`VAR B3 A${layer} ${id}`)
|
const [position, status] = await this.send(`VAR B3 A${layer} ${id}`)
|
||||||
const [position] = layout.split(" ").map(Number)
|
if (status !== "0") throw new Error(`Failed with status ${status}`)
|
||||||
return position
|
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)} />
|
<div class="backdrop" transition:fade={{duration: 250}} on:click={() => (powerDialog = !powerDialog)} />
|
||||||
<dialog open transition:slide={{duration: 250}}>
|
<dialog open transition:slide={{duration: 250}}>
|
||||||
<h3>Boot Menu</h3>
|
<h3>Boot Menu</h3>
|
||||||
<button><span class="icon">restart_alt</span>Reboot WIP</button>
|
<button
|
||||||
<button><span class="icon">rule_settings</span>Bootloader WIP</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>
|
</dialog>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||||
import FlexSearch from "flexsearch"
|
import FlexSearch from "flexsearch"
|
||||||
import type {Index} from "flexsearch"
|
import type {Index} from "flexsearch"
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import type {Chord} from "$lib/serial/chord"
|
import type {Chord} from "$lib/serial/chord"
|
||||||
import tippy from "tippy.js"
|
import tippy from "tippy.js"
|
||||||
import {calculateChordCoverage} from "$lib/chords/coverage"
|
import {calculateChordCoverage} from "$lib/chords/coverage"
|
||||||
|
import {SETTING_IDS} from "$lib/serial/settings"
|
||||||
|
|
||||||
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
|
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button class="icon" on:click={sort}>sort</button>
|
<button class="icon" on:click={sort}>sort</button>
|
||||||
<button class="icon">filter_list</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>
|
<section>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<script>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label><input type="checkbox" />Serial Log</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user