pi-keyboard 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/README.zh-CN.md +8 -1
- package/features/keyboard.feature +30 -0
- package/package.json +2 -2
- package/src/driver.ts +6 -15
- package/src/global-sessions.ts +11 -4
- package/src/protocol.ts +0 -62
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# pi-keyboard
|
|
2
2
|
|
|
3
|
+
Lighting commands use `via-rgb`, which owns HID packet encoding.
|
|
4
|
+
|
|
3
5
|
Dynamic VIA/QMK keyboard RGB lighting indicator reflecting Pi's internal states.
|
|
4
6
|
|
|
5
7
|
Ambient physical awareness of what your AI coding agent is doing right on your mechanical keyboard:
|
|
@@ -27,6 +29,15 @@ Supports discrete control for keyboard lighting channels:
|
|
|
27
29
|
- **Per-Key Matrix** (`matrix`): Keycap backlight (Channel 3 in VIA/QMK).
|
|
28
30
|
- **Underglow / Side Strips** (`underglow`): Bottom and edge light strips (Channel 2 in VIA/QMK).
|
|
29
31
|
|
|
32
|
+
## Shared Directory Session Registry
|
|
33
|
+
|
|
34
|
+
Per-session glow records use pi-kit’s canonical directory identity, hashed into
|
|
35
|
+
the registry directory name. Existing paths are resolved through `realpath`,
|
|
36
|
+
while missing paths use their absolute path; this keeps slash-versus-hyphen
|
|
37
|
+
project names separate. Reads, cleanup, and clean shutdown verify the stored
|
|
38
|
+
`cwd` belongs to the requested directory. Older registry directory names are
|
|
39
|
+
not migrated.
|
|
40
|
+
|
|
30
41
|
---
|
|
31
42
|
|
|
32
43
|
## Structure
|
|
@@ -37,7 +48,6 @@ keyboard/
|
|
|
37
48
|
├── src/
|
|
38
49
|
│ ├── config.ts — Keyboard config read/write helpers
|
|
39
50
|
│ ├── driver.ts — via-rgb binary resolution & CLI execution
|
|
40
|
-
│ ├── protocol.ts — VIA RAW HID packet building
|
|
41
51
|
│ ├── state-machine.ts — State transitions and LED apply logic
|
|
42
52
|
│ ├── global-sessions.ts — Per-cwd session glow record registry
|
|
43
53
|
│ ├── types.ts — KeyboardState, KeyboardZone, KeyboardConfig
|
|
@@ -49,14 +59,6 @@ keyboard/
|
|
|
49
59
|
|
|
50
60
|
## Installation
|
|
51
61
|
|
|
52
|
-
Install from this monorepo or local path:
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
pi install ~/Developer/FradSer/pi-packages/packages/keyboard
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Published on npm:
|
|
59
|
-
|
|
60
62
|
```bash
|
|
61
63
|
pi install npm:pi-keyboard
|
|
62
64
|
```
|
package/README.zh-CN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-keyboard
|
|
2
2
|
|
|
3
|
-
基于 VIA/QMK 协议的 Pi 键盘 RGB
|
|
3
|
+
基于 VIA/QMK 协议的 Pi 键盘 RGB 状态指示灯插件。通过 `via-rgb` 执行灯光命令,由该程序负责 HID 数据包编码。
|
|
4
4
|
|
|
5
5
|
通过机械键盘的 RGB 灯光,实时提供 AI Coding Agent 运行状态的环境感知:
|
|
6
6
|
|
|
@@ -27,6 +27,13 @@
|
|
|
27
27
|
- **按键背光矩阵** (`matrix`):仅控制键帽背光(QMK 通道 3)。
|
|
28
28
|
- **侧边底灯** (`underglow`):仅控制外壳侧边/底部灯带(QMK 通道 2)。
|
|
29
29
|
|
|
30
|
+
## 共享目录会话注册表
|
|
31
|
+
|
|
32
|
+
每个会话的灯光记录使用 pi-kit 的规范目录身份,并将其哈希为注册表目录名。
|
|
33
|
+
已存在的路径通过 `realpath` 解析,不存在的路径使用绝对路径,因此仅以斜杠和连字符
|
|
34
|
+
区分的项目不会碰撞。读取、清理和正常退出删除记录前都会核验记录中的 `cwd` 属于请求的
|
|
35
|
+
目录;旧目录名不会迁移。
|
|
36
|
+
|
|
30
37
|
---
|
|
31
38
|
|
|
32
39
|
## 安装方式
|
|
@@ -104,6 +104,12 @@ Feature: Pi Keyboard Lighting Indicator
|
|
|
104
104
|
And it no longer contributes to the green unread light
|
|
105
105
|
And a live unread session still keeps its green light
|
|
106
106
|
|
|
107
|
+
Scenario: Keyboard glow records verify canonical directory ownership
|
|
108
|
+
Given a registry file is stored under one directory key with another canonical cwd
|
|
109
|
+
When keyboard cleanup or global state evaluation runs
|
|
110
|
+
Then the foreign record is ignored
|
|
111
|
+
And clean shutdown does not delete the foreign record
|
|
112
|
+
|
|
107
113
|
Scenario: Target lighting zone selection
|
|
108
114
|
Given the user configures the target zone
|
|
109
115
|
When the zone is set to "matrix"
|
|
@@ -128,6 +134,30 @@ Feature: Pi Keyboard Lighting Indicator
|
|
|
128
134
|
When a state transition occurs
|
|
129
135
|
Then the driver fails gracefully without throwing unhandled exceptions or crashing Pi
|
|
130
136
|
|
|
137
|
+
Scenario: Missing via-rgb executable reports a failed hardware update
|
|
138
|
+
Given the configured via-rgb executable does not exist
|
|
139
|
+
When a keyboard state transition is queued
|
|
140
|
+
Then the transition result reports failure
|
|
141
|
+
And the driver does not report a successful HID write
|
|
142
|
+
|
|
143
|
+
Scenario: Non-zero via-rgb exit reports a failed hardware update
|
|
144
|
+
Given the configured via-rgb executable exits with a non-zero status
|
|
145
|
+
When a keyboard state transition is queued
|
|
146
|
+
Then the transition result reports failure
|
|
147
|
+
And its error identifies the failed command
|
|
148
|
+
|
|
149
|
+
Scenario: Hardware updates remain serial and recover after a failed command
|
|
150
|
+
Given two keyboard state transitions are queued
|
|
151
|
+
And the first transition fails
|
|
152
|
+
When the queue drains
|
|
153
|
+
Then the second transition starts only after the first settles
|
|
154
|
+
And the second transition result is reported independently
|
|
155
|
+
|
|
156
|
+
Scenario: Homebrew via-rgb candidate uses the executable path
|
|
157
|
+
Given via-rgb is installed under the Homebrew prefix
|
|
158
|
+
When the driver resolves standard executable candidates
|
|
159
|
+
Then it checks "/opt/homebrew/bin/via-rgb" as one candidate
|
|
160
|
+
|
|
131
161
|
Scenario: /keyboard command allows manual state testing and toggle
|
|
132
162
|
Given the /keyboard command is executed
|
|
133
163
|
When the user toggles lighting to "off"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-keyboard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Dynamic VIA/QMK keyboard RGB lighting indicator reflecting Pi states (idle, thinking, unread, need-approval, error)",
|
|
5
5
|
"author": "Frad LEE <fradser@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,6 @@
|
|
|
38
38
|
"@earendil-works/pi-tui": "*"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@fradser/pi-kit": "0.
|
|
41
|
+
"@fradser/pi-kit": "0.5.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/driver.ts
CHANGED
|
@@ -13,16 +13,14 @@ import {
|
|
|
13
13
|
const execFileAsync = promisify(execFile);
|
|
14
14
|
|
|
15
15
|
let activeBlinkTimer: NodeJS.Timeout | null = null;
|
|
16
|
-
let commandQueue: Promise<
|
|
16
|
+
let commandQueue: Promise<HardwareApplyResult> | undefined;
|
|
17
17
|
|
|
18
18
|
export function resolveCliPath(customPath?: string): string {
|
|
19
|
-
if (customPath
|
|
20
|
-
return customPath;
|
|
21
|
-
}
|
|
19
|
+
if (customPath) return customPath;
|
|
22
20
|
|
|
23
21
|
const standardPaths = [
|
|
24
22
|
join(homedir(), ".local", "bin", "via-rgb"),
|
|
25
|
-
"/opt/homebrew/bin", "via-rgb",
|
|
23
|
+
join("/opt/homebrew/bin", "via-rgb"),
|
|
26
24
|
"/usr/local/bin/via-rgb",
|
|
27
25
|
];
|
|
28
26
|
|
|
@@ -122,16 +120,9 @@ export async function applyKeyboardState(
|
|
|
122
120
|
};
|
|
123
121
|
|
|
124
122
|
// Queue to ensure strictly ordered HID writes
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
},
|
|
129
|
-
async () => {
|
|
130
|
-
await task();
|
|
131
|
-
},
|
|
132
|
-
);
|
|
133
|
-
await commandQueue;
|
|
134
|
-
return { success: true, state, zone: config.zone };
|
|
123
|
+
const queuedTask = (commandQueue ?? Promise.resolve()).then(task, task);
|
|
124
|
+
commandQueue = queuedTask;
|
|
125
|
+
return await queuedTask;
|
|
135
126
|
}
|
|
136
127
|
|
|
137
128
|
function startSoftwareStrobe(
|
package/src/global-sessions.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { getDirectorySessionKey, isSameDirectory } from "@fradser/pi-kit";
|
|
4
5
|
import type { KeyboardState } from "./types";
|
|
5
6
|
|
|
6
7
|
export interface SessionGlowRecord {
|
|
@@ -20,10 +21,8 @@ export function getRegistryDir(): string {
|
|
|
20
21
|
return path.join(os.homedir(), ".pi", "agent", "directory-sessions");
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return `--${normalized.replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`;
|
|
26
|
-
}
|
|
24
|
+
/** Shared directory-session key used by every registry writer and reader. */
|
|
25
|
+
export const getSessionFileKey = getDirectorySessionKey;
|
|
27
26
|
|
|
28
27
|
export function getDirectoryRegistryPath(cwd: string): string {
|
|
29
28
|
return path.join(getRegistryDir(), getSessionFileKey(cwd));
|
|
@@ -78,6 +77,8 @@ export function removeSessionGlowState(cwd: string, sessionId: string): void {
|
|
|
78
77
|
try {
|
|
79
78
|
const filePath = path.join(getDirectoryRegistryPath(cwd), `${sessionId}.json`);
|
|
80
79
|
if (fs.existsSync(filePath)) {
|
|
80
|
+
const record = JSON.parse(fs.readFileSync(filePath, "utf-8")) as { cwd?: unknown };
|
|
81
|
+
if (typeof record.cwd !== "string" || !isSameDirectory(record.cwd, cwd)) return;
|
|
81
82
|
fs.unlinkSync(filePath);
|
|
82
83
|
}
|
|
83
84
|
} catch {
|
|
@@ -119,6 +120,9 @@ export function pruneOrphanedGlowStates(): number {
|
|
|
119
120
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
120
121
|
const record = JSON.parse(content) as SessionGlowRecord;
|
|
121
122
|
if (!record || typeof record !== "object" || typeof record.pid !== "number") continue;
|
|
123
|
+
if (typeof record.cwd !== "string" || dirEntry.name !== getDirectorySessionKey(record.cwd)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
122
126
|
if (!isProcessAlive(record.pid)) {
|
|
123
127
|
try {
|
|
124
128
|
fs.unlinkSync(filePath);
|
|
@@ -189,6 +193,9 @@ export function evaluateGlobalLightingState(
|
|
|
189
193
|
const record = JSON.parse(content) as SessionGlowRecord;
|
|
190
194
|
|
|
191
195
|
if (!record || typeof record !== "object") continue;
|
|
196
|
+
if (typeof record.cwd !== "string" || dirEntry.name !== getDirectorySessionKey(record.cwd)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
192
199
|
|
|
193
200
|
// Prune dead process IDs
|
|
194
201
|
if (!isProcessAlive(record.pid)) {
|
package/src/protocol.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { KeyboardZone } from "./types";
|
|
2
|
-
|
|
3
|
-
export const VIA_RAW_USAGE_PAGE = 0xff60;
|
|
4
|
-
export const VIA_RAW_USAGE = 0x0061;
|
|
5
|
-
|
|
6
|
-
export const CMD_GET_PROTOCOL_VERSION = 0x01;
|
|
7
|
-
export const CMD_CUSTOM_SET_VALUE = 0x07;
|
|
8
|
-
export const CMD_CUSTOM_GET_VALUE = 0x08;
|
|
9
|
-
export const CMD_CUSTOM_SAVE = 0x09;
|
|
10
|
-
|
|
11
|
-
export const CH_RGBLIGHT = 2; // Zone 1: Side strip / Underglow
|
|
12
|
-
export const CH_RGB_MATRIX = 3; // Zone 2: Per-key RGB Matrix
|
|
13
|
-
|
|
14
|
-
export const VAL_BRIGHTNESS = 1;
|
|
15
|
-
export const VAL_EFFECT = 2;
|
|
16
|
-
export const VAL_EFFECT_SPEED = 3;
|
|
17
|
-
export const VAL_COLOR = 4;
|
|
18
|
-
|
|
19
|
-
export function resolveChannels(zone: KeyboardZone): number[] {
|
|
20
|
-
switch (zone) {
|
|
21
|
-
case "underglow":
|
|
22
|
-
return [CH_RGBLIGHT];
|
|
23
|
-
case "matrix":
|
|
24
|
-
return [CH_RGB_MATRIX];
|
|
25
|
-
case "all":
|
|
26
|
-
default:
|
|
27
|
-
return [CH_RGBLIGHT, CH_RGB_MATRIX];
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function buildPacket(payload: number[]): Uint8Array {
|
|
32
|
-
const packet = new Uint8Array(32);
|
|
33
|
-
for (let i = 0; i < payload.length && i < 32; i++) {
|
|
34
|
-
packet[i] = payload[i];
|
|
35
|
-
}
|
|
36
|
-
return packet;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function buildSetBrightnessPacket(channel: number, brightness: number): Uint8Array {
|
|
40
|
-
const val = Math.max(0, Math.min(255, Math.round(brightness)));
|
|
41
|
-
return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_BRIGHTNESS, val]);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function buildSetEffectPacket(channel: number, effect: number): Uint8Array {
|
|
45
|
-
const val = Math.max(0, Math.min(255, Math.round(effect)));
|
|
46
|
-
return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_EFFECT, val]);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function buildSetSpeedPacket(channel: number, speed: number): Uint8Array {
|
|
50
|
-
const val = Math.max(0, Math.min(255, Math.round(speed)));
|
|
51
|
-
return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_EFFECT_SPEED, val]);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function buildSetColorPacket(channel: number, hue: number, sat: number): Uint8Array {
|
|
55
|
-
const h = Math.max(0, Math.min(255, Math.round(hue)));
|
|
56
|
-
const s = Math.max(0, Math.min(255, Math.round(sat)));
|
|
57
|
-
return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_COLOR, h, s]);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function buildSavePacket(channel: number): Uint8Array {
|
|
61
|
-
return buildPacket([CMD_CUSTOM_SAVE, channel]);
|
|
62
|
-
}
|