Repository navigation
feat(keychron): Hall-effect keyboard controls behind a flag #1287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
78db363
feat(keychron): add a Hall-effect keyboard adapter behind a flag
snekxs e29eaf9
feat(keychron): add the Hall-effect keyboard panel
snekxs ac3cc51
feat(keychron): add a dev-only preview with a fake board
snekxs 4597ad0
merge: bring in main (keyboard-protocol 1.4.0)
snekxs d152fc0
fix(keychron): preserve verified state and localize remap warnings
snekxs a161b48
fix(keychron): keep write scope and surface read failures
snekxs 3e3b283
fix(keychron): distinguish unsupported optional reads from transport …
snekxs d9446ef
merge: combine Keychron and Arbiter keyboard controls
snekxs a43f978
chore(build): account for combined keyboard workspaces
snekxs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; | ||
| import type { KeychronCurve } from "@openmouse/keyboard-protocol/keychron"; | ||
| import { CURVE_MAX_Y, clampCurve, curveInRange, curveScale, linearCurve, setCurvePoint } from "./keychron-curve.ts"; | ||
|
|
||
| const base: KeychronState = { | ||
| productId: 0x040c, heVersion: 4, features: { switchAxis: true, resetSingleKey: false, dkr: false, turbo: false, gamepadDisable: false, supportMechanical: true }, | ||
| unit: 0.1, profileSize: 0, dksCount: null, snapTapCount: null, rows: 6, cols: 19, travel: { min: 0.2, max: 3.3, deadzone: 0.2 }, | ||
| }; | ||
|
|
||
| test("x runs to floor(10 * (max + dead zone)) and y to 127", () => { | ||
| assert.deepEqual(curveScale(base, null), { maxX: 35, fromBoard: true, unverifiedStep: false }); | ||
| assert.equal(curveScale({ ...base, travel: { min: 0.1, max: 3.35 } }, null).maxX, 33); | ||
| assert.equal(curveScale({ ...base, travel: null }, 3.3).maxX, 33); | ||
| assert.deepEqual(curveScale({ ...base, travel: null }, null), { maxX: 255, fromBoard: false, unverifiedStep: false }); | ||
| assert.equal(curveScale({ ...base, heVersion: 5 }, null).unverifiedStep, true); | ||
| }); | ||
|
|
||
| test("the linear preset is Launcher's: thirds along, ending at (max, 127)", () => { | ||
| assert.deepEqual(linearCurve(35), [[0, 0], [12, 42], [23, 85], [35, 127]]); | ||
| assert.ok(curveInRange(linearCurve(35), 35)); | ||
| assert.ok(curveInRange(linearCurve(255), 255)); | ||
| }); | ||
|
|
||
| test("a point stays between its neighbours and inside the ranges", () => { | ||
| const curve = linearCurve(35); | ||
| assert.deepEqual(setCurvePoint(curve, 1, 99, 500, 35)[1], [23, 127]); | ||
| assert.deepEqual(setCurvePoint(curve, 2, -4, -9, 35)[2], [12, 0]); | ||
| assert.deepEqual(setCurvePoint(curve, 0, 8, 3, 35)[0], [8, 3]); // the first point may move right, up to the second point | ||
| assert.deepEqual(setCurvePoint(curve, 0, 40, 3, 35)[0], [12, 3]); | ||
| assert.deepEqual(setCurvePoint(curve, 3, 200, 10, 35)[3], [35, 10]); | ||
| assert.deepEqual(setCurvePoint(curve, 1, Number.NaN, Number.NaN, 35)[1], [0, 0]); | ||
| }); | ||
|
|
||
| test("no sequence of edits can leave a byte out of range", () => { | ||
| let seed = 7; | ||
| const random = (): number => { seed = (seed * 48271) % 2147483647; return seed / 2147483647; }; | ||
| for (const maxX of [1, 33, 35, 255]) { | ||
| let curve: KeychronCurve = linearCurve(maxX); | ||
| for (let step = 0; step < 500; step++) { | ||
| curve = setCurvePoint(curve, Math.floor(random() * 4), (random() - 0.3) * 600, (random() - 0.3) * 400, maxX); | ||
| assert.ok(curveInRange(curve, maxX), JSON.stringify(curve)); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| test("clampCurve repairs a curve outside the ranges", () => { | ||
| const repaired = clampCurve([[40, 200], [30, -3], [220, 50], [10, 127]], 35); | ||
| assert.deepEqual(repaired, [[35, CURVE_MAX_Y], [35, 0], [35, 50], [35, 127]]); | ||
| assert.ok(curveInRange(repaired, 35)); | ||
| assert.equal(curveInRange([[0, 0], [36, 0], [36, 0], [36, 0]], 35), false); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { KEYCHRON_ADVANCE_MODE, type KeychronCurve, type KeychronProfileBuffer } from "@openmouse/keyboard-protocol/keychron"; | ||
| import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; | ||
|
|
||
| /** | ||
| * Ranges for the response curve, as Keychron Launcher's curve editor uses them | ||
| * (read from its code, not verified on hardware): x is key travel in 0.1 mm | ||
| * steps up to the board's maximum travel plus dead zone, y is output strength | ||
| * 0 to 127, and the four points keep x non-decreasing left to right. | ||
| */ | ||
| export const CURVE_MAX_Y = 127; | ||
| /** Millimetres per x step, as Launcher derives it (mm * 10). */ | ||
| export const CURVE_X_STEP_MM = 0.1; | ||
|
|
||
| export interface CurveScale { | ||
| /** Largest x byte. */ | ||
| maxX: number; | ||
| /** False when neither the board definition nor the axis type gave a travel range, so the byte limit is used. */ | ||
| fromBoard: boolean; | ||
| /** Command v5 uses another travel step; Launcher still scales x by 10, so that scale is unverified there. */ | ||
| unverifiedStep: boolean; | ||
| } | ||
|
|
||
| export function curveScale(state: KeychronState, axisMaxMm: number | null): CurveScale { | ||
| const travel = state.travel; | ||
| const mm = travel?.max !== undefined ? travel.max + (travel.deadzone ?? 0) : axisMaxMm; | ||
| const maxX = mm === null || mm <= 0 ? 255 : Math.min(255, Math.floor(mm / CURVE_X_STEP_MM + 1e-6)); | ||
| return { maxX, fromBoard: mm !== null && mm > 0, unverifiedStep: state.heVersion >= 5 }; | ||
| } | ||
|
|
||
| const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, Math.round(Number.isFinite(value) ? value : low))); | ||
|
|
||
| /** Move one point, keeping every byte in range and x between its neighbours. */ | ||
| export function setCurvePoint(curve: KeychronCurve, index: number, x: number, y: number, maxX: number): KeychronCurve { | ||
| const low = index === 0 ? 0 : curve[index - 1]![0]; | ||
| const high = index === curve.length - 1 ? maxX : curve[index + 1]![0]; | ||
| return curve.map((point, i) => (i === index ? [clamp(x, low, Math.max(low, high)), clamp(y, 0, CURVE_MAX_Y)] : point)) as unknown as KeychronCurve; | ||
| } | ||
|
|
||
| /** Bring any curve into range, left to right. */ | ||
| export function clampCurve(curve: KeychronCurve, maxX: number): KeychronCurve { | ||
| let previous = 0; | ||
| return curve.map(([x, y]) => { | ||
| previous = clamp(x, previous, maxX); | ||
| return [previous, clamp(y, 0, CURVE_MAX_Y)]; | ||
| }) as unknown as KeychronCurve; | ||
| } | ||
|
|
||
| export function curveInRange(curve: KeychronCurve, maxX: number): boolean { | ||
| let previous = 0; | ||
| return curve.every(([x, y]) => { | ||
| const ok = Number.isInteger(x) && Number.isInteger(y) && x >= previous && x <= maxX && y >= 0 && y <= CURVE_MAX_Y; | ||
| previous = x; | ||
| return ok; | ||
| }); | ||
| } | ||
|
|
||
| /** Launcher's linear preset: from (0, 0) to (max, 127) with the middle points a third and two thirds along. */ | ||
| export function linearCurve(maxX: number): KeychronCurve { | ||
| const at = (fraction: number): [number, number] => [Math.round(maxX * fraction), Math.round(CURVE_MAX_Y * fraction)]; | ||
| return [[0, 0], at(1 / 3), at(2 / 3), [maxX, CURVE_MAX_Y]]; | ||
| } | ||
|
|
||
| /** Keys set to the analog advanced mode; the curve shapes their output. */ | ||
| export function analogKeyCount(profile: KeychronProfileBuffer | null): number { | ||
| if (!profile) return 0; | ||
| return profile.keys.reduce((sum, row) => sum + row.filter((key) => key.advanceMode === KEYCHRON_ADVANCE_MODE.analog).length, 0); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| /** | ||
| * Keychron Hall-effect support is experimental: the protocol was derived from | ||
| * static analysis of Keychron Launcher and has not run on a physical board. | ||
| * It stays off until the flag is set, e.g. in the browser console: | ||
| * | ||
| * localStorage.setItem("openmouse.keychronHallEffect", "1"); location.reload(); | ||
| */ | ||
| export const KEYCHRON_HALL_EFFECT_FLAG = "openmouse.keychronHallEffect"; | ||
|
|
||
| export function keychronHallEffectEnabled(): boolean { | ||
| try { | ||
| return globalThis.localStorage?.getItem(KEYCHRON_HALL_EFFECT_FLAG) === "1"; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: OpenMouse-Project/openmouse
Length of output: 8511
Script executed:
Repository: OpenMouse-Project/openmouse
Length of output: 18849
Mark the Keychron workspace stale when a profile write succeeds but its refresh fails.
keychronSelectProfilesendsselectProfile, then callsrefresh(). Ifrefresh()fails, the board keeps the new profile and the panel keeps the old one. Later writes reach the new profile.keychronRename,keychronSave, andkeychronClearhave the same gap. Mark the workspace stale after a failed refresh, and block profile-dependent commands until a refresh succeeds.