From f3970c93c7ac08d83a4c936da5c74f0602940d0e Mon Sep 17 00:00:00 2001 From: TheNorthStarYang <2082519537@qq.com> Date: Sun, 5 Jul 2026 10:44:23 +0800 Subject: [PATCH] Add KeySilk portable keypad plugin SDK --- .../Sources/AhaKeyPluginKit/PluginHost.swift | 231 ++++++ .../PluginShowcaseApp.swift | 61 ++ docs/keysilk-keypad-test-plan.md | 118 +++ docs/portable-keypad-integration.md | 85 ++ plugins/keysilk-keypad/README.md | 40 + plugins/keysilk-keypad/package.json | 19 + plugins/keysilk-keypad/plugin.json | 19 + plugins/keysilk-keypad/src/main.ts | 164 ++++ plugins/keysilk-keypad/tsconfig.json | 19 + sdks/README.md | 3 + sdks/portable-keypad/README.md | 283 +++++++ .../adapters/keysilk-v1/adapter.js | 504 ++++++++++++ .../adapters/keysilk-v1/config-codec.js | 753 ++++++++++++++++++ .../keysilk-v1/extended-samples/README.md | 15 + .../extended-samples/scene2_key1_a.json | 53 ++ .../extended-samples/scene2_key2_a.json | 29 + .../extended-samples/scene2_key2_b.json | 29 + .../extended-samples/scene2_key3_a.json | 77 ++ .../extended-samples/scene2_knob_press_a.json | 101 +++ .../sdk_all_extended_actions_a.json | 197 +++++ .../keysilk-v1/layout-3key-1knob.json | 38 + .../adapters/keysilk-v1/protocol.js | 49 ++ sdks/portable-keypad/companion-runtime.js | 196 +++++ sdks/portable-keypad/core/device-model.js | 37 + sdks/portable-keypad/index.d.ts | 427 ++++++++++ sdks/portable-keypad/index.js | 204 +++++ sdks/portable-keypad/package.json | 25 + sdks/typescript/src/index.ts | 30 + 28 files changed, 3806 insertions(+) create mode 100644 docs/keysilk-keypad-test-plan.md create mode 100644 docs/portable-keypad-integration.md create mode 100644 plugins/keysilk-keypad/README.md create mode 100644 plugins/keysilk-keypad/package.json create mode 100644 plugins/keysilk-keypad/plugin.json create mode 100644 plugins/keysilk-keypad/src/main.ts create mode 100644 plugins/keysilk-keypad/tsconfig.json create mode 100644 sdks/portable-keypad/README.md create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/adapter.js create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/config-codec.js create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/extended-samples/README.md create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key1_a.json create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key2_a.json create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key2_b.json create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key3_a.json create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_knob_press_a.json create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/extended-samples/sdk_all_extended_actions_a.json create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/layout-3key-1knob.json create mode 100644 sdks/portable-keypad/adapters/keysilk-v1/protocol.js create mode 100644 sdks/portable-keypad/companion-runtime.js create mode 100644 sdks/portable-keypad/core/device-model.js create mode 100644 sdks/portable-keypad/index.d.ts create mode 100644 sdks/portable-keypad/index.js create mode 100644 sdks/portable-keypad/package.json diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift index e1669abf..d90f2cdb 100644 --- a/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation // 在 PluginClient 之上包一层「宿主能力」:注册一组 `host/*` JSON-RPC method, @@ -33,6 +34,11 @@ public final class PluginHost: @unchecked Sendable { "host/getInfo", "host/log", "host/getSwitchState", + "host/openUrl", + "host/openPath", + "host/pasteText", + "host/registerGlobalHotkey", + "host/unregisterGlobalHotkey", ] /// 注册默认 `host/*` 方法集。请在 `client.start()` 之前调用。 @@ -55,6 +61,53 @@ public final class PluginHost: @unchecked Sendable { "agentReachable": .bool(state != nil), ]) } + + await register("host/openUrl") { params in + let url = try HostActionParams.requiredString(params, key: "url", method: "host/openUrl") + guard let parsed = URL(string: url), ["http", "https"].contains(parsed.scheme?.lowercased() ?? "") else { + throw JSONRPCError(code: JSONRPCError.invalidParams, message: "host/openUrl expects an http(s) URL") + } + let opened = await MainActor.run { + NSWorkspace.shared.open(parsed) + } + return .object(["opened": .bool(opened)]) + } + + await register("host/openPath") { params in + let path = try HostActionParams.requiredString(params, key: "path", method: "host/openPath") + let opened = await MainActor.run { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + return .object(["opened": .bool(opened)]) + } + + await register("host/pasteText") { params in + let text = try HostActionParams.requiredString(params, key: "text", method: "host/pasteText") + await MainActor.run { + HostTextInjector.paste(text) + } + return .object(["pasted": .bool(true)]) + } + + await register("host/registerGlobalHotkey") { [weak self] params in + guard let self else { + throw JSONRPCError(code: JSONRPCError.internalError, message: "PluginHost is unavailable") + } + let hotkey = try HostActionParams.requiredString(params, key: "hotkey", method: "host/registerGlobalHotkey") + let callbackMethod = try HostActionParams.requiredString(params, key: "callbackMethod", method: "host/registerGlobalHotkey") + let token = HostHotkeyRegistry.shared.register( + hotkey: hotkey, + callbackMethod: callbackMethod, + client: self.client + ) + return .object(["token": .string(token)]) + } + + await register("host/unregisterGlobalHotkey") { params in + let token = try HostActionParams.requiredString(params, key: "token", method: "host/unregisterGlobalHotkey") + HostHotkeyRegistry.shared.unregister(token: token) + return .object(["unregistered": .bool(true)]) + } } /// 包一层权限检查后注册。不在 `permissions` 里的 method 会被 -32601 直接拒掉。 @@ -76,6 +129,184 @@ public final class PluginHost: @unchecked Sendable { } } +// MARK: - host action params + +enum HostActionParams { + static func requiredString(_ params: JSONValue?, key: String, method: String) throws -> String { + guard case .object(let object)? = params, + case .string(let value)? = object[key], + !value.isEmpty else { + throw JSONRPCError( + code: JSONRPCError.invalidParams, + message: "\(method) expects string parameter '\(key)'", + data: nil + ) + } + return value + } +} + +// MARK: - host/pasteText + +enum HostTextInjector { + static func paste(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + + let source = CGEventSource(stateID: .combinedSessionState) + guard let down = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true), + let up = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) else { + return + } + down.flags = .maskCommand + up.flags = .maskCommand + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + } +} + +// MARK: - host/registerGlobalHotkey + +final class HostHotkeyRegistry: @unchecked Sendable { + static let shared = HostHotkeyRegistry() + + private struct Registration { + let token: String + let hotkey: ParsedHotkey + let callbackMethod: String + let client: PluginClient + } + + private let lock = NSLock() + private var registrations: [String: Registration] = [:] + private var localMonitor: Any? + private var globalMonitor: Any? + + private init() {} + + func register(hotkey: String, callbackMethod: String, client: PluginClient) -> String { + let parsed = ParsedHotkey.parse(hotkey) + let token = UUID().uuidString + let registration = Registration( + token: token, + hotkey: parsed, + callbackMethod: callbackMethod, + client: client + ) + lock.lock() + registrations[token] = registration + let shouldInstall = localMonitor == nil && globalMonitor == nil + lock.unlock() + + if shouldInstall { + installMonitors() + } + return token + } + + func unregister(token: String) { + lock.lock() + registrations.removeValue(forKey: token) + let shouldRemove = registrations.isEmpty + lock.unlock() + + if shouldRemove { + removeMonitors() + } + } + + private func installMonitors() { + DispatchQueue.main.async { + if self.localMonitor == nil { + self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handle(event: event) + return event + } + } + if self.globalMonitor == nil { + self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in + self?.handle(event: event) + } + } + } + } + + private func removeMonitors() { + DispatchQueue.main.async { + if let localMonitor = self.localMonitor { + NSEvent.removeMonitor(localMonitor) + self.localMonitor = nil + } + if let globalMonitor = self.globalMonitor { + NSEvent.removeMonitor(globalMonitor) + self.globalMonitor = nil + } + } + } + + private func handle(event: NSEvent) { + lock.lock() + let matches = registrations.values.filter { $0.hotkey.matches(event: event) } + lock.unlock() + + for registration in matches { + let params: JSONValue = .object([ + "token": .string(registration.token), + "hotkey": .string(registration.hotkey.display), + ]) + Task { + _ = try? await registration.client.call(registration.callbackMethod, params: params) + } + } + } +} + +struct ParsedHotkey { + let display: String + let keyCode: UInt16 + let modifiers: NSEvent.ModifierFlags + + static func parse(_ hotkey: String) -> ParsedHotkey { + let parts = hotkey + .split(separator: "+") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + var modifiers: NSEvent.ModifierFlags = [] + var key: String? + for part in parts { + switch part { + case "cmd", "command", "meta": modifiers.insert(.command) + case "ctrl", "control": modifiers.insert(.control) + case "alt", "option": modifiers.insert(.option) + case "shift": modifiers.insert(.shift) + default: key = part + } + } + guard let key, let keyCode = Self.keyCode(for: key) else { + return ParsedHotkey(display: hotkey, keyCode: 11, modifiers: [.control, .option, .shift]) + } + return ParsedHotkey(display: hotkey, keyCode: keyCode, modifiers: modifiers) + } + + func matches(event: NSEvent) -> Bool { + let relevant: NSEvent.ModifierFlags = [.command, .control, .option, .shift] + return event.keyCode == keyCode + && event.modifierFlags.intersection(relevant) == modifiers.intersection(relevant) + } + + private static func keyCode(for key: String) -> UInt16? { + [ + "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, + "c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, + "y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22, + "5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29, + "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "l": 37, + "j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, "/": 44, + "n": 45, "m": 46, ".": 47, "`": 50, + ][key] + } +} + // MARK: - host/getInfo public struct HostAppInfo: Codable, Sendable { diff --git a/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift b/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift index 7163be50..ef3853dc 100644 --- a/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift +++ b/ahakeyconfig-mac/Sources/AhaKeyPluginShowcase/PluginShowcaseApp.swift @@ -119,6 +119,61 @@ final class PluginShowcaseModel: ObservableObject { } } + func installKeySilkBaiduHotkey() async { + guard let plugin = pluginSupporting("keysilk/installCompanionProfile") else { + appendActivity("No loaded plugin exposes keysilk/installCompanionProfile") + return + } + + let profile: JSONValue = .object([ + "version": .int(1), + "adapter": .string("keysilk_v1"), + "layout": .string("keysilk_3key_1knob"), + "actions": .array([ + .object([ + "type": .string("hotkey_open_url"), + "adapter": .string("keysilk_v1"), + "layout": .string("keysilk_3key_1knob"), + "scope": .string("extended"), + "action": .string("key1.press"), + "url": .string("https://www.baidu.com/"), + "binding": .string("CtrlAltShiftB"), + "hotkey": .string("Ctrl+Alt+Shift+B"), + ]), + ]), + ]) + + do { + let result = try await plugin.host.client.call( + "keysilk/installCompanionProfile", + params: .object(["profile": profile]), + timeout: 4 + ) + appendActivity("keysilk/installCompanionProfile -> \(compactJSON(result))") + statusJSON = prettyJSON(result) + } catch { + appendActivity("keysilk/installCompanionProfile failed: \(error)") + } + } + + func uninstallKeySilkCompanionProfile() async { + guard let plugin = pluginSupporting("keysilk/uninstallCompanionProfile") else { + appendActivity("No loaded plugin exposes keysilk/uninstallCompanionProfile") + return + } + + do { + let result = try await plugin.host.client.call( + "keysilk/uninstallCompanionProfile", + timeout: 4 + ) + appendActivity("keysilk/uninstallCompanionProfile -> \(compactJSON(result))") + statusJSON = prettyJSON(result) + } catch { + appendActivity("keysilk/uninstallCompanionProfile failed: \(error)") + } + } + private func pluginSupporting(_ method: String) -> PluginManager.LoadedPlugin? { loadedPlugins.first { $0.initialize?.methods?.contains(method) == true } } @@ -224,6 +279,12 @@ struct PluginShowcaseView: View { Button("Call demo/greet") { Task { await model.greet() } } + Button("Install KeySilk Baidu Hotkey") { + Task { await model.installKeySilkBaiduHotkey() } + } + Button("Uninstall KeySilk Hotkeys") { + Task { await model.uninstallKeySilkCompanionProfile() } + } } Text(model.statusJSON) .font(.system(.body, design: .monospaced)) diff --git a/docs/keysilk-keypad-test-plan.md b/docs/keysilk-keypad-test-plan.md new file mode 100644 index 00000000..8e5ee2a2 --- /dev/null +++ b/docs/keysilk-keypad-test-plan.md @@ -0,0 +1,118 @@ +# KeySilk Keypad Test Plan + +This document verifies the KeySilk portable keypad SDK after it is integrated +into the AhaKey `vibebar` branch. + +## 1. SDK Package Check + +Run on Windows or macOS: + +```powershell +cd C:\Users\20825\Desktop\codex\ahakey-desktop-vibebar\sdks\portable-keypad + +node --check index.js +node --check companion-runtime.js +node --check core\device-model.js +node --check adapters\keysilk-v1\adapter.js +node --check adapters\keysilk-v1\config-codec.js + +$env:npm_config_cache="C:\Users\20825\Desktop\codex\ahakey-desktop-vibebar\.npm-cache" +npm pack --dry-run +``` + +Expected: + +```text +package: @ahakey/portable-keypad-sdk@0.1.0 +total files: 17 +``` + +Clean the temporary npm cache if needed: + +```powershell +cd C:\Users\20825\Desktop\codex\ahakey-desktop-vibebar +Remove-Item .npm-cache -Recurse -Force +``` + +## 2. Plugin Build Check + +The KeySilk plugin depends on the local AhaKey plugin SDK and portable keypad +SDK. + +```bash +cd /path/to/ahakey-desktop-vibebar/sdks/typescript +npm install +npm run build:sdk + +cd ../../plugins/keysilk-keypad +npm install +npm run build +``` + +Expected: + +```text +plugins/keysilk-keypad/dist/main.js +``` + +## 3. AhaKey Plugin Host Load Check + +This step needs macOS with Swift available, because the current plugin host is +implemented in `ahakeyconfig-mac`. + +```bash +cd /path/to/ahakey-desktop-vibebar +export AHAKEY_PLUGINS_DIR="$PWD/plugins" +swift run --package-path ahakeyconfig-mac PluginShowcase +``` + +Expected in the Plugin Showcase window: + +- `KeySilk Portable Keypad` appears under `Loaded Plugins` +- method list includes `keysilk/installCompanionProfile` +- activity log includes `keysilk-keypad plugin online` + +## 4. Minimal Companion UI Test + +The Plugin Showcase now has a smoke-test button for the KeySilk companion +runtime. + +Steps: + +1. Complete the plugin build check. +2. Start `PluginShowcase` with `AHAKEY_PLUGINS_DIR="$PWD/plugins"`. +3. Click `Reload Plugins`. +4. Click `Install KeySilk Baidu Hotkey`. +5. Press `Ctrl+Alt+Shift+B`. + +Expected: + +- the install button returns JSON containing an installed hotkey token +- pressing `Ctrl+Alt+Shift+B` opens `https://www.baidu.com/` + +To clean up: + +1. Click `Uninstall KeySilk Hotkeys`. +2. Press `Ctrl+Alt+Shift+B` again. + +Expected: + +- Baidu no longer opens through the KeySilk plugin hotkey registration + +## Current Boundary + +This test covers the software-side companion path: + +```text +reserved hotkey -> AhaKey host -> keysilk/hotkeyTriggered -> SDK companion runtime -> host/openUrl +``` + +It does not yet test writing KeySilk config through AhaKey Desktop. That still +requires host HID APIs: + +```text +host/hid/list +host/hid/readConfig +host/hid/writeConfig +``` + diff --git a/docs/portable-keypad-integration.md b/docs/portable-keypad-integration.md new file mode 100644 index 00000000..0b158b36 --- /dev/null +++ b/docs/portable-keypad-integration.md @@ -0,0 +1,85 @@ +# Portable Keypad Integration + +## Placement + +The portable keypad work is split into two layers: + +```text +sdks/portable-keypad/ +plugins/keysilk-keypad/ +``` + +`sdks/portable-keypad` contains the device SDK. It owns KeySilk / COIDEA config +parsing, config generation, host-action profiles, and embedded companion trigger +planning. + +`plugins/keysilk-keypad` is the AhaKey plugin wrapper. It runs inside the +existing `plugin.json` + JSON-RPC plugin model and exposes KeySilk capabilities +to the AhaKey host. + +## Why Not Put It In Existing Device Services + +The Windows Java/Python and macOS device services target AhaKey's own device +protocol: modes, OLED frames, BLE/TCP bridge commands, and custom key commands. +KeySilk uses a different factory config format with raw `.bin` records and +host-action triggers. + +Keeping KeySilk in a portable keypad SDK avoids mixing two unrelated device +protocols in the same service classes and lets future keypad adapters reuse the +same plugin surface. + +## Current Plugin Surface + +The `keysilk-keypad` plugin currently exposes: + +```text +keysilk/listAdapters +keysilk/getLayout +keysilk/getBindings +keysilk/getCapabilities +keysilk/validateCompanionProfile +keysilk/getCompanionTriggers +keysilk/installCompanionProfile +keysilk/uninstallCompanionProfile +``` + +The companion profile installer registers the SDK hotkey trigger plan with the +host. When a hotkey fires, the host calls the plugin's `keysilk/hotkeyTriggered` +method, and the plugin dispatches the action through host methods. + +## Host APIs Needed Next + +To make companion actions run automatically when AhaKey Desktop starts, the host +should add permission-gated methods such as: + +```text +host/hid/list +host/hid/readConfig +host/hid/writeConfig +``` + +The macOS plugin host now has the first five runtime methods: + +```text +host/openUrl +host/openPath +host/pasteText +host/registerGlobalHotkey +host/unregisterGlobalHotkey +``` + +The remaining HID methods are still needed before the plugin can write KeySilk +configs directly through AhaKey Desktop. + +The runtime dispatch path is: + +```text +hotkey -> plugin -> SDK companion runtime -> host/openUrl or host/pasteText +``` + +The current smoke-test UI is in `PluginShowcase`: click +`Install KeySilk Baidu Hotkey` to register a sample `Ctrl+Alt+Shift+B` profile. +See `docs/keysilk-keypad-test-plan.md` for the full test flow. + +For Bluetooth daily use, use the hotkey trigger pool. USB/2.4G can also support +raw host-action report prefixes once the host exposes HID/RawInput events. diff --git a/plugins/keysilk-keypad/README.md b/plugins/keysilk-keypad/README.md new file mode 100644 index 00000000..2e257b36 --- /dev/null +++ b/plugins/keysilk-keypad/README.md @@ -0,0 +1,40 @@ +# KeySilk Portable Keypad Plugin + +This plugin is the AhaKey host-facing wrapper for the portable keypad SDK. + +The SDK package in `sdks/portable-keypad` owns KeySilk config parsing, +configuration generation, host-action profiles, and companion trigger planning. +This plugin owns the JSON-RPC surface exposed to AhaKey Desktop. + +Current status: + +- exposes SDK capabilities for the KeySilk / COIDEA 3-key + 1-knob adapter +- validates host-action profiles +- returns embedded companion trigger plans +- installs companion profiles by registering the reserved hotkey pool with the + AhaKey host +- dispatches hotkey companion actions through `host/openUrl`, `host/openPath`, + and `host/pasteText` +- leaves USB/2.4G raw report listening and HID config writes to future host + APIs + +Plugin methods: + +```text +keysilk/listAdapters +keysilk/getLayout +keysilk/getBindings +keysilk/getCapabilities +keysilk/validateCompanionProfile +keysilk/getCompanionTriggers +keysilk/installCompanionProfile +keysilk/uninstallCompanionProfile +keysilk/hotkeyTriggered +``` + +Build: + +```bash +npm install +npm run build +``` diff --git a/plugins/keysilk-keypad/package.json b/plugins/keysilk-keypad/package.json new file mode 100644 index 00000000..1ef47568 --- /dev/null +++ b/plugins/keysilk-keypad/package.json @@ -0,0 +1,19 @@ +{ + "name": "@ahakey/keysilk-keypad-plugin", + "version": "0.1.0", + "description": "AhaKey plugin wrapper for KeySilk / COIDEA portable keypad support.", + "type": "module", + "main": "./dist/main.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@ahakey/plugin-sdk": "file:../../sdks/typescript", + "@ahakey/portable-keypad-sdk": "file:../../sdks/portable-keypad" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "typescript": "^6.0.3" + } +} diff --git a/plugins/keysilk-keypad/plugin.json b/plugins/keysilk-keypad/plugin.json new file mode 100644 index 00000000..855cb44d --- /dev/null +++ b/plugins/keysilk-keypad/plugin.json @@ -0,0 +1,19 @@ +{ + "id": "dev.ahakey.keysilk-keypad", + "name": "KeySilk Portable Keypad", + "version": "0.1.0", + "entrypoint": { + "command": "node", + "args": ["${pluginDir}/dist/main.js"] + }, + "permissions": [ + "host/getInfo", + "host/log", + "host/getSwitchState", + "host/openUrl", + "host/openPath", + "host/pasteText", + "host/registerGlobalHotkey", + "host/unregisterGlobalHotkey" + ] +} diff --git a/plugins/keysilk-keypad/src/main.ts b/plugins/keysilk-keypad/src/main.ts new file mode 100644 index 00000000..35fdd97a --- /dev/null +++ b/plugins/keysilk-keypad/src/main.ts @@ -0,0 +1,164 @@ +import { createRequire } from "node:module"; +import { definePlugin, servePlugin, type AhaKeyHost } from "@ahakey/plugin-sdk"; + +const require = createRequire(import.meta.url); +const keypadSdk = require("@ahakey/portable-keypad-sdk") as PortableKeypadSdk; + +type JsonRecord = Record; + +interface PortableKeypadSdk { + listAdapters(): unknown[]; + getLayout(deviceOrConfig?: unknown): unknown; + getBindings(deviceOrConfig?: unknown): unknown; + getCapabilities(deviceOrConfig?: unknown): unknown; + validateCompanionProfile(profile: unknown): CompanionValidationResult; + getCompanionTriggers(profile: unknown): CompanionTriggerPlan; + handleCompanionHotkey(profile: unknown, bindingOrHotkey: string, host: CompanionHostHandlers): unknown[]; +} + +let host: AhaKeyHost | undefined; +let activeProfile: unknown; +const registeredHotkeys = new Map(); + +interface CompanionValidationResult { + ok: boolean; + errors: string[]; + warnings: string[]; + profile: unknown; +} + +interface CompanionTriggerPlan { + hotkeys: Array<{ + binding: string; + hotkey: string; + actions: unknown[]; + }>; + rawReports: Array<{ + reportPrefixHex: string; + actions: unknown[]; + }>; +} + +interface CompanionHostHandlers { + openUrl(url: string): Promise; + openPath(path: string): Promise; + pasteText(text: string): Promise; +} + +function asRecord(value: unknown, method: string): JsonRecord { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as JsonRecord; + } + throw new Error(`${method} expects an object parameter`); +} + +async function unregisterAllHotkeys(): Promise { + if (host === undefined) return; + for (const token of registeredHotkeys.values()) { + try { + await host.unregisterGlobalHotkey(token); + } catch { + // Best effort cleanup; stale tokens die with the host process. + } + } + registeredHotkeys.clear(); +} + +async function installCompanionProfile(profile: unknown): Promise<{ + installedHotkeys: Array<{ hotkey: string; token: string }>; + rawReports: CompanionTriggerPlan["rawReports"]; + warnings: string[]; +}> { + if (host === undefined) { + throw new Error("AhaKey host is not initialized"); + } + const validation = keypadSdk.validateCompanionProfile(profile); + if (!validation.ok) { + throw new Error(`Invalid companion profile: ${validation.errors.join("; ")}`); + } + + await unregisterAllHotkeys(); + activeProfile = validation.profile; + + const triggers = keypadSdk.getCompanionTriggers(activeProfile); + const installedHotkeys: Array<{ hotkey: string; token: string }> = []; + for (const trigger of triggers.hotkeys) { + const result = await host.registerGlobalHotkey(trigger.hotkey, "keysilk/hotkeyTriggered"); + registeredHotkeys.set(trigger.hotkey, result.token); + installedHotkeys.push({ hotkey: trigger.hotkey, token: result.token }); + } + + return { + installedHotkeys, + rawReports: triggers.rawReports, + warnings: validation.warnings, + }; +} + +async function dispatchHotkey(params: unknown): Promise<{ dispatched: number }> { + if (activeProfile === undefined) { + return { dispatched: 0 }; + } + if (host === undefined) { + throw new Error("AhaKey host is not initialized"); + } + const record = asRecord(params, "keysilk/hotkeyTriggered"); + const hotkey = typeof record.hotkey === "string" ? record.hotkey : ""; + const results = keypadSdk.handleCompanionHotkey(activeProfile, hotkey, { + openUrl: (url) => host!.openUrl(url), + openPath: (path) => host!.openPath(path), + pasteText: (text) => host!.pasteText(text), + }); + await Promise.all(results); + return { dispatched: results.length }; +} + +servePlugin(definePlugin({ + name: "KeySilk Portable Keypad", + version: "0.1.0", + methods: { + "keysilk/listAdapters": () => keypadSdk.listAdapters(), + "keysilk/getLayout": () => keypadSdk.getLayout({ adapter: "keysilk_v1" }), + "keysilk/getBindings": () => keypadSdk.getBindings({ adapter: "keysilk_v1" }), + "keysilk/getCapabilities": () => keypadSdk.getCapabilities({ adapter: "keysilk_v1" }), + "keysilk/validateCompanionProfile": (params) => { + const record = asRecord(params, "keysilk/validateCompanionProfile"); + return keypadSdk.validateCompanionProfile(record.profile); + }, + "keysilk/getCompanionTriggers": (params) => { + const record = asRecord(params, "keysilk/getCompanionTriggers"); + return keypadSdk.getCompanionTriggers(record.profile); + }, + "keysilk/installCompanionProfile": async (params) => { + const record = asRecord(params, "keysilk/installCompanionProfile"); + return installCompanionProfile(record.profile); + }, + "keysilk/uninstallCompanionProfile": async () => { + await unregisterAllHotkeys(); + activeProfile = undefined; + return { uninstalled: true }; + }, + "keysilk/hotkeyTriggered": dispatchHotkey, + }, + onInitialize(_params, connectedHost) { + host = connectedHost; + }, + async onInitialized() { + await host?.log("keysilk-keypad plugin online"); + const required = [ + "host/openUrl", + "host/openPath", + "host/pasteText", + "host/registerGlobalHotkey", + "host/unregisterGlobalHotkey", + ]; + const missing = required.filter((method) => !host?.supports(method)); + if (missing.length > 0) { + await host?.log(`keysilk-keypad: companion runtime is limited; missing ${missing.join(", ")}`, "warn"); + } + }, + async onShutdown() { + await unregisterAllHotkeys(); + await host?.log("keysilk-keypad plugin shutdown"); + }, +})); diff --git a/plugins/keysilk-keypad/tsconfig.json b/plugins/keysilk-keypad/tsconfig.json new file mode 100644 index 00000000..731b8aeb --- /dev/null +++ b/plugins/keysilk-keypad/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "types": [ + "node" + ], + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/sdks/README.md b/sdks/README.md index 5198e954..fb72a919 100644 --- a/sdks/README.md +++ b/sdks/README.md @@ -4,4 +4,7 @@ This directory contains SDKs for building AhaKey desktop plugins. - [`typescript`](./typescript): TypeScript SDK for JSON-RPC plugins running as child processes over stdin/stdout. +- [`portable-keypad`](./portable-keypad): Device configuration SDK for portable + keypad adapters used by AhaKey plugins, starting with KeySilk / COIDEA 3-key + + 1-knob support. diff --git a/sdks/portable-keypad/README.md b/sdks/portable-keypad/README.md new file mode 100644 index 00000000..d7aef84d --- /dev/null +++ b/sdks/portable-keypad/README.md @@ -0,0 +1,283 @@ +# Portable Keypad SDK + +CommonJS SDK for configuring supported small keyboards. The current adapter +targets the KeySilk / COIDEA 3-key + 1-knob device. + +## Supported Device + +```text +Adapter: keysilk_v1 +Brand: KeySilk / COIDEA +VID/PID: 4132:2107 +Layout: keysilk_3key_1knob +Config HID interface: UsagePage=0xff00 Usage=0x0001 +``` + +## Use From This Repository + +```js +const sdk = require("./sdk"); +``` + +Repository-level runnable examples live in: + +```text +examples/ +``` + +## Device Flow + +```js +const sdk = require("./sdk"); + +const devices = sdk.listDevices(); +const config = sdk.readDevice(devices[0]); + +sdk.setActionBinding(config, { + scope: "extended", + action: "knob1.rotate_right", + binding: "B", +}); + +sdk.writeDevice(config); +``` + +## Offline Flow + +```js +const fs = require("fs"); +const sdk = require("./sdk"); + +const raw = fs.readFileSync("config.bin"); +const config = sdk.importConfig(raw, { adapter: "keysilk_v1" }); + +sdk.setActionBinding(config, { + scope: "base", + action: "key1.press", + binding: "Alt", +}); + +fs.writeFileSync("out.bin", sdk.exportConfig(config)); +``` + +## Apply A UI Model + +```js +const model = { + adapter: "keysilk_v1", + layout: "keysilk_3key_1knob", + base: { + "key1.press": "Alt", + }, + extended: { + "knob1.rotate_right": "B", + }, +}; + +sdk.applyBindingModel(config, model); +``` + +## Host-Assisted Open URL + +Open URL actions require a companion app on Windows. The keypad stores a +host-action trigger; the URL is stored by the integrating app or companion +profile. + +```js +const fs = require("fs"); +const sdk = require("./sdk"); + +const config = sdk.importConfig(fs.readFileSync("current.bin")); + +sdk.setHostOpenUrlAction(config, { + scope: "extended", + action: "key1.press", + url: "https://www.example.com/", +}); + +fs.writeFileSync("open_example.bin", sdk.exportConfig(config)); +fs.writeFileSync( + "open_example.profile.json", + JSON.stringify(sdk.exportHostActionProfile(config), null, 2), +); +``` + +CLI equivalent: + +```powershell +node tools\keysilk-cli.js set-url analysis\current_after_baidu_user.bin key1.press https://www.example.com/ analysis\open_example.bin analysis\open_example.profile.json +node tools\keysilk-cli.js write-noack analysis\open_example.bin +node tools\keysilk-cli.js url-companion-profile analysis\open_example.profile.json 300 +``` + +## Bluetooth-Compatible Open URL + +In Bluetooth mode the factory host-action trigger is not exposed through +Windows RawInput. Use the hotkey companion mode instead: the keypad emits a +reserved shortcut and the companion opens the URL/path. + +```js +sdk.setHotkeyOpenUrlAction(config, { + scope: "extended", + action: "key1.press", + url: "https://www.example.com/", +}); +``` + +CLI equivalent: + +```powershell +node tools\keysilk-cli.js set-hotkey-url analysis\current.bin key1.press https://www.example.com/ analysis\open_example_bt.bin analysis\open_example_bt.profile.json +node tools\keysilk-cli.js write-noack analysis\open_example_bt.bin +node tools\keysilk-cli.js hotkey-companion-profile analysis\open_example_bt.profile.json 300 +``` + +## Companion Text Paste + +Text paste uses the same Bluetooth-compatible hotkey path. The text is stored in +the companion profile and pasted at the current cursor. + +```powershell +node tools\keysilk-cli.js set-hotkey-text analysis\current.bin key1.press "hello from KeySilk" analysis\hotkey_text.bin analysis\hotkey_text.profile.json +node tools\keysilk-cli.js write-noack analysis\hotkey_text.bin +node tools\keysilk-cli.js hotkey-companion-profile analysis\hotkey_text.profile.json 300 +``` + +## Embedded Companion Runtime + +If the SDK is embedded into a larger desktop app, the app does not need to ask +users to download or start a separate companion process. Start companion support +when the plugin/app loads: + +```js +const sdk = require("@ahakey/portable-keypad-sdk"); + +const profile = sdk.exportHostActionProfile(config); +const validation = sdk.validateCompanionProfile(profile); +if (!validation.ok) throw new Error(validation.errors.join("; ")); + +const triggers = sdk.getCompanionTriggers(profile); + +// Register these with the host app's native global-hotkey layer. +for (const trigger of triggers.hotkeys) { + registerGlobalHotkey(trigger.hotkey, () => { + sdk.handleCompanionHotkey(profile, trigger.hotkey, { + openUrl: (url) => shell.openExternal(url), + openPath: (path) => shell.openPath(path), + pasteText: (text) => pasteTextAtCursor(text), + }); + }); +} + +// Register these with the host app's RawInput/HID listener for USB/2.4G. +for (const trigger of triggers.rawReports) { + registerRawReportPrefix(trigger.reportPrefixHex, (reportHex) => { + sdk.handleCompanionRawReport(profile, reportHex, { + openUrl: (url) => shell.openExternal(url), + openPath: (path) => shell.openPath(path), + }); + }); +} +``` + +The current CLI companion is only a prototype runner for repository testing. A +shipping product should embed this runtime contract and keep the native event +listeners inside the main app or plugin host. + +## Public API + +```text +listAdapters() +listDevices() +readDevice(device) +writeDevice(config) +importConfig(raw, options) +exportConfig(config) +getLayout(deviceOrConfig) +getBindings(deviceOrConfig) +getCapabilities(deviceOrConfig) +getActionBinding(config, actionId, options) +setActionBinding(config, request) +applyBindingModel(config, model) +setHostOpenUrlAction(config, request) +setHostOpenPathAction(config, request) +setHotkeyOpenUrlAction(config, request) +setHotkeyOpenPathAction(config, request) +setHotkeyTextAction(config, request) +setSimpleMacroTapsAction(config, request) +exportHostActionProfile(config) +normalizeCompanionProfile(profile) +validateCompanionProfile(profile) +getCompanionTriggers(profile) +dispatchCompanionAction(action, host) +handleCompanionHotkey(profile, bindingOrHotkey, host) +handleCompanionRawReport(profile, reportHex, host) +``` + +## Supported Actions + +```text +key1.press +key2.press +key3.press +knob1.rotate_left +knob1.press +knob1.rotate_right +knob1.press_rotate_left +knob1.press_rotate_right +``` + +## Supported Bindings + +```text +Hardware verified: + Ctrl Alt A B Esc Enter Space + Mute VolumeDown VolumeUp PlayPause + NextLayer PreviousLayer + HostOpenUrl HostOpenPath + MouseLeftClick MouseRightClick MouseMiddleClick + MouseWheelUp MouseWheelDown + CtrlMouseWheelUp CtrlMouseWheelDown + simple macro taps via setSimpleMacroTapsAction() + +Observed in captured configs: + Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 + TextObserved + OpenCalculatorObserved + +Inferred, pending batch hardware verification: + C-Z + Digit0 Digit1 Digit9 + Shift Win + CtrlA CtrlC CtrlV CtrlX CtrlZ CtrlY CtrlS CtrlP CtrlF +``` + +`getBindings()` returns `verification` metadata for each binding: + +```js +const bindings = sdk.getBindings(config); +console.log(bindings.CtrlC.verification); // inferred +``` + +## Notes + +- `writeDevice()` defaults to the no-ACK write path because it has been more + reliable with the current sample device. +- Extended record creation has been hardware-verified for all 8 actions on the + current KeySilk sample. +- Host-assisted open URL is supported through `HostOpenUrl` plus a companion + listener. It is not a standalone device-side URL launcher. +- Host-assisted open file/program path is supported through `HostOpenPath` plus + a companion listener. The path is stored in the companion profile, not in the + keypad config. +- Bluetooth-compatible open URL/path is supported through a reserved hotkey pool + (`CtrlAltShiftB` through `CtrlAltShiftI`) plus the embedded companion runtime. +- Host-side text paste is supported through the same reserved hotkey pool plus + the embedded companion runtime. +- Config writes are supported through USB or the factory-supported wireless + configuration path. Bluetooth config writes are not supported by this adapter. +- Mouse button, wheel, and simple macro tap writes are hardware verified for + captured variants. Text-slot records are parsed from captured configs, but + SDK writes remain disabled until the encodings are hardware verified. +- Built-in host open actions can be parsed. Standalone device-side open-program + path storage is not implemented yet. diff --git a/sdks/portable-keypad/adapters/keysilk-v1/adapter.js b/sdks/portable-keypad/adapters/keysilk-v1/adapter.js new file mode 100644 index 00000000..c4c130e6 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/adapter.js @@ -0,0 +1,504 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const { + actionOrder3Key1Knob, + getBindingForAction, + keySilkBindings, + patchSimpleMacroTaps, + parseConfig, + patchShortcutBinding, + summarizeConfig, +} = require("./config-codec"); + +const layout3Key1Knob = require("./layout-3key-1knob.json"); +const projectRoot = path.resolve(__dirname, "..", "..", ".."); +const hidTool = path.join(projectRoot, "tools", "bin", "KeySilkHidTool.exe"); + +const adapterInfo = { + id: "keysilk_v1", + brand: "KeySilk / COIDEA", + vid: 0x4132, + pid: 0x2107, + layout: "keysilk_3key_1knob", + actions: actionOrder3Key1Knob, + bindings: Object.keys(keySilkBindings), +}; + +const capabilityInfo = { + adapter: adapterInfo.id, + layout: adapterInfo.layout, + states: { + done: "Implemented and hardware verified.", + observed: "Parsed from captured configs, but not fully exercised as a new SDK write target.", + inferred: "Generated from a consistent byte pattern and pending batch hardware verification.", + blocked: "Needs factory samples or more reverse engineering before writes are enabled.", + }, + features: [ + { id: "device.detect", label: "Detect KeySilk config interface", state: "done" }, + { id: "device.read", label: "Read raw config", state: "done" }, + { id: "device.write", label: "Write raw config", state: "done" }, + { id: "actions.base", label: "Patch base action records", state: "done" }, + { id: "actions.extended", label: "Create and patch extended action records", state: "done" }, + { id: "bindings.keyboard.verified", label: "Verified keyboard keys", state: "done", bindings: ["A", "B", "C", "Z", "Digit0", "Digit1", "Esc", "Enter", "Space"] }, + { id: "bindings.keyboard.observed", label: "Observed digit keys", state: "observed", bindings: ["Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8"] }, + { id: "bindings.keyboard.inferred", label: "Inferred keyboard keys", state: "inferred", bindings: ["D-Y", "Digit9"] }, + { id: "bindings.shortcuts.verified", label: "Verified modifier shortcuts", state: "done", bindings: ["Ctrl", "Shift", "Alt", "Win", "CtrlC", "CtrlV", "CtrlZ"] }, + { id: "bindings.shortcuts.inferred", label: "Inferred shortcut combinations", state: "inferred", bindings: ["CtrlA", "CtrlX", "CtrlY", "CtrlS", "CtrlP", "CtrlF", "CtrlAltShiftB", "CtrlAltShiftC", "CtrlAltShiftD", "CtrlAltShiftE", "CtrlAltShiftF", "CtrlAltShiftG", "CtrlAltShiftH", "CtrlAltShiftI"] }, + { id: "bindings.media", label: "Media controls", state: "done", bindings: ["Mute", "VolumeDown", "VolumeUp", "PlayPause"] }, + { id: "bindings.layer", label: "Scene switching controls", state: "done", bindings: ["NextLayer", "PreviousLayer"] }, + { id: "bindings.host_open_url", label: "Host-assisted open URL", state: "done", bindings: ["HostOpenUrl"] }, + { id: "bindings.host_open_path", label: "Host-assisted open file/program path", state: "done", bindings: ["HostOpenPath"] }, + { id: "bindings.mouse", label: "Mouse buttons", state: "done", bindings: ["MouseLeftClick", "MouseRightClick", "MouseMiddleClick"] }, + { id: "bindings.mouse_wheel", label: "Mouse wheel", state: "done", bindings: ["MouseWheelUp", "MouseWheelDown"] }, + { id: "bindings.modified_mouse_wheel", label: "Modified mouse wheel", state: "done", bindings: ["CtrlMouseWheelUp", "CtrlMouseWheelDown"] }, + { id: "bindings.macro", label: "Simple macro taps", state: "done", bindings: ["MacroObserved"] }, + { id: "bindings.text", label: "Text records", state: "observed", bindings: ["TextObserved"] }, + { id: "bindings.open_program", label: "Host-assisted built-in open actions", state: "observed", bindings: ["OpenCalculatorObserved"] }, + { id: "bindings.text_write", label: "Text slot writes", state: "blocked" }, + { id: "bindings.macro_complex", label: "Complex macro sequences", state: "blocked" }, + { id: "bindings.open_program_write", label: "Standalone open program/path writes", state: "blocked" }, + { id: "device.restore_defaults", label: "Factory restore/defaults", state: "blocked" }, + { id: "device.firmware", label: "Firmware update operations", state: "blocked" }, + ], +}; + +const hotkeyCompanionBindings = [ + "CtrlAltShiftB", + "CtrlAltShiftC", + "CtrlAltShiftD", + "CtrlAltShiftE", + "CtrlAltShiftF", + "CtrlAltShiftG", + "CtrlAltShiftH", + "CtrlAltShiftI", +]; + +function hotkeyToAccelerator(binding) { + const suffix = String(binding || "").replace(/^CtrlAltShift/, ""); + return `Ctrl+Alt+Shift+${suffix}`; +} + +function runHid(args, options = {}) { + if (!fs.existsSync(hidTool)) { + throw new Error( + `KeySilk HID transport helper not found at ${hidTool}. ` + + "Inside AhaKey desktop, call the SDK's pure config/profile APIs from a plugin " + + "and route device IO through host-provided HID methods instead.", + ); + } + const result = spawnSync(hidTool, args, { + encoding: "utf8", + stdio: options.inherit ? "inherit" : "pipe", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error(output || `KeySilk HID tool failed with exit code ${result.status}`); + } + return { + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +function parseDeviceList(output) { + const devices = []; + let current = null; + for (const line of output.split(/\r?\n/)) { + const pathMatch = line.match(/^\[(\d+)\]\s+(.+)$/); + if (pathMatch) { + current = { + adapter: adapterInfo.id, + brand: adapterInfo.brand, + vid: adapterInfo.vid, + pid: adapterInfo.pid, + path: pathMatch[2], + interfaceIndex: Number(pathMatch[1]), + }; + devices.push(current); + continue; + } + + const capsMatch = line.match(/UsagePage=0x([0-9a-fA-F]+)\s+Usage=0x([0-9a-fA-F]+)\s+Input=(\d+)\s+Output=(\d+)\s+Feature=(\d+)/); + if (current && capsMatch) { + current.usagePage = Number.parseInt(capsMatch[1], 16); + current.usage = Number.parseInt(capsMatch[2], 16); + current.inputReportLength = Number(capsMatch[3]); + current.outputReportLength = Number(capsMatch[4]); + current.featureReportLength = Number(capsMatch[5]); + current.isConfigInterface = current.usagePage === 0xff00 && current.usage === 0x0001; + } + } + return devices; +} + +function readConfigFile(filePath) { + return fs.readFileSync(filePath); +} + +function writeConfigFile(filePath, rawConfig) { + fs.writeFileSync(filePath, rawConfig); +} + +function inspectConfig(rawConfig) { + return parseConfig(rawConfig); +} + +function importConfig(rawConfig, options = {}) { + const raw = Buffer.from(rawConfig); + return { + adapter: adapterInfo.id, + device: options.device || null, + raw, + parsed: inspectConfig(raw), + }; +} + +function exportConfig(config) { + return Buffer.from(Buffer.isBuffer(config) ? config : config.raw); +} + +function inspectConfigFile(filePath) { + return inspectConfig(readConfigFile(filePath)); +} + +function summarizeConfigFile(filePath) { + return summarizeConfig(readConfigFile(filePath)); +} + +function getLayout() { + return JSON.parse(JSON.stringify(layout3Key1Knob)); +} + +function getBindings() { + return Object.fromEntries( + Object.entries(keySilkBindings).map(([id, binding]) => [ + id, + { + id, + label: binding.label, + category: binding.category, + verification: binding.verification, + aliases: binding.aliases || [], + }, + ]), + ); +} + +function getCapabilities() { + return JSON.parse(JSON.stringify(capabilityInfo)); +} + +function getActionBinding(configOrRaw, actionId, options = {}) { + const scope = options.scope || (actionId.startsWith("extended.") ? "extended" : "base"); + const raw = Buffer.isBuffer(configOrRaw) ? configOrRaw : configOrRaw.raw; + const normalizedAction = actionId.startsWith("extended.") || scope === "base" + ? actionId + : `extended.${actionId}`; + return getBindingForAction(raw, normalizedAction); +} + +function setBinding(rawConfig, actionId, bindingId) { + return patchShortcutBinding(rawConfig, actionId, bindingId); +} + +function setActionBinding(configOrRaw, actionId, bindingId, options = {}) { + const scope = options.scope || (actionId.startsWith("extended.") ? "extended" : "base"); + const normalizedAction = actionId.startsWith("extended.") || scope === "base" + ? actionId + : `extended.${actionId}`; + const raw = Buffer.isBuffer(configOrRaw) ? configOrRaw : configOrRaw.raw; + const patched = setBinding(raw, normalizedAction, bindingId); + if (Buffer.isBuffer(configOrRaw)) return patched; + + configOrRaw.raw = patched; + configOrRaw.parsed = inspectConfig(patched); + return configOrRaw; +} + +function assertUrl(url) { + let parsed; + try { + parsed = new URL(url); + } catch (error) { + throw new Error(`Invalid URL for host-assisted open action: ${url}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Only http/https URLs are supported for host-assisted open action: ${url}`); + } + return parsed.toString(); +} + +function setHostOpenUrlAction(config, request) { + if (!config || Buffer.isBuffer(config)) { + throw new Error("setHostOpenUrlAction requires an imported config object"); + } + const action = request.action; + const scope = request.scope || "extended"; + const url = assertUrl(request.url); + + setActionBinding(config, action, "HostOpenUrl", { scope }); + config.hostActions = (config.hostActions || []).filter((item) => !(item.type === "open_url" && item.action === action && item.scope === scope)); + config.hostActions.push({ + type: "open_url", + adapter: adapterInfo.id, + layout: adapterInfo.layout, + scope, + action, + url, + reportPrefixHex: "00150403", + note: "Host-assisted KeySilk open URL trigger. The URL is executed by a companion app, not stored in the keypad config.", + }); + return config; +} + +function assertHostPath(targetPath) { + if (typeof targetPath !== "string" || !targetPath.trim()) { + throw new Error("Host-assisted open path requires a non-empty path"); + } + return targetPath.trim(); +} + +function setHostOpenPathAction(config, request) { + if (!config || Buffer.isBuffer(config)) { + throw new Error("setHostOpenPathAction requires an imported config object"); + } + const action = request.action; + const scope = request.scope || "extended"; + const targetPath = assertHostPath(request.path); + + setActionBinding(config, action, "HostOpenPath", { scope }); + config.hostActions = (config.hostActions || []).filter((item) => !(item.type === "open_path" && item.action === action && item.scope === scope)); + config.hostActions.push({ + type: "open_path", + adapter: adapterInfo.id, + layout: adapterInfo.layout, + scope, + action, + path: targetPath, + reportPrefixHex: "00150404", + note: "Host-assisted KeySilk open file/program trigger. The path is executed by a companion app, not stored in the keypad config.", + }); + return config; +} + +function defaultHotkeyBindingForAction(action) { + const index = actionOrder3Key1Knob.indexOf(action); + return hotkeyCompanionBindings[index >= 0 ? index : 0]; +} + +function normalizeHotkeyBinding(binding, action) { + const hotkeyBinding = binding || defaultHotkeyBindingForAction(action); + if (!hotkeyCompanionBindings.includes(hotkeyBinding)) { + throw new Error(`Unsupported hotkey companion binding "${hotkeyBinding}". Supported: ${hotkeyCompanionBindings.join(", ")}`); + } + return hotkeyBinding; +} + +function setHotkeyOpenUrlAction(config, request) { + if (!config || Buffer.isBuffer(config)) { + throw new Error("setHotkeyOpenUrlAction requires an imported config object"); + } + const action = request.action; + const scope = request.scope || "extended"; + const url = assertUrl(request.url); + const binding = normalizeHotkeyBinding(request.binding, action); + + setActionBinding(config, action, binding, { scope }); + config.hostActions = (config.hostActions || []).filter((item) => !(item.type === "hotkey_open_url" && item.action === action && item.scope === scope)); + config.hostActions.push({ + type: "hotkey_open_url", + adapter: adapterInfo.id, + layout: adapterInfo.layout, + scope, + action, + url, + binding, + hotkey: hotkeyToAccelerator(binding), + note: "Bluetooth-compatible host open URL action. The keypad emits a reserved hotkey; the target URL is executed by a companion app.", + }); + return config; +} + +function setHotkeyOpenPathAction(config, request) { + if (!config || Buffer.isBuffer(config)) { + throw new Error("setHotkeyOpenPathAction requires an imported config object"); + } + const action = request.action; + const scope = request.scope || "extended"; + const targetPath = assertHostPath(request.path); + const binding = normalizeHotkeyBinding(request.binding, action); + + setActionBinding(config, action, binding, { scope }); + config.hostActions = (config.hostActions || []).filter((item) => !(item.type === "hotkey_open_path" && item.action === action && item.scope === scope)); + config.hostActions.push({ + type: "hotkey_open_path", + adapter: adapterInfo.id, + layout: adapterInfo.layout, + scope, + action, + path: targetPath, + binding, + hotkey: hotkeyToAccelerator(binding), + note: "Bluetooth-compatible host open file/program action. The keypad emits a reserved hotkey; the target path is executed by a companion app.", + }); + return config; +} + +function assertHotkeyText(text) { + if (typeof text !== "string" || text.length === 0) { + throw new Error("Hotkey text action requires non-empty text"); + } + if (text.length > 4096) { + throw new Error("Hotkey text action text is too long; max 4096 characters"); + } + return text; +} + +function setHotkeyTextAction(config, request) { + if (!config || Buffer.isBuffer(config)) { + throw new Error("setHotkeyTextAction requires an imported config object"); + } + const action = request.action; + const scope = request.scope || "extended"; + const text = assertHotkeyText(request.text); + const binding = normalizeHotkeyBinding(request.binding, action); + + setActionBinding(config, action, binding, { scope }); + config.hostActions = (config.hostActions || []).filter((item) => !(item.type === "hotkey_text" && item.action === action && item.scope === scope)); + config.hostActions.push({ + type: "hotkey_text", + adapter: adapterInfo.id, + layout: adapterInfo.layout, + scope, + action, + text, + binding, + hotkey: hotkeyToAccelerator(binding), + note: "Host-side text action. The keypad emits a reserved hotkey; the companion pastes this text at the current cursor.", + }); + return config; +} + +function setSimpleMacroTapsAction(config, request) { + if (!config || Buffer.isBuffer(config)) { + throw new Error("setSimpleMacroTapsAction requires an imported config object"); + } + const action = request.action; + const scope = request.scope || "extended"; + if (scope !== "extended") { + throw new Error("Simple macro taps require extended scope for current KeySilk firmware"); + } + const actionId = action.startsWith("extended.") ? action : `extended.${action}`; + const patched = patchSimpleMacroTaps(config.raw, actionId, request.taps, request.label || "Macro"); + config.raw = patched; + config.parsed = inspectConfig(patched); + return config; +} + +function exportHostActionProfile(config) { + return { + version: 1, + adapter: adapterInfo.id, + layout: adapterInfo.layout, + actions: config.hostActions || [], + }; +} + +function toHex(bytes) { + return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(" "); +} + +function extractExtendedSamples(rawConfig) { + const parsed = inspectConfig(rawConfig); + return parsed.extendedRecords + .filter((record) => record.binding) + .map((record) => ({ + adapter: adapterInfo.id, + layout: adapterInfo.layout, + action: `extended.${record.action}`, + offset: record.offset, + length: record.length, + binding: record.binding, + recordHex: toHex(rawConfig.subarray(record.offset, record.offset + record.length)), + declaredLength: parsed.declaredLength, + rawLength: parsed.rawLength, + })); +} + +function saveExtendedSampleFile(inputPath, sampleName, outputDir) { + const rawConfig = readConfigFile(inputPath); + const samples = extractExtendedSamples(rawConfig); + const safeName = sampleName.replace(/[^a-zA-Z0-9._-]/g, "_"); + const targetDir = outputDir || path.join(__dirname, "extended-samples"); + const targetPath = path.join(targetDir, `${safeName}.json`); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(targetPath, `${JSON.stringify({ source: inputPath, samples }, null, 2)}\n`); + return { targetPath, samples }; +} + +function patchConfigFile(inputPath, actionId, bindingId, outputPath) { + const patched = setBinding(readConfigFile(inputPath), actionId, bindingId); + writeConfigFile(outputPath, patched); + return patched; +} + +function listDevices() { + return parseDeviceList(runHid(["list"]).stdout).filter((device) => device.isConfigInterface); +} + +function readDevice(device = null) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "keysilk-sdk-")); + const outputPath = path.join(tempDir, "config.bin"); + try { + runHid(["read", outputPath]); + return importConfig(readConfigFile(outputPath), { device }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function writeDevice(deviceOrConfig, maybeConfig, options = {}) { + const config = maybeConfig || deviceOrConfig; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "keysilk-sdk-")); + const inputPath = path.join(tempDir, "config.bin"); + try { + writeConfigFile(inputPath, exportConfig(config)); + runHid([options.ack ? "write" : "write-noack", inputPath], { inherit: Boolean(options.inherit) }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +module.exports = { + adapterInfo, + extractExtendedSamples, + exportConfig, + getActionBinding, + getBindings, + getCapabilities, + getLayout, + importConfig, + inspectConfig, + inspectConfigFile, + listDevices, + patchConfigFile, + readConfigFile, + readDevice, + saveExtendedSampleFile, + setActionBinding, + setBinding, + setHostOpenPathAction, + setHostOpenUrlAction, + setHotkeyOpenPathAction, + setHotkeyOpenUrlAction, + setHotkeyTextAction, + setSimpleMacroTapsAction, + exportHostActionProfile, + summarizeConfig, + summarizeConfigFile, + writeDevice, + writeConfigFile, +}; diff --git a/sdks/portable-keypad/adapters/keysilk-v1/config-codec.js b/sdks/portable-keypad/adapters/keysilk-v1/config-codec.js new file mode 100644 index 00000000..41d66e72 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/config-codec.js @@ -0,0 +1,753 @@ +const verification = { + HARDWARE: "hardware", + OBSERVED: "observed", + INFERRED: "inferred", +}; + +function withBindingDefaults(binding, defaults = {}) { + return { + verification: verification.INFERRED, + ...defaults, + ...binding, + }; +} + +function makeBasicKey(id, label, keysilkCode, hidUsage, overrides = {}) { + return [ + id, + withBindingDefaults({ + label, + tail: [0x01, keysilkCode, 0x00, hidUsage], + category: "basic-key", + ...overrides, + }), + ]; +} + +function makeShortcut(id, label, keysilkCode, modifierMask, hidUsage, overrides = {}) { + return [ + id, + withBindingDefaults({ + label, + tail: [0x01, keysilkCode, modifierMask, hidUsage], + category: "shortcut", + ...overrides, + }), + ]; +} + +const letterBindings = Object.fromEntries( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map((letter, index) => + makeBasicKey(letter, letter, 0x0a + index, 0x04 + index, { + verification: ["A", "B", "C", "Z"].includes(letter) ? verification.HARDWARE : verification.INFERRED, + }), + ), +); + +const digitBindings = Object.fromEntries( + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"].map((digit, index) => + makeBasicKey(`Digit${digit}`, digit, index === 9 ? 0x00 : 0x01 + index, index === 9 ? 0x27 : 0x1e + index, { + aliases: [digit], + verification: digit === "0" || digit === "1" + ? verification.HARDWARE + : index >= 1 && index <= 7 ? verification.OBSERVED : verification.INFERRED, + }), + ), +); + +const keySilkShortcutBindings = { + Ctrl: withBindingDefaults({ label: "Ctrl", code: [0x64, 0x01], category: "shortcut", verification: verification.HARDWARE }), + Shift: withBindingDefaults({ label: "Shift", code: [0x65, 0x02], category: "shortcut", verification: verification.HARDWARE }), + Alt: withBindingDefaults({ label: "Alt", code: [0x66, 0x04], category: "shortcut", verification: verification.HARDWARE }), + Win: withBindingDefaults({ label: "Win", code: [0x67, 0x08], category: "shortcut", verification: verification.HARDWARE }), + CtrlA: makeShortcut("CtrlA", "Ctrl+A", 0x0a, 0x01, 0x04)[1], + CtrlC: makeShortcut("CtrlC", "Ctrl+C", 0x0c, 0x01, 0x06, { verification: verification.HARDWARE })[1], + CtrlV: makeShortcut("CtrlV", "Ctrl+V", 0x1f, 0x01, 0x19, { verification: verification.HARDWARE })[1], + CtrlX: makeShortcut("CtrlX", "Ctrl+X", 0x21, 0x01, 0x1b)[1], + CtrlZ: makeShortcut("CtrlZ", "Ctrl+Z", 0x23, 0x01, 0x1d, { verification: verification.HARDWARE })[1], + CtrlY: makeShortcut("CtrlY", "Ctrl+Y", 0x22, 0x01, 0x1c)[1], + CtrlS: makeShortcut("CtrlS", "Ctrl+S", 0x1c, 0x01, 0x16)[1], + CtrlP: makeShortcut("CtrlP", "Ctrl+P", 0x19, 0x01, 0x13)[1], + CtrlF: makeShortcut("CtrlF", "Ctrl+F", 0x0f, 0x01, 0x09)[1], + CtrlAltShiftB: makeShortcut("CtrlAltShiftB", "C+A+S+B", 0x0b, 0x07, 0x05)[1], + CtrlAltShiftC: makeShortcut("CtrlAltShiftC", "C+A+S+C", 0x0c, 0x07, 0x06)[1], + CtrlAltShiftD: makeShortcut("CtrlAltShiftD", "C+A+S+D", 0x0d, 0x07, 0x07)[1], + CtrlAltShiftE: makeShortcut("CtrlAltShiftE", "C+A+S+E", 0x0e, 0x07, 0x08)[1], + CtrlAltShiftF: makeShortcut("CtrlAltShiftF", "C+A+S+F", 0x0f, 0x07, 0x09)[1], + CtrlAltShiftG: makeShortcut("CtrlAltShiftG", "C+A+S+G", 0x10, 0x07, 0x0a)[1], + CtrlAltShiftH: makeShortcut("CtrlAltShiftH", "C+A+S+H", 0x11, 0x07, 0x0b)[1], + CtrlAltShiftI: makeShortcut("CtrlAltShiftI", "C+A+S+I", 0x12, 0x07, 0x0c)[1], +}; + +const keySilkBasicKeyBindings = { + ...letterBindings, + ...digitBindings, + Esc: makeBasicKey("Esc", "Esc", 0x56, 0x29, { verification: verification.HARDWARE })[1], + Enter: makeBasicKey("Enter", "Enter", 0x5d, 0x28, { verification: verification.HARDWARE })[1], + Space: makeBasicKey("Space", "Space", 0x5e, 0x2c, { verification: verification.HARDWARE })[1], +}; + +const keySilkMediaBindings = { + Mute: withBindingDefaults({ label: "Mute", tail: [0x06, 0x00, 0xe2, 0x00], category: "media", verification: verification.HARDWARE }), + VolumeDown: withBindingDefaults({ label: "Volume-", tail: [0x06, 0x01, 0xea, 0x00], category: "media", verification: verification.HARDWARE }), + VolumeUp: withBindingDefaults({ label: "Volume+", tail: [0x06, 0x02, 0xe9, 0x00], category: "media", verification: verification.HARDWARE }), + PlayPause: withBindingDefaults({ label: "PlayPause", tail: [0x06, 0x03, 0xcd, 0x00], category: "media", verification: verification.HARDWARE }), +}; + +const keySilkLayerBindings = { + NextLayer: withBindingDefaults({ label: "NextScene", tail: [0x0a, 0x00, 0x00, 0x00], category: "layer", verification: verification.HARDWARE }), + PreviousLayer: withBindingDefaults({ label: "PrevScene", tail: [0x0a, 0x01, 0x01, 0x00], category: "layer", verification: verification.HARDWARE }), +}; + +const keySilkHostBindings = { + HostOpenUrl: withBindingDefaults({ + label: "OpenURL", + tail: [0x04, 0x03, 0xff, 0xff], + category: "host-action", + verification: verification.HARDWARE, + }), + HostOpenPath: withBindingDefaults({ + label: "OpenPath", + tail: [0x04, 0x04, 0xff, 0xff], + category: "host-action", + verification: verification.HARDWARE, + }), +}; + +const keySilkOpenProgramBindings = { + OpenCalculatorObserved: withBindingDefaults({ label: "Calculator", category: "open-program", verification: verification.OBSERVED }), +}; + +const keySilkMouseBindings = { + MouseLeftClick: withBindingDefaults({ label: "LClick", category: "mouse", verification: verification.HARDWARE, mouseButtonMask: 0x01 }), + MouseRightClick: withBindingDefaults({ label: "RClick", category: "mouse", verification: verification.HARDWARE, mouseButtonMask: 0x02 }), + MouseMiddleClick: withBindingDefaults({ label: "MClick", category: "mouse", verification: verification.HARDWARE, mouseButtonMask: 0x04 }), +}; + +const keySilkMouseWheelBindings = { + MouseWheelUp: withBindingDefaults({ label: "Wheel+1", category: "mouse-wheel", verification: verification.HARDWARE, mouseWheelDelta: 0x01 }), + MouseWheelDown: withBindingDefaults({ label: "Wheel-1", category: "mouse-wheel", verification: verification.HARDWARE, mouseWheelDelta: 0xff }), + CtrlMouseWheelUp: withBindingDefaults({ label: "CWh+1", category: "mouse-wheel", verification: verification.HARDWARE, mouseWheelDelta: 0x01, mouseWheelModifier: "ctrl" }), + CtrlMouseWheelDown: withBindingDefaults({ label: "CWh-1", category: "mouse-wheel", verification: verification.HARDWARE, mouseWheelDelta: 0xff, mouseWheelModifier: "ctrl" }), +}; + +const keySilkMacroBindings = { + MacroObserved: withBindingDefaults({ label: "Macro", category: "macro", verification: verification.OBSERVED }), +}; + +const keySilkTextBindings = { + TextObserved: withBindingDefaults({ label: "Text", category: "text", verification: verification.OBSERVED }), +}; + +const keySilkBindings = { + ...keySilkShortcutBindings, + ...keySilkBasicKeyBindings, + ...keySilkMediaBindings, + ...keySilkLayerBindings, + ...keySilkHostBindings, + ...keySilkOpenProgramBindings, + ...keySilkMouseBindings, + ...keySilkMouseWheelBindings, + ...keySilkMacroBindings, + ...keySilkTextBindings, +}; + +const actionOrder3Key1Knob = [ + "key1.press", + "key2.press", + "key3.press", + "knob1.rotate_left", + "knob1.press", + "knob1.rotate_right", + "knob1.press_rotate_left", + "knob1.press_rotate_right", +]; + +const actionRecordLength = 0x18; +const extendedPointerTableOffsetField = 0x0c; +const extendedPointerTableLength = actionOrder3Key1Knob.length * 4; +const transportPayloadLength = 60; + +function readU32LE(buffer, offset) { + return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24); +} + +function writeU32LE(buffer, offset, value) { + buffer[offset] = value & 0xff; + buffer[offset + 1] = (value >>> 8) & 0xff; + buffer[offset + 2] = (value >>> 16) & 0xff; + buffer[offset + 3] = (value >>> 24) & 0xff; +} + +function padLengthForTransport(length) { + return Math.ceil(length / transportPayloadLength) * transportPayloadLength; +} + +function getRecordOffsets(rawConfig) { + const declaredLength = readU32LE(rawConfig, 0); + const offsets = []; + for (let cursor = 0x30; cursor + 4 <= rawConfig.length; cursor += 4) { + const offset = readU32LE(rawConfig, cursor); + if (offset <= 0 || offset >= rawConfig.length) break; + if (offsets.length && offset <= offsets[offsets.length - 1]) break; + offsets.push(offset); + if (offset >= declaredLength) break; + } + return offsets; +} + +function getExtendedRecordOffsets(rawConfig) { + const tableOffset = readU32LE(rawConfig, extendedPointerTableOffsetField); + if (tableOffset <= 0 || tableOffset >= rawConfig.length) { + return []; + } + + const offsets = []; + const tableEnd = tableOffset + extendedPointerTableLength; + for (let cursor = tableOffset; cursor + 4 <= rawConfig.length && cursor < tableEnd; cursor += 4) { + const offset = readU32LE(rawConfig, cursor); + if (offset === 0) { + offsets.push(null); + continue; + } + if (offset <= 0 || offset >= rawConfig.length) break; + offsets.push(offset); + } + return offsets; +} + +function findNextRecordOffset(offsets, start) { + return offsets + .filter((item) => item != null && item > start) + .sort((a, b) => a - b)[0] || null; +} + +function getRecordSpan(rawConfig, recordIndex) { + const offsets = getRecordOffsets(rawConfig); + const start = offsets[recordIndex]; + if (start == null) { + throw new Error(`No KeySilk record at index ${recordIndex}`); + } + const next = offsets[recordIndex + 1] || start + actionRecordLength; + const end = Math.min(next, rawConfig.length); + return { start, end, length: end - start }; +} + +function getExtendedRecordSpan(rawConfig, recordIndex) { + const offsets = getExtendedRecordOffsets(rawConfig); + const start = offsets[recordIndex]; + if (start == null) { + throw new Error(`No KeySilk extended record at index ${recordIndex}`); + } + const nextOffset = findNextRecordOffset(offsets, start); + const declaredLength = readU32LE(rawConfig, 0); + const end = Math.min(nextOffset || declaredLength || start + actionRecordLength, rawConfig.length); + return { start, end, length: end - start }; +} + +function readUtf16Label(record) { + const bytes = []; + for (let i = 2; i < Math.min(record.length, 0x14); i += 2) { + if (record[i] === 0 && record[i + 1] === 0) break; + bytes.push(record[i], record[i + 1]); + } + return Buffer.from(bytes).toString("utf16le"); +} + +function writeUtf16Label(record, label) { + record.fill(0, 2, 0x14); + const labelBytes = Buffer.from(label, "utf16le"); + if (labelBytes.length > 0x12) { + throw new Error(`Label too long for current KeySilk record: ${label}`); + } + labelBytes.copy(record, 2); +} + +function parseRecord(record) { + return { + label: readUtf16Label(record), + typeByte: record[0x14], + code: [record[0x15], record[0x16]], + rawTail: Array.from(record.slice(0x14, 0x18)), + payload: Array.from(record.slice(0x18)), + }; +} + +function bindingMatchesRecord(binding, recordBinding) { + if (!binding || !recordBinding) return false; + if (binding.tail) { + return binding.tail.every((value, index) => recordBinding.rawTail[index] === value); + } + return recordBinding.typeByte === 0x01 + && recordBinding.code[0] === binding.code[0] + && recordBinding.code[1] === binding.code[1]; +} + +function identifyBinding(recordBinding) { + const hostBinding = identifyHostBinding(recordBinding); + if (hostBinding) return hostBinding; + + const mouseBinding = identifyMouseBinding(recordBinding); + if (mouseBinding) return mouseBinding; + + const macroBinding = identifyMacroBinding(recordBinding); + if (macroBinding) return macroBinding; + + const textBinding = identifyTextBinding(recordBinding); + if (textBinding) return textBinding; + + for (const [id, binding] of Object.entries(keySilkBindings)) { + if (bindingMatchesRecord(binding, recordBinding)) { + return id; + } + } + return null; +} + +function identifyHostBinding(recordBinding) { + if (!recordBinding) return null; + const tail = recordBinding.rawTail || []; + if (tail[0] !== 0x04) return null; + if (tail[1] === 0x03) return "HostOpenUrl"; + if (tail[1] === 0x02) return "OpenCalculatorObserved"; + if (tail[1] === 0x04) return "HostOpenPath"; + return null; +} + +function identifyMouseBinding(recordBinding) { + if (!recordBinding) return null; + const tail = recordBinding.rawTail || []; + if (tail[0] === 0x07 && tail[1] === 0x00 && tail[2] === 0x00 && tail[3] === 0x04) { + const delta = recordBinding.payload && recordBinding.payload[16]; + return { + 0x01: "CtrlMouseWheelUp", + 0xff: "CtrlMouseWheelDown", + }[delta] || null; + } + + if (tail[0] !== 0x07 || tail[1] !== 0x00 || tail[2] !== 0x00 || tail[3] !== 0x03) { + return null; + } + + const payload = recordBinding.payload || []; + if (payload[0] === 0xff && payload[1] === 0x00 && payload[6] === 0x01) { + return "MouseWheelUp"; + } + if (payload[0] === 0xff && payload[1] === 0x00 && payload[6] === 0xff) { + return "MouseWheelDown"; + } + + const isMouseClickPayload = payload[0] === 0xff && payload[8] === 0x0c && payload[10] === 0xff; + if (!isMouseClickPayload) return null; + + const buttonMask = payload[1]; + return { + 0x01: "MouseLeftClick", + 0x02: "MouseRightClick", + 0x04: "MouseMiddleClick", + }[buttonMask] || null; +} + +function identifyMacroBinding(recordBinding) { + if (!recordBinding) return null; + const tail = recordBinding.rawTail || []; + if (tail[0] !== 0x07 || tail[1] !== 0x00 || tail[2] !== 0x00) return null; + return [0x03, 0x05, 0x06].includes(tail[3]) ? "MacroObserved" : null; +} + +function identifyTextBinding(recordBinding) { + if (!recordBinding) return null; + const tail = recordBinding.rawTail || []; + return tail[0] === 0x03 ? "TextObserved" : null; +} + +function parseConfig(rawConfig) { + const offsets = getRecordOffsets(rawConfig); + const extendedOffsets = getExtendedRecordOffsets(rawConfig); + return { + declaredLength: readU32LE(rawConfig, 0), + rawLength: rawConfig.length, + records: offsets.map((offset, index) => { + const next = offsets[index + 1] || offset + actionRecordLength; + const record = rawConfig.subarray(offset, Math.min(next, rawConfig.length)); + return { + action: actionOrder3Key1Knob[index] || `unknown.${index}`, + offset, + length: record.length, + binding: parseRecord(record), + }; + }), + extendedRecords: extendedOffsets.map((offset, index) => { + if (offset == null) { + return { + action: actionOrder3Key1Knob[index] || `unknown.${index}`, + offset: null, + length: 0, + binding: null, + }; + } + const nextOffset = findNextRecordOffset(extendedOffsets, offset); + const declaredLength = readU32LE(rawConfig, 0); + const end = Math.min(nextOffset || declaredLength || offset + actionRecordLength, rawConfig.length); + const record = rawConfig.subarray(offset, end); + return { + action: actionOrder3Key1Knob[index] || `unknown.${index}`, + offset, + length: record.length, + binding: parseRecord(record), + }; + }), + }; +} + +function formatBinding(binding) { + if (!binding) return "(empty)"; + const bindingId = identifyBinding(binding); + const tail = binding.rawTail.map((value) => value.toString(16).padStart(2, "0")).join(" "); + return `${bindingId || binding.label || "(unnamed)"} [${tail}]`; +} + +function summarizeConfig(rawConfig) { + const parsed = parseConfig(rawConfig); + const lines = [ + `declaredLength=${parsed.declaredLength} rawLength=${parsed.rawLength}`, + "Base records:", + ]; + + for (const record of parsed.records) { + lines.push(` ${record.action} = ${formatBinding(record.binding)}`); + } + + if (parsed.extendedRecords.length) { + lines.push("Extended records:"); + for (const record of parsed.extendedRecords) { + if (!record.binding) continue; + lines.push(` extended.${record.action} = ${formatBinding(record.binding)}`); + } + } else { + lines.push("Extended records: none"); + } + + return lines.join("\n"); +} + +function writeBindingToRecord(record, binding) { + record[0] = 0xff; + record[1] = 0xff; + writeUtf16Label(record, binding.label); + if (binding.tail) { + record[0x14] = binding.tail[0]; + record[0x15] = binding.tail[1]; + record[0x16] = binding.tail[2]; + record[0x17] = binding.tail[3]; + } else { + record[0x14] = 0x01; + record[0x15] = binding.code[0]; + record[0x16] = binding.code[1]; + record[0x17] = 0x00; + } +} + +function resolveAction(actionId) { + const isExtendedAction = actionId.startsWith("extended."); + const normalizedActionId = isExtendedAction ? actionId.slice("extended.".length) : actionId; + const recordIndex = actionOrder3Key1Knob.indexOf(normalizedActionId); + if (recordIndex < 0) { + throw new Error(`Unsupported action for current KeySilk 3-key layout: ${actionId}`); + } + return { isExtendedAction, normalizedActionId, recordIndex }; +} + +function resolveBinding(target) { + const binding = keySilkBindings[target] + || Object.values(keySilkBindings).find((item) => (item.aliases || []).includes(target)); + if (!binding) { + throw new Error(`Unsupported binding "${target}". Supported: ${Object.keys(keySilkBindings).join(", ")}`); + } + return binding; +} + +function buildMouseClickRecord(binding) { + const record = Buffer.alloc(54, 0); + record[0] = 0xff; + record[1] = 0xff; + writeUtf16Label(record, binding.label); + record[0x14] = 0x07; + record[0x15] = 0x00; + record[0x16] = 0x00; + record[0x17] = 0x03; + record[0x18] = 0xff; + record[0x19] = binding.mouseButtonMask; + record[0x20] = 0x0c; + record[0x22] = 0xff; + return record; +} + +function buildMouseWheelRecord(binding) { + if (binding.mouseWheelModifier === "ctrl") { + const record = Buffer.alloc(64, 0); + record[0] = 0xff; + record[1] = 0xff; + writeUtf16Label(record, binding.label); + record[0x14] = 0x07; + record[0x15] = 0x00; + record[0x16] = 0x00; + record[0x17] = 0x04; + record[0x18] = 0x01; + record[0x20] = 0x02; + record[0x22] = 0xff; + record[0x28] = binding.mouseWheelDelta; + record[0x2a] = 0x02; + return record; + } + + const record = Buffer.alloc(54, 0); + record[0] = 0xff; + record[1] = 0xff; + writeUtf16Label(record, binding.label); + record[0x14] = 0x07; + record[0x15] = 0x00; + record[0x16] = 0x00; + record[0x17] = 0x03; + record[0x18] = 0xff; + record[0x1e] = binding.mouseWheelDelta; + return record; +} + +function resolveBasicKeyUsage(key) { + const binding = resolveBinding(key); + if (binding.category !== "basic-key" || !binding.tail) { + throw new Error(`Macro tap key must be a basic key binding, got "${key}"`); + } + return binding.tail[3]; +} + +function delayToMacroUnit(ms) { + if (!Number.isInteger(ms) || ms < 0 || ms > 1020 || ms % 4 !== 0) { + throw new Error(`Macro delay must be an integer 0..1020ms in 4ms units, got ${ms}`); + } + return ms / 4; +} + +function buildSimpleMacroTapRecord(taps, label = "Macro") { + if (!Array.isArray(taps) || taps.length < 1 || taps.length > 4) { + throw new Error("Simple macro taps must contain 1..4 key taps"); + } + const stepCount = taps.length * 2 + 1; + const record = Buffer.alloc(0x18 + stepCount * 10, 0); + record[0] = 0xff; + record[1] = 0xff; + writeUtf16Label(record, label); + record[0x14] = 0x07; + record[0x15] = 0x00; + record[0x16] = 0x00; + record[0x17] = stepCount; + + for (let index = 0; index < taps.length; index += 1) { + const tap = taps[index]; + const offset = 0x18 + index * 20; + record[offset] = 0x00; + record[offset + 1] = resolveBasicKeyUsage(tap.key); + record[offset + 8] = delayToMacroUnit(tap.delayMs || 0); + } + return record; +} + +function ensureExtendedTable(rawConfig) { + const declaredLength = readU32LE(rawConfig, 0); + const declaredTableOffset = readU32LE(rawConfig, extendedPointerTableOffsetField); + if (declaredTableOffset) { + return { + buffer: Buffer.from(rawConfig), + tableOffset: declaredTableOffset, + declaredLength, + }; + } + + const tableOffset = declaredLength; + const newDeclaredLength = tableOffset + extendedPointerTableLength; + const newRawLength = padLengthForTransport(newDeclaredLength); + const next = Buffer.alloc(newRawLength, 0); + Buffer.from(rawConfig).copy(next, 0, 0, Math.min(rawConfig.length, next.length)); + writeU32LE(next, extendedPointerTableOffsetField, tableOffset); + writeU32LE(next, 0, newDeclaredLength); + return { + buffer: next, + tableOffset, + declaredLength: newDeclaredLength, + }; +} + +function replaceExtendedRecord(rawConfig, recordIndex, replacementRecord) { + const tableState = ensureExtendedTable(rawConfig); + const source = tableState.buffer; + const tableOffset = tableState.tableOffset; + const declaredLength = readU32LE(source, 0); + const offsets = getExtendedRecordOffsets(source); + const existingOffset = offsets[recordIndex]; + const oldStart = existingOffset || declaredLength; + const nextOffset = existingOffset ? findNextRecordOffset(offsets, oldStart) : null; + const oldEnd = existingOffset ? (nextOffset || declaredLength) : declaredLength; + const oldLength = oldEnd - oldStart; + const delta = replacementRecord.length - oldLength; + const newDeclaredLength = declaredLength + delta; + const newRawLength = padLengthForTransport(newDeclaredLength); + const next = Buffer.alloc(newRawLength, 0); + + source.copy(next, 0, 0, oldStart); + replacementRecord.copy(next, oldStart); + source.copy(next, oldStart + replacementRecord.length, oldEnd, declaredLength); + + writeU32LE(next, 0, newDeclaredLength); + writeU32LE(next, tableOffset + recordIndex * 4, oldStart); + + for (let index = 0; index < actionOrder3Key1Knob.length; index += 1) { + const pointerOffset = tableOffset + index * 4; + const pointer = readU32LE(next, pointerOffset); + if (pointer > oldStart) { + writeU32LE(next, pointerOffset, pointer + delta); + } + } + + return next; +} + +function ensureExtendedRecord(rawConfig, recordIndex) { + const declaredLength = readU32LE(rawConfig, 0); + const declaredTableOffset = readU32LE(rawConfig, extendedPointerTableOffsetField); + const tableOffset = declaredTableOffset || declaredLength; + + const tableEnd = tableOffset + extendedPointerTableLength; + const existingOffset = tableOffset + recordIndex * 4 + 4 <= rawConfig.length + ? readU32LE(rawConfig, tableOffset + recordIndex * 4) + : 0; + + if (existingOffset) { + if (existingOffset < tableEnd || existingOffset + actionRecordLength > rawConfig.length) { + throw new Error(`Invalid KeySilk extended record offset: 0x${existingOffset.toString(16)}`); + } + return { + buffer: Buffer.from(rawConfig), + span: { + start: existingOffset, + end: existingOffset + actionRecordLength, + length: actionRecordLength, + }, + }; + } + + const recordOffset = Math.max(declaredLength, tableEnd); + const newDeclaredLength = recordOffset + actionRecordLength; + const newRawLength = padLengthForTransport(newDeclaredLength); + const next = Buffer.alloc(newRawLength, 0); + Buffer.from(rawConfig).copy(next, 0, 0, Math.min(rawConfig.length, next.length)); + + if (!declaredTableOffset) { + writeU32LE(next, extendedPointerTableOffsetField, tableOffset); + } + writeU32LE(next, tableOffset + recordIndex * 4, recordOffset); + writeU32LE(next, 0, newDeclaredLength); + + return { + buffer: next, + span: { + start: recordOffset, + end: recordOffset + actionRecordLength, + length: actionRecordLength, + }, + }; +} + +function patchShortcutBinding(rawConfig, actionId, target) { + const { isExtendedAction, recordIndex } = resolveAction(actionId); + const binding = resolveBinding(target); + + if (binding.category === "mouse") { + if (!isExtendedAction) { + throw new Error(`Mouse bindings require extended scope for current KeySilk firmware: ${actionId}`); + } + return replaceExtendedRecord(rawConfig, recordIndex, buildMouseClickRecord(binding)); + } + + if (binding.category === "mouse-wheel") { + if (binding.mouseWheelDelta == null) { + throw new Error(`Mouse wheel binding "${target}" is read-only until wheel writes are hardware verified`); + } + if (!isExtendedAction) { + throw new Error(`Mouse wheel bindings require extended scope for current KeySilk firmware: ${actionId}`); + } + return replaceExtendedRecord(rawConfig, recordIndex, buildMouseWheelRecord(binding)); + } + + if (binding.category === "macro") { + throw new Error(`Macro binding "${target}" is read-only until macro writes are fully reverse engineered and hardware verified`); + } + + if (binding.category === "text") { + throw new Error(`Text binding "${target}" is read-only until text slot writes are fully reverse engineered and hardware verified`); + } + + if (binding.category === "open-program") { + throw new Error(`Open-program binding "${target}" is read-only until standalone program/path writes are fully reverse engineered and hardware verified`); + } + + const result = isExtendedAction + ? ensureExtendedRecord(rawConfig, recordIndex) + : { buffer: Buffer.from(rawConfig), span: getRecordSpan(rawConfig, recordIndex) }; + const next = result.buffer; + const span = result.span; + if (span.length !== 0x18) { + throw new Error(`Unexpected KeySilk record length for ${actionId}: 0x${span.length.toString(16)}`); + } + + const record = next.subarray(span.start, span.end); + writeBindingToRecord(record, binding); + return next; +} + +function patchSimpleMacroTaps(rawConfig, actionId, taps, label) { + const { isExtendedAction, recordIndex } = resolveAction(actionId); + if (!isExtendedAction) { + throw new Error(`Macro bindings require extended scope for current KeySilk firmware: ${actionId}`); + } + return replaceExtendedRecord(rawConfig, recordIndex, buildSimpleMacroTapRecord(taps, label)); +} + +function getBindingForAction(rawConfig, actionId) { + const { isExtendedAction, recordIndex } = resolveAction(actionId); + const parsed = parseConfig(rawConfig); + const records = isExtendedAction ? parsed.extendedRecords : parsed.records; + const record = records[recordIndex]; + if (!record || !record.binding) return null; + return { + action: actionId, + binding: identifyBinding(record.binding), + raw: record.binding, + offset: record.offset, + }; +} + +module.exports = { + actionOrder3Key1Knob, + keySilkBasicKeyBindings, + keySilkBindings, + keySilkHostBindings, + keySilkLayerBindings, + keySilkMacroBindings, + keySilkMediaBindings, + keySilkMouseBindings, + keySilkMouseWheelBindings, + keySilkOpenProgramBindings, + keySilkShortcutBindings, + keySilkTextBindings, + verification, + ensureExtendedRecord, + getBindingForAction, + getRecordOffsets, + getExtendedRecordOffsets, + identifyBinding, + parseConfig, + patchSimpleMacroTaps, + patchShortcutBinding, + summarizeConfig, +}; diff --git a/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/README.md b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/README.md new file mode 100644 index 00000000..ae680dc2 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/README.md @@ -0,0 +1,15 @@ +# KeySilk Extended Samples + +This directory stores factory-compatible extended-record samples. + +Generate a sample after using the factory app to download a setting to the +device, then reading the config back: + +```powershell +node tools\keysilk-cli.js read analysis\factory_after_download.bin +node tools\keysilk-cli.js extended-sample analysis\factory_after_download.bin scene2_key2_a +``` + +Each JSON file records non-empty `extended.*` records found in the raw config. +These samples are used to learn how to create missing extended records without +depending on the factory app. diff --git a/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key1_a.json b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key1_a.json new file mode 100644 index 00000000..cade8e38 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key1_a.json @@ -0,0 +1,53 @@ +{ + "source": "analysis\\factory_scene2_key1_a.bin", + "samples": [ + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key1.press", + "offset": 304, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 352, + "rawLength": 360 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key2.press", + "offset": 328, + "length": 24, + "binding": { + "label": "字母B", + "typeByte": 1, + "code": [ + 11, + 0 + ], + "rawTail": [ + 1, + 11, + 0, + 5 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 42 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0b 00 05", + "declaredLength": 352, + "rawLength": 360 + } + ] +} diff --git a/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key2_a.json b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key2_a.json new file mode 100644 index 00000000..d7dacb36 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key2_a.json @@ -0,0 +1,29 @@ +{ + "source": "analysis\\factory_extended_key2_a.bin", + "samples": [ + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key2.press", + "offset": 304, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 328, + "rawLength": 360 + } + ] +} diff --git a/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key2_b.json b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key2_b.json new file mode 100644 index 00000000..ab16a8a0 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key2_b.json @@ -0,0 +1,29 @@ +{ + "source": "analysis\\factory_scene2_key2_b_after_download.bin", + "samples": [ + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key2.press", + "offset": 304, + "length": 24, + "binding": { + "label": "字母B", + "typeByte": 1, + "code": [ + 11, + 0 + ], + "rawTail": [ + 1, + 11, + 0, + 5 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 42 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0b 00 05", + "declaredLength": 328, + "rawLength": 360 + } + ] +} diff --git a/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key3_a.json b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key3_a.json new file mode 100644 index 00000000..88b91d65 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_key3_a.json @@ -0,0 +1,77 @@ +{ + "source": "analysis\\factory_scene2_key3_a.bin", + "samples": [ + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key1.press", + "offset": 304, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 376, + "rawLength": 420 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key2.press", + "offset": 328, + "length": 24, + "binding": { + "label": "字母B", + "typeByte": 1, + "code": [ + 11, + 0 + ], + "rawTail": [ + 1, + 11, + 0, + 5 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 42 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0b 00 05", + "declaredLength": 376, + "rawLength": 420 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key3.press", + "offset": 352, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 376, + "rawLength": 420 + } + ] +} diff --git a/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_knob_press_a.json b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_knob_press_a.json new file mode 100644 index 00000000..be02ab3a --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/scene2_knob_press_a.json @@ -0,0 +1,101 @@ +{ + "source": "analysis\\factory_scene2_knob_press_a.bin", + "samples": [ + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key1.press", + "offset": 304, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 400, + "rawLength": 420 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key2.press", + "offset": 328, + "length": 24, + "binding": { + "label": "字母B", + "typeByte": 1, + "code": [ + 11, + 0 + ], + "rawTail": [ + 1, + 11, + 0, + 5 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 42 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0b 00 05", + "declaredLength": 400, + "rawLength": 420 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key3.press", + "offset": 352, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 400, + "rawLength": 420 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.knob1.press", + "offset": 376, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 400, + "rawLength": 420 + } + ] +} diff --git a/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/sdk_all_extended_actions_a.json b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/sdk_all_extended_actions_a.json new file mode 100644 index 00000000..10190114 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/extended-samples/sdk_all_extended_actions_a.json @@ -0,0 +1,197 @@ +{ + "source": "analysis\\after_verify_all_extended_knobs_a.bin", + "samples": [ + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key1.press", + "offset": 304, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 496, + "rawLength": 540 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key2.press", + "offset": 328, + "length": 24, + "binding": { + "label": "字母B", + "typeByte": 1, + "code": [ + 11, + 0 + ], + "rawTail": [ + 1, + 11, + 0, + 5 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 42 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0b 00 05", + "declaredLength": 496, + "rawLength": 540 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.key3.press", + "offset": 352, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 496, + "rawLength": 540 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.knob1.rotate_left", + "offset": 400, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 496, + "rawLength": 540 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.knob1.press", + "offset": 376, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 496, + "rawLength": 540 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.knob1.rotate_right", + "offset": 424, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 496, + "rawLength": 540 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.knob1.press_rotate_left", + "offset": 448, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 496, + "rawLength": 540 + }, + { + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "action": "extended.knob1.press_rotate_right", + "offset": 472, + "length": 24, + "binding": { + "label": "字母A", + "typeByte": 1, + "code": [ + 10, + 0 + ], + "rawTail": [ + 1, + 10, + 0, + 4 + ] + }, + "recordHex": "ff ff 57 5b cd 6b 41 00 00 00 00 00 00 00 00 00 00 00 00 00 01 0a 00 04", + "declaredLength": 496, + "rawLength": 540 + } + ] +} diff --git a/sdks/portable-keypad/adapters/keysilk-v1/layout-3key-1knob.json b/sdks/portable-keypad/adapters/keysilk-v1/layout-3key-1knob.json new file mode 100644 index 00000000..9f54e992 --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/layout-3key-1knob.json @@ -0,0 +1,38 @@ +{ + "brand": "KeySilk / COIDEA", + "model": "3键 + 1旋钮", + "adapter": "keysilk_v1", + "layout": "keysilk_3key_1knob", + "components": [ + { + "id": "knob1", + "type": "encoder", + "label": "旋钮", + "actions": [ + { "id": "knob1.rotate_left", "label": "左旋" }, + { "id": "knob1.press", "label": "旋钮按下" }, + { "id": "knob1.rotate_right", "label": "右旋" }, + { "id": "knob1.press_rotate_left", "label": "按住左旋" }, + { "id": "knob1.press_rotate_right", "label": "按住右旋" } + ] + }, + { + "id": "key1", + "type": "button", + "label": "按键 1", + "actions": [{ "id": "key1.press", "label": "按键 1" }] + }, + { + "id": "key2", + "type": "button", + "label": "按键 2", + "actions": [{ "id": "key2.press", "label": "按键 2" }] + }, + { + "id": "key3", + "type": "button", + "label": "按键 3", + "actions": [{ "id": "key3.press", "label": "按键 3" }] + } + ] +} diff --git a/sdks/portable-keypad/adapters/keysilk-v1/protocol.js b/sdks/portable-keypad/adapters/keysilk-v1/protocol.js new file mode 100644 index 00000000..e75b748a --- /dev/null +++ b/sdks/portable-keypad/adapters/keysilk-v1/protocol.js @@ -0,0 +1,49 @@ +export const keysilkV1Protocol = { + vendorId: 0x4132, + productId: 0x2107, + reportLength: 64, + payloadLength: 60, + commands: { + handshake: 0x0a, + readBlock: 0x0d, + writeBlock: 0x0b, + switchLayer: 0x0c, + finishWrite: 0x0f, + finishRead: 0x1f, + }, +}; + +export function makeReadBlockReport(offset) { + const report = new Uint8Array(keysilkV1Protocol.reportLength); + report[0] = keysilkV1Protocol.commands.readBlock; + report[2] = (offset >> 8) & 0xff; + report[3] = offset & 0xff; + fillProbeTail(report); + return report; +} + +export function makeWriteBlockReport(offset, payload) { + if (payload.length > keysilkV1Protocol.payloadLength) { + throw new Error("KeySilk write payload must be 60 bytes or less"); + } + const report = new Uint8Array(keysilkV1Protocol.reportLength); + report[0] = keysilkV1Protocol.commands.writeBlock; + report[2] = (offset >> 8) & 0xff; + report[3] = offset & 0xff; + report.set(payload, 4); + return report; +} + +export function splitConfigIntoWriteReports(configBytes) { + const reports = []; + for (let offset = 0; offset < configBytes.length; offset += keysilkV1Protocol.payloadLength) { + reports.push(makeWriteBlockReport(offset, configBytes.slice(offset, offset + keysilkV1Protocol.payloadLength))); + } + return reports; +} + +function fillProbeTail(report) { + for (let i = 5; i < report.length; i += 1) { + report[i] = i; + } +} diff --git a/sdks/portable-keypad/companion-runtime.js b/sdks/portable-keypad/companion-runtime.js new file mode 100644 index 00000000..070ea6d2 --- /dev/null +++ b/sdks/portable-keypad/companion-runtime.js @@ -0,0 +1,196 @@ +const HOTKEY_ACCELERATORS = { + CtrlAltShiftB: "Ctrl+Alt+Shift+B", + CtrlAltShiftC: "Ctrl+Alt+Shift+C", + CtrlAltShiftD: "Ctrl+Alt+Shift+D", + CtrlAltShiftE: "Ctrl+Alt+Shift+E", + CtrlAltShiftF: "Ctrl+Alt+Shift+F", + CtrlAltShiftG: "Ctrl+Alt+Shift+G", + CtrlAltShiftH: "Ctrl+Alt+Shift+H", + CtrlAltShiftI: "Ctrl+Alt+Shift+I", +}; + +const HOTKEY_ACTION_TYPES = new Set([ + "hotkey_open_url", + "hotkey_open_path", + "hotkey_text", +]); + +const RAW_REPORT_ACTION_TYPES = new Set([ + "open_url", + "open_path", +]); + +function normalizeHex(value) { + return String(value || "").replace(/[^0-9a-f]/gi, "").toLowerCase(); +} + +function normalizeHotkey(value) { + return String(value || "").replace(/\s+/g, "").toLowerCase(); +} + +function normalizeCompanionProfile(profile = {}) { + const actions = (profile.actions || []).map((action) => { + const next = { ...action }; + if (HOTKEY_ACTION_TYPES.has(next.type)) { + next.binding = next.binding || "CtrlAltShiftB"; + next.hotkey = next.hotkey || HOTKEY_ACCELERATORS[next.binding] || next.binding; + } + return next; + }); + + return { + version: profile.version || 1, + adapter: profile.adapter || actions[0]?.adapter || null, + layout: profile.layout || actions[0]?.layout || null, + actions, + }; +} + +function validateCompanionProfile(profile = {}) { + const normalized = normalizeCompanionProfile(profile); + const errors = []; + const warnings = []; + const hotkeyCounts = new Map(); + const reportCounts = new Map(); + + normalized.actions.forEach((action, index) => { + if (!action || typeof action !== "object") { + errors.push(`actions[${index}] must be an object`); + return; + } + + if (HOTKEY_ACTION_TYPES.has(action.type)) { + const key = action.binding || action.hotkey; + if (!key) { + errors.push(`actions[${index}] hotkey action is missing binding/hotkey`); + } else { + hotkeyCounts.set(key, (hotkeyCounts.get(key) || 0) + 1); + } + } else if (RAW_REPORT_ACTION_TYPES.has(action.type)) { + const prefix = normalizeHex(action.reportPrefixHex); + if (!prefix) { + errors.push(`actions[${index}] raw-report action is missing reportPrefixHex`); + } else { + reportCounts.set(prefix, (reportCounts.get(prefix) || 0) + 1); + } + } else { + errors.push(`actions[${index}] unsupported companion action type "${action.type || "unknown"}"`); + } + + if (action.type === "open_url" || action.type === "hotkey_open_url") { + if (!action.url) errors.push(`actions[${index}] URL action is missing url`); + } + if (action.type === "open_path" || action.type === "hotkey_open_path") { + if (!action.path) errors.push(`actions[${index}] path action is missing path`); + } + if (action.type === "hotkey_text") { + if (!action.text) errors.push(`actions[${index}] text action is missing text`); + } + }); + + for (const [key, count] of hotkeyCounts) { + if (count > 1) { + warnings.push(`Multiple companion actions share hotkey "${key}". The host app cannot distinguish physical keypad actions unless each one uses a unique reserved hotkey.`); + } + } + + for (const [prefix, count] of reportCounts) { + if (count > 1) { + warnings.push(`Multiple companion actions share raw report prefix "${prefix}". The host app will dispatch all matching actions.`); + } + } + + return { ok: errors.length === 0, errors, warnings, profile: normalized }; +} + +function getCompanionTriggers(profile = {}) { + const normalized = normalizeCompanionProfile(profile); + const hotkeys = []; + const rawReports = []; + + normalized.actions.forEach((action) => { + if (HOTKEY_ACTION_TYPES.has(action.type)) { + const existing = hotkeys.find((item) => item.binding === action.binding && item.hotkey === action.hotkey); + if (existing) { + existing.actions.push(action); + } else { + hotkeys.push({ + binding: action.binding, + hotkey: action.hotkey, + actions: [action], + }); + } + } + + if (RAW_REPORT_ACTION_TYPES.has(action.type)) { + const prefixHex = normalizeHex(action.reportPrefixHex); + const existing = rawReports.find((item) => item.reportPrefixHex === prefixHex); + if (existing) { + existing.actions.push(action); + } else { + rawReports.push({ + reportPrefixHex: prefixHex, + actions: [action], + }); + } + } + }); + + return { hotkeys, rawReports }; +} + +function dispatchCompanionAction(action, host) { + if (!host || typeof host !== "object") { + throw new Error("Companion host handlers are required"); + } + + if (action.type === "open_url" || action.type === "hotkey_open_url") { + if (typeof host.openUrl !== "function") { + throw new Error("Companion host handler openUrl(url, action) is required"); + } + return host.openUrl(action.url, action); + } + + if (action.type === "open_path" || action.type === "hotkey_open_path") { + if (typeof host.openPath !== "function") { + throw new Error("Companion host handler openPath(path, action) is required"); + } + return host.openPath(action.path, action); + } + + if (action.type === "hotkey_text") { + if (typeof host.pasteText !== "function") { + throw new Error("Companion host handler pasteText(text, action) is required"); + } + return host.pasteText(action.text, action); + } + + throw new Error(`Unsupported companion action type "${action.type || "unknown"}"`); +} + +function handleCompanionHotkey(profile, bindingOrHotkey, host) { + const target = normalizeHotkey(bindingOrHotkey); + const actions = normalizeCompanionProfile(profile).actions.filter((action) => + HOTKEY_ACTION_TYPES.has(action.type) + && (normalizeHotkey(action.binding) === target || normalizeHotkey(action.hotkey) === target), + ); + return actions.map((action) => dispatchCompanionAction(action, host)); +} + +function handleCompanionRawReport(profile, reportHex, host) { + const report = normalizeHex(reportHex); + const actions = normalizeCompanionProfile(profile).actions.filter((action) => + RAW_REPORT_ACTION_TYPES.has(action.type) + && report.startsWith(normalizeHex(action.reportPrefixHex)), + ); + return actions.map((action) => dispatchCompanionAction(action, host)); +} + +module.exports = { + dispatchCompanionAction, + getCompanionTriggers, + handleCompanionHotkey, + handleCompanionRawReport, + normalizeCompanionProfile, + validateCompanionProfile, +}; diff --git a/sdks/portable-keypad/core/device-model.js b/sdks/portable-keypad/core/device-model.js new file mode 100644 index 00000000..74a0757e --- /dev/null +++ b/sdks/portable-keypad/core/device-model.js @@ -0,0 +1,37 @@ +const componentTypes = { + button: ["press", "hold", "release", "double_press"], + encoder: ["rotate_left", "rotate_right", "press", "press_rotate_left", "press_rotate_right"], + toggle: ["position_1", "position_2", "position_3"], + joystick: ["up", "down", "left", "right", "press", "axis_x", "axis_y"], +}; + +function createDeviceModel({ adapter, device, layout, layers }) { + return { + sdkVersion: "0.1.0", + adapter, + device, + layout, + layers, + }; +} + +function createEmptyBindings(layout, layerCount = 10) { + const actions = layout.components.flatMap((component) => component.actions); + const layers = {}; + for (let layer = 0; layer < layerCount; layer += 1) { + layers[layer] = {}; + for (const action of actions) { + layers[layer][action.id] = { + type: "unassigned", + value: null, + }; + } + } + return layers; +} + +module.exports = { + componentTypes, + createDeviceModel, + createEmptyBindings, +}; diff --git a/sdks/portable-keypad/index.d.ts b/sdks/portable-keypad/index.d.ts new file mode 100644 index 00000000..0039e624 --- /dev/null +++ b/sdks/portable-keypad/index.d.ts @@ -0,0 +1,427 @@ +/// + +export type AdapterId = "keysilk_v1"; + +export type KeySilkActionId = + | "key1.press" + | "key2.press" + | "key3.press" + | "knob1.rotate_left" + | "knob1.press" + | "knob1.rotate_right" + | "knob1.press_rotate_left" + | "knob1.press_rotate_right"; + +export type BindingId = + | "Ctrl" + | "Shift" + | "Alt" + | "Win" + | "CtrlA" + | "CtrlC" + | "CtrlV" + | "CtrlX" + | "CtrlZ" + | "CtrlY" + | "CtrlS" + | "CtrlP" + | "CtrlF" + | "CtrlAltShiftB" + | "CtrlAltShiftC" + | "CtrlAltShiftD" + | "CtrlAltShiftE" + | "CtrlAltShiftF" + | "CtrlAltShiftG" + | "CtrlAltShiftH" + | "CtrlAltShiftI" + | "A" + | "B" + | "C" + | "D" + | "E" + | "F" + | "G" + | "H" + | "I" + | "J" + | "K" + | "L" + | "M" + | "N" + | "O" + | "P" + | "Q" + | "R" + | "S" + | "T" + | "U" + | "V" + | "W" + | "X" + | "Y" + | "Z" + | "Digit0" + | "Digit1" + | "Digit2" + | "Digit3" + | "Digit4" + | "Digit5" + | "Digit6" + | "Digit7" + | "Digit8" + | "Digit9" + | "Esc" + | "Enter" + | "Space" + | "Mute" + | "VolumeDown" + | "VolumeUp" + | "PlayPause" + | "NextLayer" + | "PreviousLayer" + | "HostOpenUrl" + | "HostOpenPath" + | "MouseLeftClick" + | "MouseRightClick" + | "MouseMiddleClick" + | "MouseWheelUp" + | "MouseWheelDown" + | "CtrlMouseWheelUp" + | "CtrlMouseWheelDown" + | "MacroObserved" + | "TextObserved" + | "OpenCalculatorObserved"; + +export type BindingScope = "base" | "extended"; + +export type HotkeyCompanionBinding = + | "CtrlAltShiftB" + | "CtrlAltShiftC" + | "CtrlAltShiftD" + | "CtrlAltShiftE" + | "CtrlAltShiftF" + | "CtrlAltShiftG" + | "CtrlAltShiftH" + | "CtrlAltShiftI"; + +export interface AdapterInfo { + id: AdapterId; + brand: string; + vid: number; + pid: number; + layout: string; + actions: KeySilkActionId[]; + bindings: BindingId[]; +} + +export interface KeyboardDevice { + adapter: AdapterId; + brand: string; + vid: number; + pid: number; + path: string; + interfaceIndex: number; + usagePage: number; + usage: number; + inputReportLength: number; + outputReportLength: number; + featureReportLength: number; + isConfigInterface: boolean; +} + +export interface LayoutAction { + id: KeySilkActionId | string; + label: string; +} + +export interface LayoutComponent { + id: string; + type: "button" | "encoder" | string; + label: string; + actions: LayoutAction[]; +} + +export interface KeyboardLayout { + brand: string; + model: string; + adapter: AdapterId; + layout: string; + components: LayoutComponent[]; +} + +export interface BindingInfo { + id: BindingId; + label: string; + category: "shortcut" | "basic-key" | "media" | "layer" | "host-action" | "mouse" | "mouse-wheel" | "macro" | "text" | "open-program" | string; + verification: "hardware" | "observed" | "inferred" | string; + aliases: string[]; +} + +export type CapabilityState = "done" | "observed" | "inferred" | "blocked"; + +export interface CapabilityFeature { + id: string; + label: string; + state: CapabilityState; + bindings?: string[]; +} + +export interface CapabilityInfo { + adapter: AdapterId; + layout: string; + states: Record; + features: CapabilityFeature[]; +} + +export interface ParsedRecordBinding { + label: string; + typeByte: number; + code: [number, number]; + rawTail: number[]; +} + +export interface ParsedRecord { + action: KeySilkActionId | string; + offset: number | null; + length: number; + binding: ParsedRecordBinding | null; +} + +export interface ParsedConfig { + declaredLength: number; + rawLength: number; + records: ParsedRecord[]; + extendedRecords: ParsedRecord[]; +} + +export interface KeyboardConfig { + adapter: AdapterId; + device: KeyboardDevice | null; + raw: Buffer; + parsed: ParsedConfig; +} + +export interface ImportConfigOptions { + adapter?: AdapterId; + device?: KeyboardDevice | null; +} + +export interface WriteDeviceOptions { + ack?: boolean; + inherit?: boolean; +} + +export interface ActionBindingRequest { + scope?: BindingScope; + layer?: number; + action: KeySilkActionId | string; + binding: BindingId | StructuredBinding; +} + +export type StructuredBinding = + | { type: "mouse"; action: string } + | { type: "text"; value: string } + | { type: "macro"; steps: unknown[] } + | { type: "open_program"; path: string }; + +export interface ActionBindingResult { + action: string; + binding: BindingId | null; + raw: ParsedRecordBinding | null; + offset: number | null; +} + +export interface BindingModel { + sdkVersion?: string; + adapter?: AdapterId; + layout?: string; + base?: Partial>; + extended?: Partial>; + hostActions?: Array; + simpleMacroActions?: SimpleMacroTapsRequest[]; +} + +export interface HostOpenUrlRequest { + scope?: BindingScope; + action: KeySilkActionId | string; + url: string; +} + +export interface HostOpenPathRequest { + scope?: BindingScope; + action: KeySilkActionId | string; + path: string; +} + +export interface HotkeyOpenUrlRequest { + scope?: BindingScope; + action: KeySilkActionId | string; + url: string; + binding?: HotkeyCompanionBinding; +} + +export interface HotkeyOpenPathRequest { + scope?: BindingScope; + action: KeySilkActionId | string; + path: string; + binding?: HotkeyCompanionBinding; +} + +export interface HotkeyTextRequest { + scope?: BindingScope; + action: KeySilkActionId | string; + text: string; + binding?: HotkeyCompanionBinding; +} + +export interface HostOpenUrlProfileAction { + type: "open_url"; + adapter: AdapterId; + layout: string; + scope: BindingScope; + action: KeySilkActionId | string; + url: string; + reportPrefixHex: string; + note?: string; +} + +export interface HostOpenPathProfileAction { + type: "open_path"; + adapter: AdapterId; + layout: string; + scope: BindingScope; + action: KeySilkActionId | string; + path: string; + reportPrefixHex: string; + note?: string; +} + +export interface HotkeyOpenUrlProfileAction { + type: "hotkey_open_url"; + adapter: AdapterId; + layout: string; + scope: BindingScope; + action: KeySilkActionId | string; + url: string; + binding: HotkeyCompanionBinding; + hotkey: string; + note?: string; +} + +export interface HotkeyOpenPathProfileAction { + type: "hotkey_open_path"; + adapter: AdapterId; + layout: string; + scope: BindingScope; + action: KeySilkActionId | string; + path: string; + binding: HotkeyCompanionBinding; + hotkey: string; + note?: string; +} + +export interface HotkeyTextProfileAction { + type: "hotkey_text"; + adapter: AdapterId; + layout: string; + scope: BindingScope; + action: KeySilkActionId | string; + text: string; + binding: HotkeyCompanionBinding; + hotkey: string; + note?: string; +} + +export interface HostActionProfile { + version: 1; + adapter: AdapterId; + layout: string; + actions: Array; +} + +export type CompanionProfileAction = + | HostOpenUrlProfileAction + | HostOpenPathProfileAction + | HotkeyOpenUrlProfileAction + | HotkeyOpenPathProfileAction + | HotkeyTextProfileAction; + +export interface CompanionRuntimeProfile extends Omit { + adapter: AdapterId | string | null; + layout: string | null; + actions: CompanionProfileAction[]; +} + +export interface CompanionValidationResult { + ok: boolean; + errors: string[]; + warnings: string[]; + profile: CompanionRuntimeProfile; +} + +export interface CompanionTriggerPlan { + hotkeys: Array<{ + binding: string; + hotkey: string; + actions: CompanionProfileAction[]; + }>; + rawReports: Array<{ + reportPrefixHex: string; + actions: CompanionProfileAction[]; + }>; +} + +export interface CompanionHostHandlers { + openUrl?: (url: string, action: CompanionProfileAction) => unknown; + openPath?: (path: string, action: CompanionProfileAction) => unknown; + pasteText?: (text: string, action: CompanionProfileAction) => unknown; +} + +export interface SimpleMacroTap { + key: BindingId | string; + delayMs?: number; +} + +export interface SimpleMacroTapsRequest { + scope?: "extended"; + action: KeySilkActionId | string; + label?: string; + taps: SimpleMacroTap[]; +} + +export function listAdapters(): AdapterInfo[]; +export function listDevices(): KeyboardDevice[]; +export function readDevice(device?: KeyboardDevice): KeyboardConfig; +export function writeDevice(config: KeyboardConfig, maybeConfig?: null, options?: WriteDeviceOptions): void; +export function writeDevice(device: KeyboardDevice, config: KeyboardConfig, options?: WriteDeviceOptions): void; +export function importConfig(rawConfig: Buffer | Uint8Array, options?: ImportConfigOptions): KeyboardConfig; +export function exportConfig(config: KeyboardConfig | Buffer): Buffer; +export function getLayout(deviceOrConfig?: KeyboardDevice | KeyboardConfig): KeyboardLayout; +export function getBindings(deviceOrConfig?: KeyboardDevice | KeyboardConfig): Record; +export function getCapabilities(deviceOrConfig?: KeyboardDevice | KeyboardConfig): CapabilityInfo; +export function getActionBinding( + config: KeyboardConfig, + actionId: KeySilkActionId | string, + options?: { scope?: BindingScope; layer?: number } +): ActionBindingResult | null; +export function setActionBinding(config: KeyboardConfig, request: ActionBindingRequest): KeyboardConfig; +export function setActionBinding( + config: KeyboardConfig, + actionId: KeySilkActionId | string, + bindingId: BindingId | StructuredBinding, + options?: { scope?: BindingScope; layer?: number } +): KeyboardConfig; +export function applyBindingModel(config: KeyboardConfig, model: BindingModel): KeyboardConfig; +export function setHostOpenUrlAction(config: KeyboardConfig, request: HostOpenUrlRequest): KeyboardConfig; +export function setHostOpenPathAction(config: KeyboardConfig, request: HostOpenPathRequest): KeyboardConfig; +export function setHotkeyOpenUrlAction(config: KeyboardConfig, request: HotkeyOpenUrlRequest): KeyboardConfig; +export function setHotkeyOpenPathAction(config: KeyboardConfig, request: HotkeyOpenPathRequest): KeyboardConfig; +export function setHotkeyTextAction(config: KeyboardConfig, request: HotkeyTextRequest): KeyboardConfig; +export function setSimpleMacroTapsAction(config: KeyboardConfig, request: SimpleMacroTapsRequest): KeyboardConfig; +export function exportHostActionProfile(config: KeyboardConfig): HostActionProfile; +export function normalizeCompanionProfile(profile: Partial): CompanionRuntimeProfile; +export function validateCompanionProfile(profile: Partial): CompanionValidationResult; +export function getCompanionTriggers(profile: Partial): CompanionTriggerPlan; +export function dispatchCompanionAction(action: CompanionProfileAction, host: CompanionHostHandlers): unknown; +export function handleCompanionHotkey(profile: Partial, bindingOrHotkey: string, host: CompanionHostHandlers): unknown[]; +export function handleCompanionRawReport(profile: Partial, reportHex: string, host: CompanionHostHandlers): unknown[]; diff --git a/sdks/portable-keypad/index.js b/sdks/portable-keypad/index.js new file mode 100644 index 00000000..260ed402 --- /dev/null +++ b/sdks/portable-keypad/index.js @@ -0,0 +1,204 @@ +const keySilkV1 = require("./adapters/keysilk-v1/adapter"); +const companionRuntime = require("./companion-runtime"); + +const adapters = { + [keySilkV1.adapterInfo.id]: keySilkV1, +}; + +function getAdapter(adapterId) { + const adapter = adapters[adapterId]; + if (!adapter) { + throw new Error(`Unsupported keyboard adapter: ${adapterId}`); + } + return adapter; +} + +function listAdapters() { + return Object.values(adapters).map((adapter) => adapter.adapterInfo); +} + +function listDevices() { + return Object.values(adapters).flatMap((adapter) => adapter.listDevices()); +} + +function resolveAdapterForDevice(device) { + if (device && device.adapter) return getAdapter(device.adapter); + return keySilkV1; +} + +function readDevice(device) { + return resolveAdapterForDevice(device).readDevice(device); +} + +function writeDevice(deviceOrConfig, maybeConfig, options) { + const config = maybeConfig || deviceOrConfig; + const adapter = getAdapter(config.adapter || (deviceOrConfig && deviceOrConfig.adapter)); + return adapter.writeDevice(deviceOrConfig, maybeConfig, options); +} + +function importConfig(rawConfig, options = {}) { + const adapter = getAdapter(options.adapter || keySilkV1.adapterInfo.id); + return adapter.importConfig(rawConfig, options); +} + +function exportConfig(config) { + return getAdapter(config.adapter).exportConfig(config); +} + +function getLayout(deviceOrConfig) { + return resolveAdapterForDevice(deviceOrConfig).getLayout(deviceOrConfig); +} + +function getBindings(deviceOrConfig) { + return resolveAdapterForDevice(deviceOrConfig).getBindings(deviceOrConfig); +} + +function getCapabilities(deviceOrConfig) { + return resolveAdapterForDevice(deviceOrConfig).getCapabilities(deviceOrConfig); +} + +function getActionBinding(config, actionId, options) { + return getAdapter(config.adapter).getActionBinding(config, actionId, options); +} + +function setActionBinding(config, actionIdOrRequest, bindingId, options) { + if (typeof actionIdOrRequest === "object") { + const request = actionIdOrRequest; + if (request.binding && typeof request.binding === "object") { + throw new Error(`Structured binding type "${request.binding.type || "unknown"}" is not supported by ${config.adapter} yet`); + } + return getAdapter(config.adapter).setActionBinding( + config, + request.action, + request.binding, + { scope: request.scope, layer: request.layer, ...options }, + ); + } + + return getAdapter(config.adapter).setActionBinding(config, actionIdOrRequest, bindingId, options); +} + +function applyBindingModel(config, model) { + if (model.adapter && model.adapter !== config.adapter) { + throw new Error(`Model adapter ${model.adapter} does not match config adapter ${config.adapter}`); + } + + for (const [action, binding] of Object.entries(model.base || {})) { + if (binding) { + setActionBinding(config, { scope: "base", action, binding }); + } + } + + for (const [action, binding] of Object.entries(model.extended || {})) { + if (binding) { + setActionBinding(config, { scope: "extended", action, binding }); + } + } + + for (const hostAction of model.hostActions || []) { + if (hostAction && hostAction.type === "open_url") { + setHostOpenUrlAction(config, { + scope: hostAction.scope || "extended", + action: hostAction.action, + url: hostAction.url, + }); + } + if (hostAction && hostAction.type === "open_path") { + setHostOpenPathAction(config, { + scope: hostAction.scope || "extended", + action: hostAction.action, + path: hostAction.path, + }); + } + if (hostAction && hostAction.type === "hotkey_open_url") { + setHotkeyOpenUrlAction(config, { + scope: hostAction.scope || "extended", + action: hostAction.action, + url: hostAction.url, + binding: hostAction.binding, + }); + } + if (hostAction && hostAction.type === "hotkey_open_path") { + setHotkeyOpenPathAction(config, { + scope: hostAction.scope || "extended", + action: hostAction.action, + path: hostAction.path, + binding: hostAction.binding, + }); + } + if (hostAction && hostAction.type === "hotkey_text") { + setHotkeyTextAction(config, { + scope: hostAction.scope || "extended", + action: hostAction.action, + text: hostAction.text, + binding: hostAction.binding, + }); + } + } + + for (const macroAction of model.simpleMacroActions || []) { + if (macroAction && macroAction.type === "simple_macro_taps") { + setSimpleMacroTapsAction(config, { + scope: macroAction.scope || "extended", + action: macroAction.action, + taps: macroAction.taps, + label: macroAction.label, + }); + } + } + + return config; +} + +function setHostOpenUrlAction(config, request) { + return getAdapter(config.adapter).setHostOpenUrlAction(config, request); +} + +function setHostOpenPathAction(config, request) { + return getAdapter(config.adapter).setHostOpenPathAction(config, request); +} + +function setHotkeyOpenUrlAction(config, request) { + return getAdapter(config.adapter).setHotkeyOpenUrlAction(config, request); +} + +function setHotkeyOpenPathAction(config, request) { + return getAdapter(config.adapter).setHotkeyOpenPathAction(config, request); +} + +function setHotkeyTextAction(config, request) { + return getAdapter(config.adapter).setHotkeyTextAction(config, request); +} + +function setSimpleMacroTapsAction(config, request) { + return getAdapter(config.adapter).setSimpleMacroTapsAction(config, request); +} + +function exportHostActionProfile(config) { + return getAdapter(config.adapter).exportHostActionProfile(config); +} + +module.exports = { + adapters, + applyBindingModel, + ...companionRuntime, + exportHostActionProfile, + exportConfig, + getActionBinding, + getAdapter, + getBindings, + getCapabilities, + getLayout, + importConfig, + listAdapters, + listDevices, + readDevice, + setActionBinding, + setHostOpenPathAction, + setHostOpenUrlAction, + setHotkeyOpenPathAction, + setHotkeyOpenUrlAction, + setHotkeyTextAction, + setSimpleMacroTapsAction, + writeDevice, +}; diff --git a/sdks/portable-keypad/package.json b/sdks/portable-keypad/package.json new file mode 100644 index 00000000..65959c38 --- /dev/null +++ b/sdks/portable-keypad/package.json @@ -0,0 +1,25 @@ +{ + "name": "@ahakey/portable-keypad-sdk", + "version": "0.1.0", + "description": "Portable keypad configuration SDK for AhaKey plugins, with KeySilk v1 adapter support.", + "main": "index.js", + "types": "index.d.ts", + "type": "commonjs", + "files": [ + "index.js", + "index.d.ts", + "companion-runtime.js", + "README.md", + "core/**/*.js", + "adapters/**/*.js", + "adapters/**/*.json" + ], + "keywords": [ + "keypad", + "keyboard", + "hid", + "keysilk", + "coidea" + ], + "license": "UNLICENSED" +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 7fbb83c2..f1b2f9a0 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -57,6 +57,16 @@ export interface SwitchStateResult { agentReachable: boolean; } +export interface HostBooleanResult { + opened?: boolean; + pasted?: boolean; + unregistered?: boolean; +} + +export interface RegisterGlobalHotkeyResult { + token: string; +} + export type HostLogLevel = "debug" | "info" | "warn" | "error" | (string & {}); export type RpcMethod = ( @@ -418,6 +428,26 @@ export class AhaKeyHost { getSwitchState(): Promise { return this.call("host/getSwitchState"); } + + openUrl(url: string): Promise { + return this.call("host/openUrl", { url }); + } + + openPath(path: string): Promise { + return this.call("host/openPath", { path }); + } + + pasteText(text: string): Promise { + return this.call("host/pasteText", { text }); + } + + registerGlobalHotkey(hotkey: string, callbackMethod: string): Promise { + return this.call("host/registerGlobalHotkey", { hotkey, callbackMethod }); + } + + unregisterGlobalHotkey(token: string): Promise { + return this.call("host/unregisterGlobalHotkey", { token }); + } } export class AhaKeyPluginServer {