pi-keyboard 0.1.6 → 0.1.8

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frad LEE
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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
  ```
@@ -118,3 +120,7 @@ Settings are saved in `~/.pi/agent/keyboard.json`:
118
120
  "saveToEeprom": false
119
121
  }
120
122
  ```
123
+
124
+ ## License
125
+
126
+ MIT
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.6",
3
+ "version": "0.1.8",
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.4.2"
41
+ "@fradser/pi-kit": "0.5.1"
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<void> = Promise.resolve();
16
+ let commandQueue: Promise<HardwareApplyResult> | undefined;
17
17
 
18
18
  export function resolveCliPath(customPath?: string): string {
19
- if (customPath && existsSync(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
- commandQueue = commandQueue.then(
126
- async () => {
127
- await task();
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(
@@ -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
- export function getSessionFileKey(cwd: string): string {
24
- const normalized = path.resolve(cwd || process.cwd());
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
- }