Skip to content

Commit 0c5c69f

Browse files
authored
feat: sync keyboard led status (#502)
1 parent 0cee284 commit 0c5c69f

File tree

9 files changed

+235
-74
lines changed

9 files changed

+235
-74
lines changed

internal/usbgadget/hid_keyboard.go

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package usbgadget
22

33
import (
4+
"context"
45
"fmt"
56
"os"
7+
"reflect"
8+
"time"
69
)
710

811
var keyboardConfig = gadgetConfigItem{
@@ -36,6 +39,7 @@ var keyboardReportDesc = []byte{
3639
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
3740
0x95, 0x05, /* REPORT_COUNT (5) */
3841
0x75, 0x01, /* REPORT_SIZE (1) */
42+
3943
0x05, 0x08, /* USAGE_PAGE (LEDs) */
4044
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
4145
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
@@ -54,13 +58,139 @@ var keyboardReportDesc = []byte{
5458
0xc0, /* END_COLLECTION */
5559
}
5660

57-
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
58-
if u.keyboardHidFile == nil {
59-
var err error
60-
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
61-
if err != nil {
62-
return fmt.Errorf("failed to open hidg0: %w", err)
61+
const (
62+
hidReadBufferSize = 8
63+
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf
64+
// https://www.usb.org/sites/default/files/hut1_2.pdf
65+
KeyboardLedMaskNumLock = 1 << 0
66+
KeyboardLedMaskCapsLock = 1 << 1
67+
KeyboardLedMaskScrollLock = 1 << 2
68+
KeyboardLedMaskCompose = 1 << 3
69+
KeyboardLedMaskKana = 1 << 4
70+
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
71+
)
72+
73+
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
74+
// COMPOSE, and KANA events is maintained by the host and NOT the keyboard. If
75+
// using the keyboard descriptor in Appendix B, LED states are set by sending a
76+
// 5-bit absolute report to the keyboard via a Set_Report(Output) request.
77+
type KeyboardState struct {
78+
NumLock bool `json:"num_lock"`
79+
CapsLock bool `json:"caps_lock"`
80+
ScrollLock bool `json:"scroll_lock"`
81+
Compose bool `json:"compose"`
82+
Kana bool `json:"kana"`
83+
}
84+
85+
func getKeyboardState(b byte) KeyboardState {
86+
// should we check if it's the correct usage page?
87+
return KeyboardState{
88+
NumLock: b&KeyboardLedMaskNumLock != 0,
89+
CapsLock: b&KeyboardLedMaskCapsLock != 0,
90+
ScrollLock: b&KeyboardLedMaskScrollLock != 0,
91+
Compose: b&KeyboardLedMaskCompose != 0,
92+
Kana: b&KeyboardLedMaskKana != 0,
93+
}
94+
}
95+
96+
func (u *UsbGadget) updateKeyboardState(b byte) {
97+
u.keyboardStateLock.Lock()
98+
defer u.keyboardStateLock.Unlock()
99+
100+
if b&^ValidKeyboardLedMasks != 0 {
101+
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
102+
return
103+
}
104+
105+
newState := getKeyboardState(b)
106+
if reflect.DeepEqual(u.keyboardState, newState) {
107+
return
108+
}
109+
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
110+
u.keyboardState = newState
111+
112+
if u.onKeyboardStateChange != nil {
113+
(*u.onKeyboardStateChange)(newState)
114+
}
115+
}
116+
117+
func (u *UsbGadget) SetOnKeyboardStateChange(f func(state KeyboardState)) {
118+
u.onKeyboardStateChange = &f
119+
}
120+
121+
func (u *UsbGadget) GetKeyboardState() KeyboardState {
122+
u.keyboardStateLock.Lock()
123+
defer u.keyboardStateLock.Unlock()
124+
125+
return u.keyboardState
126+
}
127+
128+
func (u *UsbGadget) listenKeyboardEvents() {
129+
var path string
130+
if u.keyboardHidFile != nil {
131+
path = u.keyboardHidFile.Name()
132+
}
133+
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
134+
l.Trace().Msg("starting")
135+
136+
go func() {
137+
buf := make([]byte, hidReadBufferSize)
138+
for {
139+
select {
140+
case <-u.keyboardStateCtx.Done():
141+
l.Info().Msg("context done")
142+
return
143+
default:
144+
l.Trace().Msg("reading from keyboard")
145+
if u.keyboardHidFile == nil {
146+
l.Error().Msg("keyboardHidFile is nil")
147+
time.Sleep(time.Second)
148+
continue
149+
}
150+
n, err := u.keyboardHidFile.Read(buf)
151+
if err != nil {
152+
l.Error().Err(err).Msg("failed to read")
153+
continue
154+
}
155+
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
156+
if n != 1 {
157+
l.Trace().Int("n", n).Msg("expected 1 byte, got")
158+
continue
159+
}
160+
u.updateKeyboardState(buf[0])
161+
}
63162
}
163+
}()
164+
}
165+
166+
func (u *UsbGadget) openKeyboardHidFile() error {
167+
if u.keyboardHidFile != nil {
168+
return nil
169+
}
170+
171+
var err error
172+
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
173+
if err != nil {
174+
return fmt.Errorf("failed to open hidg0: %w", err)
175+
}
176+
177+
if u.keyboardStateCancel != nil {
178+
u.keyboardStateCancel()
179+
}
180+
181+
u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
182+
u.listenKeyboardEvents()
183+
184+
return nil
185+
}
186+
187+
func (u *UsbGadget) OpenKeyboardHidFile() error {
188+
return u.openKeyboardHidFile()
189+
}
190+
191+
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
192+
if err := u.openKeyboardHidFile(); err != nil {
193+
return err
64194
}
65195

66196
_, err := u.keyboardHidFile.Write(data)

internal/usbgadget/usbgadget.go

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package usbgadget
44

55
import (
6+
"context"
67
"os"
78
"path"
89
"sync"
@@ -59,6 +60,11 @@ type UsbGadget struct {
5960
relMouseHidFile *os.File
6061
relMouseLock sync.Mutex
6162

63+
keyboardState KeyboardState
64+
keyboardStateLock sync.Mutex
65+
keyboardStateCtx context.Context
66+
keyboardStateCancel context.CancelFunc
67+
6268
enabledDevices Devices
6369

6470
strictMode bool // only intended for testing for now
@@ -70,6 +76,8 @@ type UsbGadget struct {
7076
tx *UsbGadgetTransaction
7177
txLock sync.Mutex
7278

79+
onKeyboardStateChange *func(state KeyboardState)
80+
7381
log *zerolog.Logger
7482
}
7583

@@ -96,20 +104,25 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
96104
config = &Config{isEmpty: true}
97105
}
98106

107+
keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
108+
99109
g := &UsbGadget{
100-
name: name,
101-
kvmGadgetPath: path.Join(gadgetPath, name),
102-
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
103-
configMap: configMap,
104-
customConfig: *config,
105-
configLock: sync.Mutex{},
106-
keyboardLock: sync.Mutex{},
107-
absMouseLock: sync.Mutex{},
108-
relMouseLock: sync.Mutex{},
109-
txLock: sync.Mutex{},
110-
enabledDevices: *enabledDevices,
111-
lastUserInput: time.Now(),
112-
log: logger,
110+
name: name,
111+
kvmGadgetPath: path.Join(gadgetPath, name),
112+
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
113+
configMap: configMap,
114+
customConfig: *config,
115+
configLock: sync.Mutex{},
116+
keyboardLock: sync.Mutex{},
117+
absMouseLock: sync.Mutex{},
118+
relMouseLock: sync.Mutex{},
119+
txLock: sync.Mutex{},
120+
keyboardStateCtx: keyboardCtx,
121+
keyboardStateCancel: keyboardCancel,
122+
keyboardState: KeyboardState{},
123+
enabledDevices: *enabledDevices,
124+
lastUserInput: time.Now(),
125+
log: logger,
113126

114127
strictMode: config.strictMode,
115128

jsonrpc.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,7 @@ var rpcHandlers = map[string]RPCHandler{
10171017
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
10181018
"renewDHCPLease": {Func: rpcRenewDHCPLease},
10191019
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
1020+
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
10201021
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
10211022
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
10221023
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},

ui/src/components/InfoBar.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@ export default function InfoBar() {
3636
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
3737
}, [rpcDataChannel]);
3838

39-
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
40-
const isNumLockActive = useHidStore(state => state.isNumLockActive);
41-
const isScrollLockActive = useHidStore(state => state.isScrollLockActive);
39+
const keyboardLedState = useHidStore(state => state.keyboardLedState);
4240

4341
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
4442

@@ -121,7 +119,7 @@ export default function InfoBar() {
121119
<div
122120
className={cx(
123121
"shrink-0 p-1 px-1.5 text-xs",
124-
isCapsLockActive
122+
keyboardLedState?.caps_lock
125123
? "text-black dark:text-white"
126124
: "text-slate-800/20 dark:text-slate-300/20",
127125
)}
@@ -131,7 +129,7 @@ export default function InfoBar() {
131129
<div
132130
className={cx(
133131
"shrink-0 p-1 px-1.5 text-xs",
134-
isNumLockActive
132+
keyboardLedState?.num_lock
135133
? "text-black dark:text-white"
136134
: "text-slate-800/20 dark:text-slate-300/20",
137135
)}
@@ -141,13 +139,23 @@ export default function InfoBar() {
141139
<div
142140
className={cx(
143141
"shrink-0 p-1 px-1.5 text-xs",
144-
isScrollLockActive
142+
keyboardLedState?.scroll_lock
145143
? "text-black dark:text-white"
146144
: "text-slate-800/20 dark:text-slate-300/20",
147145
)}
148146
>
149147
Scroll Lock
150148
</div>
149+
{keyboardLedState?.compose ? (
150+
<div className="shrink-0 p-1 px-1.5 text-xs">
151+
Compose
152+
</div>
153+
) : null}
154+
{keyboardLedState?.kana ? (
155+
<div className="shrink-0 p-1 px-1.5 text-xs">
156+
Kana
157+
</div>
158+
) : null}
151159
</div>
152160
</div>
153161
</div>

ui/src/components/VirtualKeyboard.tsx

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1+
import { useShallow } from "zustand/react/shallow";
2+
import { ChevronDownIcon } from "@heroicons/react/16/solid";
3+
import { AnimatePresence, motion } from "framer-motion";
14
import { useCallback, useEffect, useRef, useState } from "react";
25
import Keyboard from "react-simple-keyboard";
3-
import { ChevronDownIcon } from "@heroicons/react/16/solid";
4-
import { motion, AnimatePresence } from "framer-motion";
56

67
import Card from "@components/Card";
78
// eslint-disable-next-line import/order
89
import { Button } from "@components/Button";
910

1011
import "react-simple-keyboard/build/css/index.css";
1112

12-
import { useHidStore, useUiStore } from "@/hooks/stores";
13+
import AttachIconRaw from "@/assets/attach-icon.svg";
14+
import DetachIconRaw from "@/assets/detach-icon.svg";
1315
import { cx } from "@/cva.config";
14-
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
16+
import { useHidStore, useUiStore } from "@/hooks/stores";
1517
import useKeyboard from "@/hooks/useKeyboard";
16-
import DetachIconRaw from "@/assets/detach-icon.svg";
17-
import AttachIconRaw from "@/assets/attach-icon.svg";
18+
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
1819

1920
export const DetachIcon = ({ className }: { className?: string }) => {
2021
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
@@ -40,8 +41,8 @@ function KeyboardWrapper() {
4041
const [isDragging, setIsDragging] = useState(false);
4142
const [position, setPosition] = useState({ x: 0, y: 0 });
4243
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
43-
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
44-
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
44+
45+
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
4546

4647
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
4748
if (!keyboardRef.current) return;
@@ -157,17 +158,11 @@ function KeyboardWrapper() {
157158
toggleLayout();
158159

159160
if (isCapsLockActive) {
160-
setIsCapsLockActive(false);
161161
sendKeyboardEvent([keys["CapsLock"]], []);
162162
return;
163163
}
164164
}
165165

166-
// Handle caps lock state change
167-
if (isKeyCaps) {
168-
setIsCapsLockActive(!isCapsLockActive);
169-
}
170-
171166
// Collect new active keys and modifiers
172167
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
173168
const newModifiers =
@@ -183,7 +178,7 @@ function KeyboardWrapper() {
183178

184179
setTimeout(resetKeyboardState, 100);
185180
},
186-
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
181+
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState],
187182
);
188183

189184
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);

0 commit comments

Comments
 (0)