From 6af975ab91011a66b28b3a478633b02d5349087f Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Fri, 15 May 2026 13:26:13 +0900 Subject: [PATCH 01/37] =?UTF-8?q?feat:=20USB=E3=82=AD=E3=83=BC=E3=83=9C?= =?UTF-8?q?=E3=83=BC=E3=83=89=E5=85=A5=E5=8A=9B=E3=82=92BLE=20HID=E3=81=B8?= =?UTF-8?q?=E8=BB=A2=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/config.rpi.yaml | 1 + go.mod | 2 +- internal/bluez/hid.go | 13 +- internal/config/config.go | 19 ++- internal/config/config_test.go | 29 ++++ internal/hidapp/app.go | 23 ++++ internal/hidapp/app_test.go | 47 +++++++ internal/input/forwarder_linux.go | 195 +++++++++++++++++++++++++++ internal/input/forwarder_other.go | 17 +++ internal/input/keyboard.go | 212 ++++++++++++++++++++++++++++++ internal/input/keyboard_test.go | 70 ++++++++++ 11 files changed, 621 insertions(+), 7 deletions(-) create mode 100644 internal/input/forwarder_linux.go create mode 100644 internal/input/forwarder_other.go create mode 100644 internal/input/keyboard.go create mode 100644 internal/input/keyboard_test.go diff --git a/examples/config.rpi.yaml b/examples/config.rpi.yaml index 99c6cd2..81831aa 100644 --- a/examples/config.rpi.yaml +++ b/examples/config.rpi.yaml @@ -10,3 +10,4 @@ hid: appearance: keyboard pairable: true discoverable: true + input_devices: [] diff --git a/go.mod b/go.mod index 6756150..a256d81 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.3 require ( github.com/godbus/dbus/v5 v5.2.2 go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/sys v0.43.0 ) require ( @@ -209,7 +210,6 @@ require ( golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/internal/bluez/hid.go b/internal/bluez/hid.go index 72cfb4a..80184e2 100644 --- a/internal/bluez/hid.go +++ b/internal/bluez/hid.go @@ -105,6 +105,7 @@ type DaemonOptions struct { Pairable bool Discoverable bool TestReports [][]byte + InputReports func(context.Context, func([]byte) error) error OnPeerReady func(Peer) error Log io.Writer } @@ -205,6 +206,10 @@ func (app *HIDApplication) SendReports(reports [][]byte) error { return nil } +func (app *HIDApplication) SendReport(report []byte) error { + return app.SendReports([][]byte{report}) +} + func (app *HIDApplication) SendReportsAfterSubscription(ctx context.Context, reports [][]byte) error { if len(reports) == 0 { return nil @@ -345,7 +350,7 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { }() readyErrors := make(chan error, 1) - if len(options.TestReports) > 0 || options.OnPeerReady != nil { + if len(options.TestReports) > 0 || options.OnPeerReady != nil || options.InputReports != nil { go func() { if err := app.WaitForSubscription(ctx); err != nil { if !errors.Is(err, context.Canceled) { @@ -366,6 +371,12 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { } if err := app.SendReports(options.TestReports); err != nil { readyErrors <- err + return + } + if options.InputReports != nil { + if err := options.InputReports(ctx, app.SendReport); err != nil && !errors.Is(err, context.Canceled) { + readyErrors <- err + } } }() } diff --git a/internal/config/config.go b/internal/config/config.go index ad60c25..297c3be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,11 +53,12 @@ type Behavior struct { } type HIDConfig struct { - Adapter string `yaml:"adapter"` - Name string `yaml:"name"` - Appearance string `yaml:"appearance"` - Pairable *bool `yaml:"pairable,omitempty"` - Discoverable *bool `yaml:"discoverable,omitempty"` + Adapter string `yaml:"adapter"` + Name string `yaml:"name"` + Appearance string `yaml:"appearance"` + Pairable *bool `yaml:"pairable,omitempty"` + Discoverable *bool `yaml:"discoverable,omitempty"` + InputDevices []string `yaml:"input_devices,omitempty"` } func DefaultLocalConfigPath() (string, error) { @@ -221,6 +222,14 @@ func (hid HIDConfig) Validate() error { if hid.Appearance != HIDAppearanceKeyboard { return errors.New("hid.appearance must be keyboard") } + for index, device := range hid.InputDevices { + if strings.TrimSpace(device) == "" { + return fmt.Errorf("hid.input_devices[%d] is required", index) + } + if hasControl(device) { + return fmt.Errorf("hid.input_devices[%d] must not contain control characters", index) + } + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b07e262..9b918d4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -129,6 +129,35 @@ hid: } } +func TestRaspberryPi側設定はHID入力デバイスを読める(t *testing.T) { + path := writeConfig(t, ` +hid: + input_devices: + - /dev/input/by-id/usb-Test_Keyboard-event-kbd +`) + + cfg, err := config.LoadRPI(path) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + want := "/dev/input/by-id/usb-Test_Keyboard-event-kbd" + if len(cfg.HID.InputDevices) != 1 || cfg.HID.InputDevices[0] != want { + t.Fatalf("input_devices = %#v, want [%q]", cfg.HID.InputDevices, want) + } +} + +func TestRaspberryPi側設定は空のHID入力デバイスを拒否する(t *testing.T) { + path := writeConfig(t, ` +hid: + input_devices: + - "" +`) + + if _, err := config.LoadRPI(path); err == nil { + t.Fatal("err = nil, want error") + } +} + func writeConfig(t *testing.T, content string) string { t.Helper() diff --git a/internal/hidapp/app.go b/internal/hidapp/app.go index 580219a..49b3fd1 100644 --- a/internal/hidapp/app.go +++ b/internal/hidapp/app.go @@ -10,14 +10,20 @@ import ( "github.com/RarkHopper/RpiKeyboardSwitcher/internal/bluez" "github.com/RarkHopper/RpiKeyboardSwitcher/internal/config" "github.com/RarkHopper/RpiKeyboardSwitcher/internal/hidreport" + "github.com/RarkHopper/RpiKeyboardSwitcher/internal/input" ) type HIDDaemon interface { Run(ctx context.Context, options bluez.DaemonOptions) error } +type InputForwarder interface { + Run(ctx context.Context, send func([]byte) error) error +} + type App struct { Daemon HIDDaemon + Input InputForwarder Context context.Context Stdout io.Writer Stderr io.Writer @@ -152,6 +158,11 @@ func (app App) inspect(configPath string) int { _, _ = fmt.Fprintf(app.stdout(), "appearance: %s (0x%04X)\n", cfg.HID.Appearance, bluez.KeyboardAppearance) _, _ = fmt.Fprintf(app.stdout(), "pairable: %t\n", cfg.HID.PairableEnabled()) _, _ = fmt.Fprintf(app.stdout(), "discoverable: %t\n", cfg.HID.DiscoverableEnabled()) + if len(cfg.HID.InputDevices) == 0 { + _, _ = fmt.Fprintf(app.stdout(), "input_devices: %s (default)\n", input.DefaultKeyboardGlob) + } else { + _, _ = fmt.Fprintf(app.stdout(), "input_devices: %s\n", strings.Join(cfg.HID.InputDevices, ", ")) + } _, _ = fmt.Fprintf(app.stdout(), "gatt_root: %s\n", bluez.AppPath) _, _ = fmt.Fprintf(app.stdout(), "advertisement: %s\n", bluez.AdvertisementPath) _, _ = fmt.Fprintf(app.stdout(), "service_uuid: %s\n", bluez.HIDServiceUUID) @@ -167,6 +178,7 @@ func (app App) daemonOptions(configPath string, cfg config.RPIConfig, reports [] Pairable: cfg.HID.PairableEnabled(), Discoverable: cfg.HID.DiscoverableEnabled(), TestReports: reports, + InputReports: app.inputForwarder(cfg).Run, OnPeerReady: func(peer bluez.Peer) error { return app.cachePeer(configPath, peer) }, @@ -251,6 +263,17 @@ func testReports(text string) ([][]byte, error) { return hidreport.Bytes(reports), nil } +func (app App) inputForwarder(cfg config.RPIConfig) InputForwarder { + if app.Input != nil { + return app.Input + } + + return input.Forwarder{ + Paths: cfg.HID.InputDevices, + Log: app.stderr(), + } +} + func (app App) daemonRunner() HIDDaemon { if app.Daemon == nil { return bluez.DBusDaemon{} diff --git a/internal/hidapp/app_test.go b/internal/hidapp/app_test.go index ab44c9f..af16354 100644 --- a/internal/hidapp/app_test.go +++ b/internal/hidapp/app_test.go @@ -25,6 +25,20 @@ func (daemon *fakeDaemon) Run(_ context.Context, options bluez.DaemonOptions) er return daemon.err } +type fakeInput struct { + reports [][]byte +} + +func (input fakeInput) Run(_ context.Context, send func([]byte) error) error { + for _, report := range input.reports { + if err := send(report); err != nil { + return err + } + } + + return nil +} + func TestHIDCLIはdaemonで設定からBLEkeyboardを起動する(t *testing.T) { configPath := writeConfig(t) daemon := &fakeDaemon{} @@ -62,6 +76,36 @@ func TestHIDCLIはdaemonで設定からBLEkeyboardを起動する(t *testing.T) if daemon.options.OnPeerReady == nil { t.Fatal("OnPeerReady is nil") } + if daemon.options.InputReports == nil { + t.Fatal("InputReports is nil") + } +} + +func TestHIDCLIはUSBキーボード入力をBLEreportへ渡す(t *testing.T) { + configPath := writeConfig(t) + daemon := &fakeDaemon{} + wantReport := []byte{0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00} + + code := hidapp.App{ + Daemon: daemon, + Input: fakeInput{reports: [][]byte{wantReport}}, + Stderr: &bytes.Buffer{}, + }.Run([]string{"--config", configPath, "daemon"}) + + if code != 0 { + t.Fatalf("終了コード = %d, want 0", code) + } + var gotReports [][]byte + err := daemon.options.InputReports(context.Background(), func(report []byte) error { + gotReports = append(gotReports, append([]byte(nil), report...)) + return nil + }) + if err != nil { + t.Fatalf("InputReports err = %v, want nil", err) + } + if !reflect.DeepEqual(gotReports, [][]byte{wantReport}) { + t.Fatalf("reports = %#v, want %#v", gotReports, [][]byte{wantReport}) + } } func TestHIDCLIはinspectで実際に使うBLE設定を出す(t *testing.T) { @@ -80,6 +124,7 @@ func TestHIDCLIはinspectで実際に使うBLE設定を出す(t *testing.T) { "adapter: hci1\n", "name: Desk Bridge\n", "appearance: keyboard (0x03C1)\n", + "input_devices: /dev/input/by-id/usb-Test_Keyboard-event-kbd\n", "service_uuid: " + bluez.HIDServiceUUID + "\n", } { if !bytes.Contains(stdout.Bytes(), []byte(want)) { @@ -132,6 +177,8 @@ hid: appearance: keyboard pairable: true discoverable: true + input_devices: + - /dev/input/by-id/usb-Test_Keyboard-event-kbd `) if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatal(err) diff --git a/internal/input/forwarder_linux.go b/internal/input/forwarder_linux.go new file mode 100644 index 0000000..76cda95 --- /dev/null +++ b/internal/input/forwarder_linux.go @@ -0,0 +1,195 @@ +//go:build linux + +package input + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "path/filepath" + "sort" + "strings" + "unsafe" + + "golang.org/x/sys/unix" +) + +const ( + eventSync = 0x00 + eventKey = 0x01 + + syncDropped = 0x03 + + // EVIOCGRAB prevents forwarded key events from also reaching the Raspberry Pi console. + evIOGrab = 0x40044590 +) + +type Forwarder struct { + Paths []string + Log io.Writer +} + +type inputEvent struct { + Time unix.Timeval + Type uint16 + Code uint16 + Value int32 +} + +func (forwarder Forwarder) Run(ctx context.Context, send func([]byte) error) error { + paths, err := inputDevicePaths(forwarder.Paths) + if err != nil { + return err + } + if len(paths) == 0 { + logf(forwarder.Log, "No keyboard input devices found at %s; set hid.input_devices to forward USB keyboard input\n", DefaultKeyboardGlob) + return nil + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + errs := make(chan error, len(paths)) + for _, path := range paths { + path := path + go func() { + errs <- readDevice(ctx, path, send) + }() + } + + for range paths { + select { + case <-ctx.Done(): + return nil + case err := <-errs: + if err != nil { + return err + } + } + } + + return nil +} + +func inputDevicePaths(patterns []string) ([]string, error) { + if len(patterns) == 0 { + patterns = []string{DefaultKeyboardGlob} + } + + seen := map[string]bool{} + paths := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + if strings.ContainsAny(pattern, "*?[") { + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("expand input device path %q: %w", pattern, err) + } + sort.Strings(matches) + for _, match := range matches { + if !seen[match] { + seen[match] = true + paths = append(paths, match) + } + } + continue + } + + if !seen[pattern] { + seen[pattern] = true + paths = append(paths, pattern) + } + } + + sort.Strings(paths) + return paths, nil +} + +func readDevice(ctx context.Context, path string, send func([]byte) error) error { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) + if err != nil { + return fmt.Errorf("open input device %s: %w", path, err) + } + defer func() { + _ = unix.Close(fd) + }() + if err := unix.IoctlSetPointerInt(fd, evIOGrab, 1); err != nil { + return fmt.Errorf("grab input device %s: %w", path, err) + } + defer func() { + _ = unix.IoctlSetPointerInt(fd, evIOGrab, 0) + }() + + var event inputEvent + eventSize := int(unsafe.Sizeof(event)) + typeOffset := uintptr(unsafe.Offsetof(event.Type)) + codeOffset := uintptr(unsafe.Offsetof(event.Code)) + valueOffset := uintptr(unsafe.Offsetof(event.Value)) + + buffer := make([]byte, eventSize*32) + state := KeyboardState{} + + for { + select { + case <-ctx.Done(): + return nil + default: + } + + ready, err := unix.Poll([]unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}, 250) + if err == unix.EINTR { + continue + } + if err != nil { + return fmt.Errorf("poll input device %s: %w", path, err) + } + if ready == 0 { + continue + } + + n, err := unix.Read(fd, buffer) + if err == unix.EINTR || err == unix.EAGAIN { + continue + } + if err != nil { + return fmt.Errorf("read input device %s: %w", path, err) + } + if n == 0 { + return fmt.Errorf("input device %s closed", path) + } + + for offset := 0; offset+eventSize <= n; offset += eventSize { + record := buffer[offset : offset+eventSize] + eventType := binary.NativeEndian.Uint16(record[typeOffset:]) + eventCode := binary.NativeEndian.Uint16(record[codeOffset:]) + eventValue := int32(binary.NativeEndian.Uint32(record[valueOffset:])) + + switch eventType { + case eventKey: + report, changed := state.Apply(eventCode, eventValue) + if changed { + if err := send(report.Bytes()); err != nil { + return err + } + } + case eventSync: + if eventCode == syncDropped { + report, changed := state.Reset() + if changed { + if err := send(report.Bytes()); err != nil { + return err + } + } + } + } + } + } +} + +func logf(writer io.Writer, format string, args ...any) { + if writer == nil { + return + } + + _, _ = fmt.Fprintf(writer, format, args...) +} diff --git a/internal/input/forwarder_other.go b/internal/input/forwarder_other.go new file mode 100644 index 0000000..80f7d6e --- /dev/null +++ b/internal/input/forwarder_other.go @@ -0,0 +1,17 @@ +//go:build !linux + +package input + +import ( + "context" + "io" +) + +type Forwarder struct { + Paths []string + Log io.Writer +} + +func (forwarder Forwarder) Run(_ context.Context, _ func([]byte) error) error { + return nil +} diff --git a/internal/input/keyboard.go b/internal/input/keyboard.go new file mode 100644 index 0000000..440762a --- /dev/null +++ b/internal/input/keyboard.go @@ -0,0 +1,212 @@ +package input + +import ( + "sort" + + "github.com/RarkHopper/RpiKeyboardSwitcher/internal/hidreport" +) + +const DefaultKeyboardGlob = "/dev/input/by-id/*-event-kbd" + +type KeyboardState struct { + modifiers byte + keys map[byte]bool +} + +func (state *KeyboardState) Apply(code uint16, value int32) (hidreport.Report, bool) { + if value == 2 { + return hidreport.Report{}, false + } + if value != 0 && value != 1 { + return hidreport.Report{}, false + } + + if mask, ok := modifierForCode(code); ok { + before := state.modifiers + if value == 1 { + state.modifiers |= mask + } else { + state.modifiers &^= mask + } + + return state.Report(), before != state.modifiers + } + + key, ok := keyForCode(code) + if !ok { + return hidreport.Report{}, false + } + if state.keys == nil { + state.keys = map[byte]bool{} + } + + before := state.keys[key] + if value == 1 { + state.keys[key] = true + } else { + delete(state.keys, key) + } + if before == (value == 1) { + return hidreport.Report{}, false + } + + return state.Report(), true +} + +func (state *KeyboardState) Reset() (hidreport.Report, bool) { + if state.modifiers == 0 && len(state.keys) == 0 { + return hidreport.Report{}, false + } + + state.modifiers = 0 + clear(state.keys) + + return state.Report(), true +} + +func (state KeyboardState) Report() hidreport.Report { + report := hidreport.Report{state.modifiers} + keys := make([]int, 0, len(state.keys)) + for code := range state.keys { + keys = append(keys, int(code)) + } + sort.Ints(keys) + + index := 2 + for _, code := range keys { + if index >= len(report) { + break + } + report[index] = byte(code) + index++ + } + + return report +} + +func modifierForCode(code uint16) (byte, bool) { + switch code { + case 29: + return 0x01, true + case 42: + return 0x02, true + case 56: + return 0x04, true + case 125: + return 0x08, true + case 97: + return 0x10, true + case 54: + return 0x20, true + case 100: + return 0x40, true + case 126: + return 0x80, true + default: + return 0x00, false + } +} + +func keyForCode(code uint16) (byte, bool) { + keys := map[uint16]byte{ + 1: 0x29, + 2: 0x1e, + 3: 0x1f, + 4: 0x20, + 5: 0x21, + 6: 0x22, + 7: 0x23, + 8: 0x24, + 9: 0x25, + 10: 0x26, + 11: 0x27, + 12: 0x2d, + 13: 0x2e, + 14: 0x2a, + 15: 0x2b, + 16: 0x14, + 17: 0x1a, + 18: 0x08, + 19: 0x15, + 20: 0x17, + 21: 0x1c, + 22: 0x18, + 23: 0x0c, + 24: 0x12, + 25: 0x13, + 26: 0x2f, + 27: 0x30, + 28: 0x28, + 30: 0x04, + 31: 0x16, + 32: 0x07, + 33: 0x09, + 34: 0x0a, + 35: 0x0b, + 36: 0x0d, + 37: 0x0e, + 38: 0x0f, + 39: 0x33, + 40: 0x34, + 41: 0x35, + 43: 0x31, + 44: 0x1d, + 45: 0x1b, + 46: 0x06, + 47: 0x19, + 48: 0x05, + 49: 0x11, + 50: 0x10, + 51: 0x36, + 52: 0x37, + 53: 0x38, + 55: 0x55, + 57: 0x2c, + 58: 0x39, + 59: 0x3a, + 60: 0x3b, + 61: 0x3c, + 62: 0x3d, + 63: 0x3e, + 64: 0x3f, + 65: 0x40, + 66: 0x41, + 67: 0x42, + 68: 0x43, + 69: 0x53, + 70: 0x47, + 71: 0x5f, + 72: 0x60, + 73: 0x61, + 74: 0x56, + 75: 0x5c, + 76: 0x5d, + 77: 0x5e, + 78: 0x57, + 79: 0x59, + 80: 0x5a, + 81: 0x5b, + 82: 0x62, + 83: 0x63, + 86: 0x64, + 87: 0x44, + 88: 0x45, + 96: 0x58, + 98: 0x54, + 99: 0x46, + 102: 0x4a, + 103: 0x52, + 104: 0x4b, + 105: 0x50, + 106: 0x4f, + 107: 0x4d, + 108: 0x51, + 109: 0x4e, + 110: 0x49, + 111: 0x4c, + 119: 0x48, + } + + key, ok := keys[code] + return key, ok +} diff --git a/internal/input/keyboard_test.go b/internal/input/keyboard_test.go new file mode 100644 index 0000000..24e2195 --- /dev/null +++ b/internal/input/keyboard_test.go @@ -0,0 +1,70 @@ +package input_test + +import ( + "reflect" + "testing" + + "github.com/RarkHopper/RpiKeyboardSwitcher/internal/hidreport" + "github.com/RarkHopper/RpiKeyboardSwitcher/internal/input" +) + +func TestLinuxキー入力をHIDレポートへ変換する(t *testing.T) { + var state input.KeyboardState + + report, ok := state.Apply(30, 1) + if !ok { + t.Fatal("a press changed = false, want true") + } + if want := (hidreport.Report{0x00, 0x00, 0x04}); !reflect.DeepEqual(report, want) { + t.Fatalf("a press report = %#v, want %#v", report, want) + } + + report, ok = state.Apply(30, 0) + if !ok { + t.Fatal("a release changed = false, want true") + } + if want := (hidreport.Report{}); !reflect.DeepEqual(report, want) { + t.Fatalf("a release report = %#v, want %#v", report, want) + } +} + +func Test修飾キーと通常キーを同じレポートへ入れる(t *testing.T) { + var state input.KeyboardState + + if _, ok := state.Apply(42, 1); !ok { + t.Fatal("left shift press changed = false, want true") + } + report, ok := state.Apply(30, 1) + if !ok { + t.Fatal("a press changed = false, want true") + } + if want := (hidreport.Report{0x02, 0x00, 0x04}); !reflect.DeepEqual(report, want) { + t.Fatalf("shift+a report = %#v, want %#v", report, want) + } +} + +func Testキーリピートは無視する(t *testing.T) { + var state input.KeyboardState + + if _, ok := state.Apply(30, 1); !ok { + t.Fatal("a press changed = false, want true") + } + if _, ok := state.Apply(30, 2); ok { + t.Fatal("repeat changed = true, want false") + } +} + +func Test同期落ちでは解放レポートを返す(t *testing.T) { + var state input.KeyboardState + + if _, ok := state.Apply(30, 1); !ok { + t.Fatal("a press changed = false, want true") + } + report, ok := state.Reset() + if !ok { + t.Fatal("reset changed = false, want true") + } + if want := (hidreport.Report{}); !reflect.DeepEqual(report, want) { + t.Fatalf("reset report = %#v, want %#v", report, want) + } +} From 911324debedf2be5e84f0409bbc93ec2a1202930 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Fri, 15 May 2026 13:26:34 +0900 Subject: [PATCH 02/37] =?UTF-8?q?docs:=20USB=E5=85=A5=E5=8A=9B=E4=B8=AD?= =?UTF-8?q?=E7=B6=99=E3=81=AE=E3=82=BB=E3=83=83=E3=83=88=E3=82=A2=E3=83=83?= =?UTF-8?q?=E3=83=97=E6=89=8B=E9=A0=86=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ja.md | 65 ++++++++++++++++++++++++++++++++++++++++++---------- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 106 insertions(+), 24 deletions(-) diff --git a/README.ja.md b/README.ja.md index 275d03e..0b107c2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -4,7 +4,7 @@ RpiKeyboardSwitcher は、Raspberry Pi を Bluetooth HID キーボードの橋渡しとして使うための Go 製プロトタイプです。Raspberry Pi が BLE キーボードとして広告し、接続できたPCを BlueZ から読み取り、設定ファイルへ保存します。以後は PC 側の短い `kbd` コマンドから SSH 経由で Raspberry Pi に切替を指示します。 -USB キーボード入力の中継はまだ未実装です。現在の `kbd-hid` デーモンは BLE HID キーボードとして広告し、ホストが HID 通知を有効にした後に固定のテスト文字を送れます。 +`kbd-hid` デーモンは BLE HID キーボードとして広告し、ホストが HID 通知を有効にした後に Raspberry Pi の Linux 入力デバイスから読んだキー入力を送ります。初回確認用に固定のテスト文字も送れます。 ## コマンド @@ -19,7 +19,7 @@ USB キーボード入力の中継はまだ未実装です。現在の `kbd-hid` | Raspberry Pi | `kbd-rpi`, `kbd-hid` | `/etc/kbd-switch/config.yaml` | BLE キーボードを広告し、疎通した Bluetooth 接続先を `targets` に保存し、切替を行います。 | | 切替コマンドを打つPC | `kbd` | `~/.config/kbd-switch/config.yaml` | Raspberry Pi への SSH 接続方法だけを持ちます。Bluetooth MAC アドレスは持ちません。 | | キーボード入力を受けるPC | 入力を受けるだけなら不要 | OS の Bluetooth 設定 | `Rpi Keyboard Switcher` とペアリングし、普通の BLE キーボードとして入力を受けます。このPCから切替も行うなら `kbd` も入れます。 | -| 有線キーボード | なし | なし | USB で Raspberry Pi に接続します。入力中継は次の段階です。 | +| 有線キーボード | なし | なし | USB で Raspberry Pi に接続します。`kbd-hid` が Linux 入力デバイスからキー入力を読みます。 | ## 処理の流れ @@ -31,6 +31,7 @@ sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml --test-text a -> ホストが HID 入力通知を有効にする -> kbd-hid が BlueZ Device1 の Address と Alias/Name を読む -> /etc/kbd-switch/config.yaml に targets.<生成名> を保存 + -> Raspberry Pi の USB キーボード入力を BLE HID report として送る ``` その後、PC 側から切り替えます。 @@ -48,6 +49,13 @@ kbd switch laptop ここでは、開発PCでビルドしてから Raspberry Pi と切替コマンドを打つPCへ配置する流れで進めます。例では Raspberry Pi の SSH 接続先を `pi@rpi-kbd.local` とします。 +### 0. 配線 + +- USB キーボードを Raspberry Pi の USB ポートに挿します。 +- Raspberry Pi は電源を入れ、Bluetooth を有効にします。 +- キーボード入力を受けるPCでは、Bluetooth 設定画面とテキストエディタなどの入力欄を開けるようにしておきます。 +- 切替コマンドを打つPCから Raspberry Pi へ SSH できるようにしておきます。このPCは、キーボード入力を受けるPCと同じでも別でも構いません。 + ### 1. ビルド 開発PCで3つのバイナリを作ります。 @@ -56,7 +64,7 @@ kbd switch laptop make build ``` -既定では、PC 側の `kbd` は開発PCと同じ OS/CPU 向け、Raspberry Pi 側の `kbd-rpi` と `kbd-hid` は 64-bit Raspberry Pi OS 向けに `linux/arm64` で作ります。 +既定では、PC 側の `kbd` は開発PCと同じ OS/CPU 向け、Raspberry Pi 側の `kbd-rpi` と `kbd-hid` は 64-bit Raspberry Pi OS 向けに `linux/arm64` で作ります。開発PCとは別のPCで `kbd` を使う場合は、そのPC上で `make build` を実行するか、`LOCAL_GOOS` と `LOCAL_GOARCH` をそのPCに合わせて指定します。 32-bit Raspberry Pi OS 向けに作る場合は `RPI_GOARCH=arm` を渡します。 @@ -80,7 +88,9 @@ Raspberry Pi では BlueZ と SSH を使います。Bluetooth アダプタ名は ```sh command -v bluetoothctl +sudo systemctl enable --now bluetooth.service systemctl is-active bluetooth.service +bluetoothctl list ls /sys/class/bluetooth ``` @@ -89,10 +99,11 @@ ls /sys/class/bluetooth ```sh sudo apt-get update sudo apt-get install -y bluez -sudo systemctl enable --now bluetooth.service ``` -`ls /sys/class/bluetooth` で `hci0` が出ない場合は、Raspberry Pi 側で Bluetooth が無効になっていないかを先に確認します。 +`bluetoothctl list` または `ls /sys/class/bluetooth` で `hci0` が出ない場合は、Raspberry Pi 側で Bluetooth が無効になっていないかを先に確認します。 + +`kbd-hid` は入力デバイスを読み取り、実行中は対象キーボードを Raspberry Pi の通常入力から外します。手動確認では `sudo kbd-hid ...` で実行し、systemd unit も root で起動します。 バイナリを `/usr/local/bin` に置きます。 @@ -123,6 +134,7 @@ hid: appearance: keyboard pairable: true discoverable: true + input_devices: [] ``` 接続先が保存されると、次のような項目が追加されます。 @@ -146,6 +158,7 @@ targets: - `hid.appearance`: HID の appearance。現在は `keyboard` のみ対応しています。 - `hid.pairable`: true または未指定なら、ペアリング要求を受け付けます。 - `hid.discoverable`: true または未指定なら、アダプタを discoverable にします。 +- `hid.input_devices`: 読み取る Linux 入力デバイス。空または未指定なら `/dev/input/by-id/*-event-kbd` を使います。特定のキーボードだけ読む場合は `/dev/input/by-id/...-event-kbd` を指定します。 接続先名に使える文字は英数字、`_`、`-`、`.` だけです。未知の YAML フィールドはエラーにします。 @@ -157,6 +170,20 @@ Bluetooth に触る前に、`kbd-hid` が読む設定を確認します。 kbd-hid inspect --config /etc/kbd-switch/config.yaml ``` +USB キーボードを Raspberry Pi に挿し、入力デバイス名を確認します。 + +```sh +ls -l /dev/input/by-id/*-event-kbd +``` + +複数のキーボードがあり、読む対象を固定したい場合は `hid.input_devices` に書きます。 + +```yaml +hid: + input_devices: + - /dev/input/by-id/usb-Example_Keyboard-event-kbd +``` + ### 4. 接続先を覚えさせる 初回は systemd ではなく手動で起動し、対象PCとのペアリングとテスト入力を確認します。 @@ -166,9 +193,9 @@ sudo systemctl stop kbd-hid.service 2>/dev/null || true sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml --test-text a ``` -このコマンドは起動したまま待ちます。対象PCのOS Bluetooth設定を開き、`Rpi Keyboard Switcher` とペアリングします。ホストが HID 通知を有効にすると、`kbd-hid` が BlueZ の接続済みデバイスを読み取り、`targets` に保存します。同じ Bluetooth MAC アドレスがすでに保存済みなら、既存の接続先名と表示名を保ちます。 +このコマンドは起動したまま待ちます。対象PCでテキストエディタなどの入力欄を開いてから、OS Bluetooth設定で `Rpi Keyboard Switcher` とペアリングします。ホストが HID 通知を有効にすると、`kbd-hid` が BlueZ の接続済みデバイスを読み取り、`targets` に保存します。同じ Bluetooth MAC アドレスがすでに保存済みなら、既存の接続先名と表示名を保ちます。 -対象PCで `a` が入力され、Raspberry Pi 側の `/etc/kbd-switch/config.yaml` に `targets` が増えたら、`Ctrl-C` で止めます。接続先名や表示名は、この時点で編集できます。Bluetooth MAC アドレスは通常そのままにします。 +対象PCで `a` が入力され、Raspberry Pi 側の `/etc/kbd-switch/config.yaml` に `targets` が増えたら、USB キーボードの入力も対象PCへ届くことを確認します。確認後は `Ctrl-C` で止めます。接続先名や表示名は、この時点で編集できます。Bluetooth MAC アドレスは通常そのままにします。 ### 5. systemd で常駐させる @@ -181,9 +208,11 @@ sudo systemctl enable --now kbd-hid.service sudo journalctl -u kbd-hid.service -f ``` +ログを確認できたら `Ctrl-C` で `journalctl` だけを止めます。`kbd-hid.service` は動き続けます。 + ### 6. 切替コマンドを打つPCへ配置 -Raspberry Pi のシェルから `exit` で戻り、切替コマンドを打つPCへ `kbd` を置きます。 +Raspberry Pi のシェルから `exit` で戻ります。開発PCを切替コマンドを打つPCとして使う場合は、`kbd` を PATH の通った場所に置きます。 ```sh mkdir -p ~/.local/bin @@ -244,17 +273,31 @@ Raspberry Pi 側の state の既定パスは `/run/kbd-switch/state.json` です ## 補完 +切替コマンドを打つPCで `kbd` の補完を読み込みます。 + zsh: ```sh eval "$(kbd completion zsh)" -eval "$(kbd-rpi completion zsh)" ``` bash: ```sh eval "$(kbd completion bash)" +``` + +Raspberry Pi 側で `kbd-rpi` を直接使う場合は、Raspberry Pi のシェルで `kbd-rpi` の補完を読み込みます。 + +zsh: + +```sh +eval "$(kbd-rpi completion zsh)" +``` + +bash: + +```sh eval "$(kbd-rpi completion bash)" ``` @@ -262,9 +305,7 @@ eval "$(kbd-rpi completion bash)" ## セキュリティ -Raspberry Pi はキー入力の経路上に置かれます。信頼できない Raspberry Pi を使うと、キー入力の読み取り、保存、変更、注入が可能になります。業務PCや管理対象PCでは、所有者または管理者の許可なしに使わないでください。 - -入力ログ保存は初期実装に含めず、標準では無効のままにします。 +Raspberry Pi はキー入力の経路上に置かれます。信頼できない Raspberry Pi を使うと、キー入力の読み取り、変更、注入が可能になります。業務PCや管理対象PCでは、所有者または管理者の許可なしに使わないでください。 ## 開発 diff --git a/README.md b/README.md index ea78659..3fb336e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ RpiKeyboardSwitcher is a Go prototype for using a Raspberry Pi as a Bluetooth HID keyboard bridge. The Raspberry Pi advertises itself as a BLE keyboard, learns paired target PCs from BlueZ, caches them in its config, and later switches between those cached targets from a short `kbd` command over SSH. -USB keyboard input forwarding is not implemented yet. The current `kbd-hid` daemon can advertise a BLE HID keyboard and send fixed test text after the host subscribes to HID notifications. +The `kbd-hid` daemon advertises itself as a BLE HID keyboard and forwards key input read from Raspberry Pi Linux input devices after the host subscribes to HID notifications. It can also send fixed test text for the first pairing check. ## Commands @@ -20,7 +20,7 @@ USB keyboard input forwarding is not implemented yet. The current `kbd-hid` daem | Raspberry Pi | `kbd-rpi`, `kbd-hid` | `/etc/kbd-switch/config.yaml` | Advertises the BLE keyboard, caches confirmed Bluetooth targets, and switches targets. | | PC used to run switch commands | `kbd` | `~/.config/kbd-switch/config.yaml` | Knows how to SSH to the Raspberry Pi. It does not store Bluetooth MAC addresses. | | PC used as a keyboard target | nothing required for input | OS Bluetooth settings | Pairs with `Rpi Keyboard Switcher` as a normal BLE keyboard. Install `kbd` here only if this PC also runs switch commands. | -| Wired keyboard | none | none | Plugs into the Raspberry Pi over USB. Input forwarding is a later step. | +| Wired keyboard | none | none | Plugs into the Raspberry Pi over USB. `kbd-hid` reads key input from Linux input devices. | ## Flow @@ -32,6 +32,7 @@ sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml --test-text a -> the host subscribes to HID input notifications -> kbd-hid reads BlueZ Device1 Address and Alias/Name -> kbd-hid saves targets. in /etc/kbd-switch/config.yaml + -> kbd-hid forwards Raspberry Pi USB keyboard input as BLE HID reports ``` Then switch to that target from a PC: @@ -49,6 +50,13 @@ The generated target key and display name are meant to be edited by the user. Th These steps build on a development PC, then place files on the Raspberry Pi and on the PC that runs switch commands. The examples use `pi@rpi-kbd.local` as the Raspberry Pi SSH target. +### 0. Wiring + +- Plug the USB keyboard into a Raspberry Pi USB port. +- Power on the Raspberry Pi and enable Bluetooth. +- On the PC that receives keyboard input, have the Bluetooth settings and a text editor or another text field ready. +- Make sure the PC that runs switch commands can SSH to the Raspberry Pi. This PC may be the same as the input target PC or a separate one. + ### 1. Build Build the three binaries on the development PC. @@ -57,7 +65,7 @@ Build the three binaries on the development PC. make build ``` -By default, `kbd` is built for the development PC OS/CPU, while `kbd-rpi` and `kbd-hid` are built for 64-bit Raspberry Pi OS as `linux/arm64`. +By default, `kbd` is built for the development PC OS/CPU, while `kbd-rpi` and `kbd-hid` are built for 64-bit Raspberry Pi OS as `linux/arm64`. If another PC will run `kbd`, run `make build` on that PC or set `LOCAL_GOOS` and `LOCAL_GOARCH` for that PC. For 32-bit Raspberry Pi OS, pass `RPI_GOARCH=arm`. @@ -80,7 +88,9 @@ The Raspberry Pi needs BlueZ and SSH. The Bluetooth adapter is usually named `hc ```sh command -v bluetoothctl +sudo systemctl enable --now bluetooth.service systemctl is-active bluetooth.service +bluetoothctl list ls /sys/class/bluetooth ``` @@ -89,10 +99,11 @@ If `bluetoothctl` is missing, install BlueZ on the Raspberry Pi. ```sh sudo apt-get update sudo apt-get install -y bluez -sudo systemctl enable --now bluetooth.service ``` -If `ls /sys/class/bluetooth` does not show `hci0`, check the Raspberry Pi Bluetooth settings before continuing. +If `bluetoothctl list` or `ls /sys/class/bluetooth` does not show `hci0`, check the Raspberry Pi Bluetooth settings before continuing. + +`kbd-hid` reads input devices and grabs the target keyboard while it is running so those key events do not also reach the Raspberry Pi console. The manual check uses `sudo kbd-hid ...`, and the systemd unit also runs as root. Install the binaries under `/usr/local/bin`. @@ -123,6 +134,7 @@ hid: appearance: keyboard pairable: true discoverable: true + input_devices: [] ``` After a target is learned, the file will contain entries like this: @@ -146,6 +158,7 @@ Fields: - `hid.appearance`: HID appearance. Currently only `keyboard` is supported. - `hid.pairable`: when true or omitted, allow incoming pairing requests. - `hid.discoverable`: when true or omitted, make the adapter discoverable. +- `hid.input_devices`: Linux input devices to read. When empty or omitted, `/dev/input/by-id/*-event-kbd` is used. To read only a specific keyboard, set one or more `/dev/input/by-id/...-event-kbd` paths. Target names may contain only letters, digits, `_`, `-`, and `.`. Unknown YAML fields are rejected. @@ -157,6 +170,20 @@ Check the settings read by `kbd-hid` before touching Bluetooth: kbd-hid inspect --config /etc/kbd-switch/config.yaml ``` +Plug the USB keyboard into the Raspberry Pi and check the input device name: + +```sh +ls -l /dev/input/by-id/*-event-kbd +``` + +If more than one keyboard exists and you want to pin the source, set `hid.input_devices`. + +```yaml +hid: + input_devices: + - /dev/input/by-id/usb-Example_Keyboard-event-kbd +``` + ### 4. Learn A Target For the first check, start `kbd-hid` by hand instead of systemd and verify pairing plus test input from a target PC. @@ -166,9 +193,9 @@ sudo systemctl stop kbd-hid.service 2>/dev/null || true sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml --test-text a ``` -This command keeps running. On the target PC, open the OS Bluetooth settings and pair with `Rpi Keyboard Switcher`. Once the host subscribes to HID notifications, `kbd-hid` reads the connected BlueZ device and adds it to `targets`. If the same Bluetooth MAC address is already present, the existing target key and name are kept. +This command keeps running. On the target PC, open a text editor or another text field, then open the OS Bluetooth settings and pair with `Rpi Keyboard Switcher`. Once the host subscribes to HID notifications, `kbd-hid` reads the connected BlueZ device and adds it to `targets`. If the same Bluetooth MAC address is already present, the existing target key and name are kept. -When the target PC receives `a` and `/etc/kbd-switch/config.yaml` gains a `targets` entry, stop the command with `Ctrl-C`. You can edit the generated target key and display name at this point. Leave the Bluetooth MAC address unchanged unless you know it is wrong. +When the target PC receives `a` and `/etc/kbd-switch/config.yaml` gains a `targets` entry, also confirm that USB keyboard input reaches the target PC. Then stop the command with `Ctrl-C`. You can edit the generated target key and display name at this point. Leave the Bluetooth MAC address unchanged unless you know it is wrong. ### 5. Run kbd-hid Under systemd @@ -181,9 +208,11 @@ sudo systemctl enable --now kbd-hid.service sudo journalctl -u kbd-hid.service -f ``` +After checking the logs, press `Ctrl-C` to stop only `journalctl`. `kbd-hid.service` keeps running. + ### 6. Install The PC Command -Exit the Raspberry Pi shell, then install `kbd` on the PC that runs switch commands. +Exit the Raspberry Pi shell. If the development PC is also the PC that runs switch commands, install `kbd` somewhere on PATH. ```sh mkdir -p ~/.local/bin @@ -244,17 +273,31 @@ The default Raspberry Pi state path is `/run/kbd-switch/state.json`. Set `KBD_RP ## Tab Completion +Load `kbd` completion on the PC that runs switch commands. + For zsh: ```sh eval "$(kbd completion zsh)" -eval "$(kbd-rpi completion zsh)" ``` For bash: ```sh eval "$(kbd completion bash)" +``` + +If you use `kbd-rpi` directly on the Raspberry Pi, load `kbd-rpi` completion in the Raspberry Pi shell. + +For zsh: + +```sh +eval "$(kbd-rpi completion zsh)" +``` + +For bash: + +```sh eval "$(kbd-rpi completion bash)" ``` @@ -262,9 +305,7 @@ Completion candidates are read on each completion request. `kbd` asks the Raspbe ## Security -The Raspberry Pi sits in the key input path. A compromised or untrusted Raspberry Pi could read, store, modify, or inject key input. Do not use this with a work PC or managed PC without approval from the owner or administrator. - -Input logging is not part of the initial implementation and should stay off by default. +The Raspberry Pi sits in the key input path. A compromised or untrusted Raspberry Pi could read, modify, or inject key input. Do not use this with a work PC or managed PC without approval from the owner or administrator. ## Development From a751d80c77aa73f3f828e029d53513de1a8e7b13 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Fri, 15 May 2026 19:24:30 +0900 Subject: [PATCH 03/37] =?UTF-8?q?feat:=20HID=E5=85=A5=E5=8A=9B=E3=82=92hid?= =?UTF-8?q?raw=20report=E8=BB=A2=E9=80=81=E3=81=B8=E5=88=87=E3=82=8A?= =?UTF-8?q?=E6=9B=BF=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bluez/hid.go | 154 +++++++++++++++++----- internal/bluez/hid_test.go | 51 +++++++ internal/config/config.go | 24 ++-- internal/config/config_test.go | 25 ++-- internal/hidapp/app.go | 88 +++++++------ internal/hidapp/app_test.go | 73 +++++++--- internal/hidreport/report.go | 144 -------------------- internal/hidreport/report_test.go | 54 -------- internal/input/descriptor.go | 113 ++++++++++++++++ internal/input/descriptor_test.go | 83 ++++++++++++ internal/input/forwarder_linux.go | 178 +++++++------------------ internal/input/forwarder_other.go | 10 +- internal/input/keyboard.go | 212 ------------------------------ internal/input/keyboard_test.go | 70 ---------- internal/rpiapp/app_test.go | 4 + 15 files changed, 550 insertions(+), 733 deletions(-) delete mode 100644 internal/hidreport/report.go delete mode 100644 internal/hidreport/report_test.go create mode 100644 internal/input/descriptor.go create mode 100644 internal/input/descriptor_test.go delete mode 100644 internal/input/keyboard.go delete mode 100644 internal/input/keyboard_test.go diff --git a/internal/bluez/hid.go b/internal/bluez/hid.go index 80184e2..38b4885 100644 --- a/internal/bluez/hid.go +++ b/internal/bluez/hid.go @@ -61,6 +61,9 @@ type HIDApplication struct { service *Service characteristics map[dbus.ObjectPath]*Characteristic descriptors map[dbus.ObjectPath]*Descriptor + inputReportIDs []byte + inputReportPath map[byte]dbus.ObjectPath + outputReportIDs []byte emitter emitter subscribed chan struct{} subscribeOnce sync.Once @@ -99,25 +102,54 @@ type HIDAdvertisement struct { } type DaemonOptions struct { - Adapter string - Name string - Appearance uint16 - Pairable bool - Discoverable bool - TestReports [][]byte - InputReports func(context.Context, func([]byte) error) error - OnPeerReady func(Peer) error - Log io.Writer + Adapter string + Name string + Appearance uint16 + Pairable bool + Discoverable bool + ReportMap []byte + InputReportIDs []byte + OutputReportIDs []byte + InputReports func(context.Context, func(InputReport) error) error + OnPeerReady func(Peer) error + Log io.Writer } type DBusDaemon struct{} +type HIDApplicationOptions struct { + ReportMap []byte + InputReportIDs []byte + OutputReportIDs []byte +} + +type InputReport struct { + ID byte + Data []byte +} + type Peer struct { Name string BluetoothMAC string } -func NewHIDApplication() *HIDApplication { +func NewHIDApplication(options ...HIDApplicationOptions) *HIDApplication { + settings := HIDApplicationOptions{ + ReportMap: defaultReportMap(), + InputReportIDs: []byte{0x00}, + } + if len(options) > 0 { + if len(options[0].ReportMap) > 0 { + settings.ReportMap = append([]byte(nil), options[0].ReportMap...) + } + if len(options[0].InputReportIDs) > 0 { + settings.InputReportIDs = uniqueReportIDs(options[0].InputReportIDs) + } + if len(options[0].OutputReportIDs) > 0 { + settings.OutputReportIDs = uniqueReportIDs(options[0].OutputReportIDs) + } + } + app := &HIDApplication{ service: &Service{ path: ServicePath, @@ -126,17 +158,29 @@ func NewHIDApplication() *HIDApplication { }, characteristics: make(map[dbus.ObjectPath]*Characteristic), descriptors: make(map[dbus.ObjectPath]*Descriptor), + inputReportIDs: append([]byte(nil), settings.InputReportIDs...), + inputReportPath: make(map[byte]dbus.ObjectPath, len(settings.InputReportIDs)), + outputReportIDs: append([]byte(nil), settings.OutputReportIDs...), subscribed: make(chan struct{}), } app.addCharacteristic(HIDInfoPath, HIDInformationUUID, []string{"read"}, []byte{0x11, 0x01, 0x00, 0x02}, false, false, false) - app.addCharacteristic(ReportMapPath, ReportMapUUID, []string{"read"}, reportMap(), false, false, false) + app.addCharacteristic(ReportMapPath, ReportMapUUID, []string{"read"}, settings.ReportMap, false, false, false) app.addCharacteristic(ControlPointPath, HIDControlPointUUID, []string{"write-without-response"}, nil, false, true, false) app.addCharacteristic(ProtocolModePath, ProtocolModeUUID, []string{"read", "write-without-response"}, []byte{0x01}, false, true, true) - app.addCharacteristic(ReportPath, ReportUUID, []string{"read", "notify"}, make([]byte, 8), true, false, false) + for index, reportID := range settings.InputReportIDs { + path := inputReportPath(index) + app.inputReportPath[reportID] = path + app.addCharacteristic(path, ReportUUID, []string{"read", "notify"}, nil, true, false, false) + app.addDescriptor(path+"/desc0", ReportReferenceUUID, path, []string{"read"}, []byte{reportID, 0x01}) + } + for index, reportID := range settings.OutputReportIDs { + path := outputReportPath(index) + app.addCharacteristic(path, ReportUUID, []string{"read", "write", "write-without-response"}, nil, false, true, false) + app.addDescriptor(path+"/desc0", ReportReferenceUUID, path, []string{"read"}, []byte{reportID, 0x02}) + } app.addCharacteristic(BootInputPath, BootKeyboardInputReportUUID, []string{"read", "notify"}, make([]byte, 8), true, false, false) app.addCharacteristic(BootOutputPath, BootKeyboardOutputReportUUID, []string{"read", "write", "write-without-response"}, []byte{0x00}, false, true, false) - app.addDescriptor(ReportPath+"/desc0", ReportReferenceUUID, ReportPath, []string{"read"}, []byte{0x00, 0x01}) return app } @@ -195,10 +239,7 @@ func (app *HIDApplication) SendReports(reports [][]byte) error { defer app.mu.Unlock() for _, report := range reports { - if len(report) != 8 { - return fmt.Errorf("HID keyboard report must be 8 bytes: got %d", len(report)) - } - if err := app.notifyInputLocked(report); err != nil { + if err := app.notifyInputLocked(InputReport{ID: app.inputReportIDs[0], Data: report}); err != nil { return err } } @@ -210,6 +251,13 @@ func (app *HIDApplication) SendReport(report []byte) error { return app.SendReports([][]byte{report}) } +func (app *HIDApplication) SendInputReport(report InputReport) error { + app.mu.Lock() + defer app.mu.Unlock() + + return app.notifyInputLocked(report) +} + func (app *HIDApplication) SendReportsAfterSubscription(ctx context.Context, reports [][]byte) error { if len(reports) == 0 { return nil @@ -299,7 +347,11 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { return err } - app := NewHIDApplication() + app := NewHIDApplication(HIDApplicationOptions{ + ReportMap: options.ReportMap, + InputReportIDs: options.InputReportIDs, + OutputReportIDs: options.OutputReportIDs, + }) advertisement := NewHIDAdvertisement(options.Name, options.Appearance) agent := NewAgent(options.Log) @@ -350,7 +402,7 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { }() readyErrors := make(chan error, 1) - if len(options.TestReports) > 0 || options.OnPeerReady != nil || options.InputReports != nil { + if options.OnPeerReady != nil || options.InputReports != nil { go func() { if err := app.WaitForSubscription(ctx); err != nil { if !errors.Is(err, context.Canceled) { @@ -369,12 +421,8 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { return } } - if err := app.SendReports(options.TestReports); err != nil { - readyErrors <- err - return - } if options.InputReports != nil { - if err := options.InputReports(ctx, app.SendReport); err != nil && !errors.Is(err, context.Canceled) { + if err := options.InputReports(ctx, app.SendInputReport); err != nil && !errors.Is(err, context.Canceled) { readyErrors <- err } } @@ -483,23 +531,61 @@ func (app *HIDApplication) serviceProperties(interfaceName string) (map[string]d return app.service.properties(), true } -func (app *HIDApplication) notifyInputLocked(report []byte) error { - preferredPath := ReportPath - fallbackPath := BootInputPath - if protocolMode := app.characteristics[ProtocolModePath]; protocolMode != nil && len(protocolMode.value) > 0 && protocolMode.value[0] == 0x00 { - preferredPath = BootInputPath - fallbackPath = ReportPath +func (app *HIDApplication) notifyInputLocked(report InputReport) error { + preferredPath, ok := app.inputReportPath[report.ID] + if !ok { + return nil + } + fallbackPath := dbus.ObjectPath("") + if report.ID == 0x00 && len(report.Data) == 8 { + fallbackPath = BootInputPath + } + if fallbackPath != "" { + protocolMode := app.characteristics[ProtocolModePath] + if protocolMode != nil && len(protocolMode.value) > 0 && protocolMode.value[0] == 0x00 { + preferredPath = BootInputPath + fallbackPath = app.inputReportPath[report.ID] + } } if app.isNotifyingLocked(preferredPath) { - return app.notifyLocked(preferredPath, report) + return app.notifyLocked(preferredPath, report.Data) } - if app.isNotifyingLocked(fallbackPath) { - return app.notifyLocked(fallbackPath, report) + if fallbackPath != "" && app.isNotifyingLocked(fallbackPath) { + return app.notifyLocked(fallbackPath, report.Data) } return nil } +func inputReportPath(index int) dbus.ObjectPath { + if index == 0 { + return ReportPath + } + + return dbus.ObjectPath(fmt.Sprintf("%s/report%d", ServicePath, index)) +} + +func outputReportPath(index int) dbus.ObjectPath { + return dbus.ObjectPath(fmt.Sprintf("%s/output%d", ServicePath, index)) +} + +func uniqueReportIDs(ids []byte) []byte { + seen := map[byte]bool{} + out := make([]byte, 0, len(ids)) + for _, id := range ids { + if seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + if len(out) == 0 { + return []byte{0x00} + } + + return out +} + func (app *HIDApplication) isNotifyingLocked(path dbus.ObjectPath) bool { characteristic := app.characteristics[path] @@ -660,7 +746,7 @@ func readWithOffset(value []byte, options map[string]dbus.Variant) ([]byte, erro return append([]byte(nil), value[offset:]...), nil } -func reportMap() []byte { +func defaultReportMap() []byte { return []byte{ 0x05, 0x01, 0x09, 0x06, diff --git a/internal/bluez/hid_test.go b/internal/bluez/hid_test.go index 4cbb4a7..ec0e1d2 100644 --- a/internal/bluez/hid_test.go +++ b/internal/bluez/hid_test.go @@ -112,6 +112,57 @@ func Test通知開始後に押下reportと解放reportを順に送る(t *testing } } +func TestReportMapとreportIDを差し替えられる(t *testing.T) { + reportMap := []byte{0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x85, 0x02, 0x81, 0x02, 0x85, 0x03, 0x91, 0x02, 0xc0} + app := NewHIDApplication(HIDApplicationOptions{ + ReportMap: reportMap, + InputReportIDs: []byte{0x02}, + OutputReportIDs: []byte{0x03}, + }) + objects := app.ManagedObjects() + + reportMapCharacteristic := objects[ReportMapPath][GATTCharacteristicInterface] + if got := reportMapCharacteristic["Value"].Value(); !reflect.DeepEqual(got, reportMap) { + t.Fatalf("ReportMap Value = %#v, want %#v", got, reportMap) + } + reportReference := objects[ReportPath+"/desc0"][GATTDescriptorInterface] + if got := reportReference["Value"].Value(); !reflect.DeepEqual(got, []byte{0x02, 0x01}) { + t.Fatalf("ReportReference Value = %#v, want [2 1]", got) + } + outputReportReference := objects[outputReportPath(0)+"/desc0"][GATTDescriptorInterface] + if got := outputReportReference["Value"].Value(); !reflect.DeepEqual(got, []byte{0x03, 0x02}) { + t.Fatalf("Output ReportReference Value = %#v, want [3 2]", got) + } +} + +func Test可変長reportを通知する(t *testing.T) { + app := NewHIDApplication(HIDApplicationOptions{ + ReportMap: []byte{0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x85, 0x02, 0x81, 0x02, 0xc0}, + InputReportIDs: []byte{0x02}, + }) + emitter := &fakeEmitter{} + app.SetEmitter(emitter) + + if err := app.characteristics[ReportPath].StartNotify(); err != nil { + t.Fatalf("StartNotify err = %v, want nil", err) + } + report := []byte{0x11, 0x22, 0x33} + if err := app.SendInputReport(InputReport{ID: 0x02, Data: report}); err != nil { + t.Fatalf("SendInputReport err = %v, want nil", err) + } + + if len(emitter.signals) != 1 { + t.Fatalf("signals = %#v, want 1 signal", emitter.signals) + } + changed, ok := emitter.signals[0].values[1].(map[string]dbus.Variant) + if !ok { + t.Fatalf("changed properties = %#v", emitter.signals[0].values[1]) + } + if got := changed["Value"].Value(); !reflect.DeepEqual(got, report) { + t.Fatalf("Value = %#v, want %#v", got, report) + } +} + func TestBootProtocolではBootInputへだけreportを送る(t *testing.T) { app := NewHIDApplication() emitter := &fakeEmitter{} diff --git a/internal/config/config.go b/internal/config/config.go index 297c3be..b64836f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,12 +53,12 @@ type Behavior struct { } type HIDConfig struct { - Adapter string `yaml:"adapter"` - Name string `yaml:"name"` - Appearance string `yaml:"appearance"` - Pairable *bool `yaml:"pairable,omitempty"` - Discoverable *bool `yaml:"discoverable,omitempty"` - InputDevices []string `yaml:"input_devices,omitempty"` + Adapter string `yaml:"adapter"` + Name string `yaml:"name"` + Appearance string `yaml:"appearance"` + Pairable *bool `yaml:"pairable,omitempty"` + Discoverable *bool `yaml:"discoverable,omitempty"` + HIDRawDevice string `yaml:"hidraw_device"` } func DefaultLocalConfigPath() (string, error) { @@ -222,13 +222,11 @@ func (hid HIDConfig) Validate() error { if hid.Appearance != HIDAppearanceKeyboard { return errors.New("hid.appearance must be keyboard") } - for index, device := range hid.InputDevices { - if strings.TrimSpace(device) == "" { - return fmt.Errorf("hid.input_devices[%d] is required", index) - } - if hasControl(device) { - return fmt.Errorf("hid.input_devices[%d] must not contain control characters", index) - } + if strings.TrimSpace(hid.HIDRawDevice) == "" { + return errors.New("hid.hidraw_device is required") + } + if hasControl(hid.HIDRawDevice) { + return errors.New("hid.hidraw_device must not contain control characters") } return nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9b918d4..bb66603 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -70,6 +70,8 @@ targets: switch: name: Laptop bluetooth_mac: AA:BB:CC:DD:EE:01 +hid: + hidraw_device: /dev/hidraw0 `) if _, err := config.LoadRPI(path); err != nil { @@ -83,6 +85,8 @@ targets: laptop: name: Laptop bluetooth_mac: aa:bb:cc:dd:ee:02 +hid: + hidraw_device: /dev/hidraw0 `) if _, err := config.LoadRPI(path); err == nil { @@ -91,7 +95,10 @@ targets: } func TestRaspberryPi側設定はHID設定の既定値を補う(t *testing.T) { - path := writeConfig(t, `{}`) + path := writeConfig(t, ` +hid: + hidraw_device: /dev/hidraw0 +`) cfg, err := config.LoadRPI(path) if err != nil { @@ -122,6 +129,7 @@ targets: bluetooth_mac: AA:BB:CC:DD:EE:02 hid: appearance: mouse + hidraw_device: /dev/hidraw0 `) if _, err := config.LoadRPI(path); err == nil { @@ -129,28 +137,25 @@ hid: } } -func TestRaspberryPi側設定はHID入力デバイスを読める(t *testing.T) { +func TestRaspberryPi側設定はHIDrawデバイスを読める(t *testing.T) { path := writeConfig(t, ` hid: - input_devices: - - /dev/input/by-id/usb-Test_Keyboard-event-kbd + hidraw_device: /dev/hidraw0 `) cfg, err := config.LoadRPI(path) if err != nil { t.Fatalf("err = %v, want nil", err) } - want := "/dev/input/by-id/usb-Test_Keyboard-event-kbd" - if len(cfg.HID.InputDevices) != 1 || cfg.HID.InputDevices[0] != want { - t.Fatalf("input_devices = %#v, want [%q]", cfg.HID.InputDevices, want) + if cfg.HID.HIDRawDevice != "/dev/hidraw0" { + t.Fatalf("hidraw_device = %q, want /dev/hidraw0", cfg.HID.HIDRawDevice) } } -func TestRaspberryPi側設定は空のHID入力デバイスを拒否する(t *testing.T) { +func TestRaspberryPi側設定は空のHIDrawデバイスを拒否する(t *testing.T) { path := writeConfig(t, ` hid: - input_devices: - - "" + hidraw_device: "" `) if _, err := config.LoadRPI(path); err == nil { diff --git a/internal/hidapp/app.go b/internal/hidapp/app.go index 49b3fd1..7ddeb33 100644 --- a/internal/hidapp/app.go +++ b/internal/hidapp/app.go @@ -9,7 +9,6 @@ import ( "github.com/RarkHopper/RpiKeyboardSwitcher/internal/bluez" "github.com/RarkHopper/RpiKeyboardSwitcher/internal/config" - "github.com/RarkHopper/RpiKeyboardSwitcher/internal/hidreport" "github.com/RarkHopper/RpiKeyboardSwitcher/internal/input" ) @@ -18,7 +17,8 @@ type HIDDaemon interface { } type InputForwarder interface { - Run(ctx context.Context, send func([]byte) error) error + Descriptor() (input.Descriptor, error) + Run(ctx context.Context, send func(input.Report) error) error } type App struct { @@ -45,11 +45,11 @@ func (app App) Run(args []string) int { switch options.command { case "daemon": if len(options.operands) != 0 { - _, _ = fmt.Fprintln(app.stderr(), "usage: kbd-hid [--config path] daemon [--test-text text]") + _, _ = fmt.Fprintln(app.stderr(), "usage: kbd-hid [--config path] daemon") return 2 } - return app.daemon(path, options.testText) + return app.daemon(path) case "inspect": if len(options.operands) != 0 { _, _ = fmt.Fprintln(app.stderr(), "usage: kbd-hid [--config path] inspect") @@ -65,7 +65,6 @@ func (app App) Run(args []string) int { type cliOptions struct { configPath string - testText string command string operands []string } @@ -84,15 +83,6 @@ func parseArgs(args []string) (cliOptions, error) { index = next case strings.HasPrefix(arg, "--config="): options.configPath = strings.TrimPrefix(arg, "--config=") - case arg == "--test-text": - value, next, err := requireFlagValue(args, index, "--test-text") - if err != nil { - return cliOptions{}, err - } - options.testText = value - index = next - case strings.HasPrefix(arg, "--test-text="): - options.testText = strings.TrimPrefix(arg, "--test-text=") case strings.HasPrefix(arg, "-"): return cliOptions{}, fmt.Errorf("unknown flag: %s", arg) case options.command == "": @@ -125,19 +115,20 @@ func resolveConfigPath(path string) string { return config.DefaultRPIConfigPath } -func (app App) daemon(configPath string, testText string) int { +func (app App) daemon(configPath string) int { cfg, err := config.LoadRPI(configPath) if err != nil { _, _ = fmt.Fprintln(app.stderr(), err) return 2 } - reports, err := testReports(testText) + forwarder := app.inputForwarder(cfg) + descriptor, err := forwarder.Descriptor() if err != nil { _, _ = fmt.Fprintln(app.stderr(), err) - return 2 + return 1 } - options := app.daemonOptions(configPath, cfg, reports) + options := app.daemonOptions(configPath, cfg, descriptor, forwarder) if err := app.daemonRunner().Run(app.context(), options); err != nil { _, _ = fmt.Fprintln(app.stderr(), err) return 1 @@ -158,10 +149,12 @@ func (app App) inspect(configPath string) int { _, _ = fmt.Fprintf(app.stdout(), "appearance: %s (0x%04X)\n", cfg.HID.Appearance, bluez.KeyboardAppearance) _, _ = fmt.Fprintf(app.stdout(), "pairable: %t\n", cfg.HID.PairableEnabled()) _, _ = fmt.Fprintf(app.stdout(), "discoverable: %t\n", cfg.HID.DiscoverableEnabled()) - if len(cfg.HID.InputDevices) == 0 { - _, _ = fmt.Fprintf(app.stdout(), "input_devices: %s (default)\n", input.DefaultKeyboardGlob) + _, _ = fmt.Fprintf(app.stdout(), "hidraw_device: %s\n", cfg.HID.HIDRawDevice) + if descriptor, err := app.inputForwarder(cfg).Descriptor(); err == nil { + _, _ = fmt.Fprintf(app.stdout(), "report_map_bytes: %d\n", len(descriptor.ReportMap)) + _, _ = fmt.Fprintf(app.stdout(), "input_report_ids: %s\n", reportIDsString(descriptor.InputReportIDs)) } else { - _, _ = fmt.Fprintf(app.stdout(), "input_devices: %s\n", strings.Join(cfg.HID.InputDevices, ", ")) + _, _ = fmt.Fprintf(app.stdout(), "report_map_error: %v\n", err) } _, _ = fmt.Fprintf(app.stdout(), "gatt_root: %s\n", bluez.AppPath) _, _ = fmt.Fprintf(app.stdout(), "advertisement: %s\n", bluez.AdvertisementPath) @@ -170,15 +163,24 @@ func (app App) inspect(configPath string) int { return 0 } -func (app App) daemonOptions(configPath string, cfg config.RPIConfig, reports [][]byte) bluez.DaemonOptions { +func (app App) daemonOptions(configPath string, cfg config.RPIConfig, descriptor input.Descriptor, forwarder InputForwarder) bluez.DaemonOptions { return bluez.DaemonOptions{ - Adapter: cfg.HID.Adapter, - Name: cfg.HID.Name, - Appearance: bluez.KeyboardAppearance, - Pairable: cfg.HID.PairableEnabled(), - Discoverable: cfg.HID.DiscoverableEnabled(), - TestReports: reports, - InputReports: app.inputForwarder(cfg).Run, + Adapter: cfg.HID.Adapter, + Name: cfg.HID.Name, + Appearance: bluez.KeyboardAppearance, + Pairable: cfg.HID.PairableEnabled(), + Discoverable: cfg.HID.DiscoverableEnabled(), + ReportMap: descriptor.ReportMap, + InputReportIDs: descriptor.InputReportIDs, + OutputReportIDs: descriptor.OutputReportIDs, + InputReports: func(ctx context.Context, send func(bluez.InputReport) error) error { + return forwarder.Run(ctx, func(report input.Report) error { + return send(bluez.InputReport{ + ID: report.ID, + Data: report.Data, + }) + }) + }, OnPeerReady: func(peer bluez.Peer) error { return app.cachePeer(configPath, peer) }, @@ -250,28 +252,28 @@ func targetKey(name string) string { return key } -func testReports(text string) ([][]byte, error) { - if text == "" { - return nil, nil +func (app App) inputForwarder(cfg config.RPIConfig) InputForwarder { + if app.Input != nil { + return app.Input } - reports, err := hidreport.ReportsForText(text) - if err != nil { - return nil, err + return input.Forwarder{ + Device: cfg.HID.HIDRawDevice, + Log: app.stderr(), } - - return hidreport.Bytes(reports), nil } -func (app App) inputForwarder(cfg config.RPIConfig) InputForwarder { - if app.Input != nil { - return app.Input +func reportIDsString(ids []byte) string { + if len(ids) == 0 { + return "" } - return input.Forwarder{ - Paths: cfg.HID.InputDevices, - Log: app.stderr(), + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, fmt.Sprintf("0x%02X", id)) } + + return strings.Join(parts, ", ") } func (app App) daemonRunner() HIDDaemon { diff --git a/internal/hidapp/app_test.go b/internal/hidapp/app_test.go index af16354..51fe3a8 100644 --- a/internal/hidapp/app_test.go +++ b/internal/hidapp/app_test.go @@ -10,6 +10,7 @@ import ( "github.com/RarkHopper/RpiKeyboardSwitcher/internal/bluez" "github.com/RarkHopper/RpiKeyboardSwitcher/internal/config" "github.com/RarkHopper/RpiKeyboardSwitcher/internal/hidapp" + "github.com/RarkHopper/RpiKeyboardSwitcher/internal/input" ) type fakeDaemon struct { @@ -26,11 +27,16 @@ func (daemon *fakeDaemon) Run(_ context.Context, options bluez.DaemonOptions) er } type fakeInput struct { - reports [][]byte + descriptor input.Descriptor + reports []input.Report } -func (input fakeInput) Run(_ context.Context, send func([]byte) error) error { - for _, report := range input.reports { +func (fake fakeInput) Descriptor() (input.Descriptor, error) { + return fake.descriptor, nil +} + +func (fake fakeInput) Run(_ context.Context, send func(input.Report) error) error { + for _, report := range fake.reports { if err := send(report); err != nil { return err } @@ -45,8 +51,9 @@ func TestHIDCLIはdaemonで設定からBLEkeyboardを起動する(t *testing.T) code := hidapp.App{ Daemon: daemon, + Input: fakeInput{descriptor: testDescriptor()}, Stderr: &bytes.Buffer{}, - }.Run([]string{"daemon", "--config", configPath, "--test-text", "a"}) + }.Run([]string{"daemon", "--config", configPath}) if code != 0 { t.Fatalf("終了コード = %d, want 0", code) @@ -66,12 +73,14 @@ func TestHIDCLIはdaemonで設定からBLEkeyboardを起動する(t *testing.T) if !daemon.options.Discoverable { t.Fatal("discoverable = false, want true") } - wantReports := [][]byte{ - {0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00}, - {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + if !reflect.DeepEqual(daemon.options.ReportMap, testReportMap) { + t.Fatalf("ReportMap = %#v, want %#v", daemon.options.ReportMap, testReportMap) } - if !reflect.DeepEqual(daemon.options.TestReports, wantReports) { - t.Fatalf("reports = %#v, want %#v", daemon.options.TestReports, wantReports) + if !reflect.DeepEqual(daemon.options.InputReportIDs, []byte{0x02}) { + t.Fatalf("InputReportIDs = %#v, want [2]", daemon.options.InputReportIDs) + } + if !reflect.DeepEqual(daemon.options.OutputReportIDs, []byte{0x03}) { + t.Fatalf("OutputReportIDs = %#v, want [3]", daemon.options.OutputReportIDs) } if daemon.options.OnPeerReady == nil { t.Fatal("OnPeerReady is nil") @@ -84,27 +93,30 @@ func TestHIDCLIはdaemonで設定からBLEkeyboardを起動する(t *testing.T) func TestHIDCLIはUSBキーボード入力をBLEreportへ渡す(t *testing.T) { configPath := writeConfig(t) daemon := &fakeDaemon{} - wantReport := []byte{0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00} + wantReport := bluez.InputReport{ID: 0x02, Data: []byte{0x00, 0x00, 0x04}} code := hidapp.App{ Daemon: daemon, - Input: fakeInput{reports: [][]byte{wantReport}}, + Input: fakeInput{ + descriptor: testDescriptor(), + reports: []input.Report{{ID: wantReport.ID, Data: wantReport.Data}}, + }, Stderr: &bytes.Buffer{}, }.Run([]string{"--config", configPath, "daemon"}) if code != 0 { t.Fatalf("終了コード = %d, want 0", code) } - var gotReports [][]byte - err := daemon.options.InputReports(context.Background(), func(report []byte) error { - gotReports = append(gotReports, append([]byte(nil), report...)) + var gotReports []bluez.InputReport + err := daemon.options.InputReports(context.Background(), func(report bluez.InputReport) error { + gotReports = append(gotReports, bluez.InputReport{ID: report.ID, Data: append([]byte(nil), report.Data...)}) return nil }) if err != nil { t.Fatalf("InputReports err = %v, want nil", err) } - if !reflect.DeepEqual(gotReports, [][]byte{wantReport}) { - t.Fatalf("reports = %#v, want %#v", gotReports, [][]byte{wantReport}) + if !reflect.DeepEqual(gotReports, []bluez.InputReport{wantReport}) { + t.Fatalf("reports = %#v, want %#v", gotReports, []bluez.InputReport{wantReport}) } } @@ -113,6 +125,7 @@ func TestHIDCLIはinspectで実際に使うBLE設定を出す(t *testing.T) { stdout := &bytes.Buffer{} code := hidapp.App{ + Input: fakeInput{descriptor: testDescriptor()}, Stdout: stdout, Stderr: &bytes.Buffer{}, }.Run([]string{"--config", configPath, "inspect"}) @@ -124,7 +137,9 @@ func TestHIDCLIはinspectで実際に使うBLE設定を出す(t *testing.T) { "adapter: hci1\n", "name: Desk Bridge\n", "appearance: keyboard (0x03C1)\n", - "input_devices: /dev/input/by-id/usb-Test_Keyboard-event-kbd\n", + "hidraw_device: /dev/hidraw0\n", + "report_map_bytes: 15\n", + "input_report_ids: 0x02\n", "service_uuid: " + bluez.HIDServiceUUID + "\n", } { if !bytes.Contains(stdout.Bytes(), []byte(want)) { @@ -139,6 +154,7 @@ func TestHIDCLIはBluetooth疎通後にtargetを設定へ保存する(t *testing code := hidapp.App{ Daemon: daemon, + Input: fakeInput{descriptor: testDescriptor()}, Stderr: &bytes.Buffer{}, }.Run([]string{"--config", configPath, "daemon"}) @@ -177,8 +193,7 @@ hid: appearance: keyboard pairable: true discoverable: true - input_devices: - - /dev/input/by-id/usb-Test_Keyboard-event-kbd + hidraw_device: /dev/hidraw0 `) if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatal(err) @@ -186,3 +201,23 @@ hid: return path } + +var testReportMap = []byte{ + 0x05, 0x01, + 0x09, 0x06, + 0xa1, 0x01, + 0x85, 0x02, + 0x81, 0x02, + 0x85, 0x03, + 0x91, 0x02, + 0xc0, +} + +func testDescriptor() input.Descriptor { + return input.Descriptor{ + ReportMap: testReportMap, + InputReportIDs: []byte{0x02}, + OutputReportIDs: []byte{0x03}, + UsesReportID: true, + } +} diff --git a/internal/hidreport/report.go b/internal/hidreport/report.go deleted file mode 100644 index 4b38227..0000000 --- a/internal/hidreport/report.go +++ /dev/null @@ -1,144 +0,0 @@ -package hidreport - -import "fmt" - -const modifierLeftShift byte = 0x02 - -type Report [8]byte - -func ReportsForText(text string) ([]Report, error) { - reports := make([]Report, 0, len(text)*2) - for _, char := range text { - report, err := PressReport(char) - if err != nil { - return nil, err - } - reports = append(reports, report, ReleaseReport()) - } - - return reports, nil -} - -func PressReport(char rune) (Report, error) { - modifier, keycode, ok := keyForRune(char) - if !ok { - return Report{}, fmt.Errorf("unsupported HID test character: %q", char) - } - - return Report{modifier, 0x00, keycode}, nil -} - -func ReleaseReport() Report { - return Report{} -} - -func (report Report) Bytes() []byte { - return []byte{ - report[0], - report[1], - report[2], - report[3], - report[4], - report[5], - report[6], - report[7], - } -} - -func Bytes(reports []Report) [][]byte { - out := make([][]byte, 0, len(reports)) - for _, report := range reports { - out = append(out, report.Bytes()) - } - - return out -} - -func keyForRune(char rune) (byte, byte, bool) { - if char >= 'a' && char <= 'z' { - return 0x00, byte(char-'a') + 0x04, true - } - if char >= 'A' && char <= 'Z' { - return modifierLeftShift, byte(char-'A') + 0x04, true - } - if char >= '1' && char <= '9' { - return 0x00, byte(char-'1') + 0x1e, true - } - - switch char { - case '0': - return 0x00, 0x27, true - case '\n', '\r': - return 0x00, 0x28, true - case '\t': - return 0x00, 0x2b, true - case ' ': - return 0x00, 0x2c, true - case '-': - return 0x00, 0x2d, true - case '_': - return modifierLeftShift, 0x2d, true - case '=': - return 0x00, 0x2e, true - case '+': - return modifierLeftShift, 0x2e, true - case '[': - return 0x00, 0x2f, true - case '{': - return modifierLeftShift, 0x2f, true - case ']': - return 0x00, 0x30, true - case '}': - return modifierLeftShift, 0x30, true - case '\\': - return 0x00, 0x31, true - case '|': - return modifierLeftShift, 0x31, true - case ';': - return 0x00, 0x33, true - case ':': - return modifierLeftShift, 0x33, true - case '\'': - return 0x00, 0x34, true - case '"': - return modifierLeftShift, 0x34, true - case '`': - return 0x00, 0x35, true - case '~': - return modifierLeftShift, 0x35, true - case ',': - return 0x00, 0x36, true - case '<': - return modifierLeftShift, 0x36, true - case '.': - return 0x00, 0x37, true - case '>': - return modifierLeftShift, 0x37, true - case '/': - return 0x00, 0x38, true - case '?': - return modifierLeftShift, 0x38, true - case '!': - return modifierLeftShift, 0x1e, true - case '@': - return modifierLeftShift, 0x1f, true - case '#': - return modifierLeftShift, 0x20, true - case '$': - return modifierLeftShift, 0x21, true - case '%': - return modifierLeftShift, 0x22, true - case '^': - return modifierLeftShift, 0x23, true - case '&': - return modifierLeftShift, 0x24, true - case '*': - return modifierLeftShift, 0x25, true - case '(': - return modifierLeftShift, 0x26, true - case ')': - return modifierLeftShift, 0x27, true - default: - return 0x00, 0x00, false - } -} diff --git a/internal/hidreport/report_test.go b/internal/hidreport/report_test.go deleted file mode 100644 index c82c61b..0000000 --- a/internal/hidreport/report_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package hidreport_test - -import ( - "reflect" - "testing" - - "github.com/RarkHopper/RpiKeyboardSwitcher/internal/hidreport" -) - -func TestASCII文字をHIDキーボードreportへ変換する(t *testing.T) { - tests := []struct { - name string - text string - want []hidreport.Report - }{ - { - name: "小文字を押下と解放へ変換する", - text: "a", - want: []hidreport.Report{{0x00, 0x00, 0x04}, {}}, - }, - { - name: "大文字はshift付きで変換する", - text: "A", - want: []hidreport.Report{{0x02, 0x00, 0x04}, {}}, - }, - { - name: "数字を変換する", - text: "1", - want: []hidreport.Report{{0x00, 0x00, 0x1e}, {}}, - }, - { - name: "空白を変換する", - text: " ", - want: []hidreport.Report{{0x00, 0x00, 0x2c}, {}}, - }, - { - name: "改行をenterとして変換する", - text: "\n", - want: []hidreport.Report{{0x00, 0x00, 0x28}, {}}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := hidreport.ReportsForText(tt.text) - if err != nil { - t.Fatalf("err = %v, want nil", err) - } - if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("reports = %#v, want %#v", got, tt.want) - } - }) - } -} diff --git a/internal/input/descriptor.go b/internal/input/descriptor.go new file mode 100644 index 0000000..8b85086 --- /dev/null +++ b/internal/input/descriptor.go @@ -0,0 +1,113 @@ +package input + +import "fmt" + +type Descriptor struct { + ReportMap []byte + InputReportIDs []byte + OutputReportIDs []byte + UsesReportID bool +} + +type Report struct { + ID byte + Data []byte +} + +func ParseDescriptor(reportMap []byte) (Descriptor, error) { + if len(reportMap) == 0 { + return Descriptor{}, fmt.Errorf("HID report descriptor is empty") + } + + descriptor := Descriptor{ + ReportMap: append([]byte(nil), reportMap...), + } + seenInput := map[byte]bool{} + seenOutput := map[byte]bool{} + reportID := byte(0x00) + + for index := 0; index < len(reportMap); { + prefix := reportMap[index] + index++ + if prefix == 0xfe { + if index+2 > len(reportMap) { + return Descriptor{}, fmt.Errorf("HID long item is truncated") + } + size := int(reportMap[index]) + index += 2 + if index+size > len(reportMap) { + return Descriptor{}, fmt.Errorf("HID long item payload is truncated") + } + index += size + continue + } + + size := int(prefix & 0x03) + if size == 3 { + size = 4 + } + itemType := (prefix >> 2) & 0x03 + tag := (prefix >> 4) & 0x0f + if index+size > len(reportMap) { + return Descriptor{}, fmt.Errorf("HID short item payload is truncated") + } + value := reportMap[index : index+size] + index += size + + if itemType == 1 && tag == 8 { + if len(value) != 1 { + return Descriptor{}, fmt.Errorf("HID report ID item must be one byte") + } + reportID = value[0] + descriptor.UsesReportID = true + continue + } + if itemType == 0 && tag == 8 && !seenInput[reportID] { + descriptor.InputReportIDs = append(descriptor.InputReportIDs, reportID) + seenInput[reportID] = true + } + if itemType == 0 && tag == 9 && !seenOutput[reportID] { + descriptor.OutputReportIDs = append(descriptor.OutputReportIDs, reportID) + seenOutput[reportID] = true + } + } + + if len(descriptor.InputReportIDs) == 0 { + return Descriptor{}, fmt.Errorf("HID report descriptor has no input reports") + } + + return descriptor, nil +} + +func (descriptor Descriptor) Report(raw []byte) (Report, bool) { + if len(raw) == 0 { + return Report{}, false + } + + if !descriptor.UsesReportID { + return Report{ + ID: 0x00, + Data: append([]byte(nil), raw...), + }, true + } + + id := raw[0] + if !descriptor.hasInputReportID(id) { + return Report{}, false + } + + return Report{ + ID: id, + Data: append([]byte(nil), raw[1:]...), + }, true +} + +func (descriptor Descriptor) hasInputReportID(id byte) bool { + for _, candidate := range descriptor.InputReportIDs { + if candidate == id { + return true + } + } + + return false +} diff --git a/internal/input/descriptor_test.go b/internal/input/descriptor_test.go new file mode 100644 index 0000000..18db67c --- /dev/null +++ b/internal/input/descriptor_test.go @@ -0,0 +1,83 @@ +package input_test + +import ( + "reflect" + "testing" + + "github.com/RarkHopper/RpiKeyboardSwitcher/internal/input" +) + +func TestHIDreportDescriptorからreportIDなしの入力reportを読む(t *testing.T) { + descriptor, err := input.ParseDescriptor([]byte{ + 0x05, 0x01, + 0x09, 0x06, + 0xa1, 0x01, + 0x81, 0x02, + 0x91, 0x02, + 0xc0, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if descriptor.UsesReportID { + t.Fatal("UsesReportID = true, want false") + } + if !reflect.DeepEqual(descriptor.InputReportIDs, []byte{0x00}) { + t.Fatalf("InputReportIDs = %#v, want [0]", descriptor.InputReportIDs) + } + if !reflect.DeepEqual(descriptor.OutputReportIDs, []byte{0x00}) { + t.Fatalf("OutputReportIDs = %#v, want [0]", descriptor.OutputReportIDs) + } + + report, ok := descriptor.Report([]byte{0x00, 0x00, 0x04}) + if !ok { + t.Fatal("report ok = false, want true") + } + if want := (input.Report{ID: 0x00, Data: []byte{0x00, 0x00, 0x04}}); !reflect.DeepEqual(report, want) { + t.Fatalf("report = %#v, want %#v", report, want) + } +} + +func TestHIDreportDescriptorからreportIDありの入力reportを読む(t *testing.T) { + descriptor, err := input.ParseDescriptor([]byte{ + 0x05, 0x01, + 0x09, 0x06, + 0xa1, 0x01, + 0x85, 0x02, + 0x81, 0x02, + 0x85, 0x03, + 0x81, 0x02, + 0x85, 0x04, + 0x91, 0x02, + 0xc0, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !descriptor.UsesReportID { + t.Fatal("UsesReportID = false, want true") + } + if !reflect.DeepEqual(descriptor.InputReportIDs, []byte{0x02, 0x03}) { + t.Fatalf("InputReportIDs = %#v, want [2 3]", descriptor.InputReportIDs) + } + if !reflect.DeepEqual(descriptor.OutputReportIDs, []byte{0x04}) { + t.Fatalf("OutputReportIDs = %#v, want [4]", descriptor.OutputReportIDs) + } + + report, ok := descriptor.Report([]byte{0x02, 0x00, 0x00, 0x04}) + if !ok { + t.Fatal("report ok = false, want true") + } + if want := (input.Report{ID: 0x02, Data: []byte{0x00, 0x00, 0x04}}); !reflect.DeepEqual(report, want) { + t.Fatalf("report = %#v, want %#v", report, want) + } + if _, ok := descriptor.Report([]byte{0x04, 0x00}); ok { + t.Fatal("unknown report ID ok = true, want false") + } +} + +func Test壊れたHIDreportDescriptorは拒否する(t *testing.T) { + if _, err := input.ParseDescriptor([]byte{0x75}); err == nil { + t.Fatal("err = nil, want error") + } +} diff --git a/internal/input/forwarder_linux.go b/internal/input/forwarder_linux.go index 76cda95..ca66bdc 100644 --- a/internal/input/forwarder_linux.go +++ b/internal/input/forwarder_linux.go @@ -4,131 +4,45 @@ package input import ( "context" - "encoding/binary" "fmt" "io" - "path/filepath" - "sort" - "strings" - "unsafe" "golang.org/x/sys/unix" ) -const ( - eventSync = 0x00 - eventKey = 0x01 - - syncDropped = 0x03 - - // EVIOCGRAB prevents forwarded key events from also reaching the Raspberry Pi console. - evIOGrab = 0x40044590 -) - type Forwarder struct { - Paths []string - Log io.Writer -} - -type inputEvent struct { - Time unix.Timeval - Type uint16 - Code uint16 - Value int32 + Device string + Log io.Writer } -func (forwarder Forwarder) Run(ctx context.Context, send func([]byte) error) error { - paths, err := inputDevicePaths(forwarder.Paths) +func (forwarder Forwarder) Descriptor() (Descriptor, error) { + fd, err := unix.Open(forwarder.Device, unix.O_RDONLY|unix.O_CLOEXEC, 0) if err != nil { - return err - } - if len(paths) == 0 { - logf(forwarder.Log, "No keyboard input devices found at %s; set hid.input_devices to forward USB keyboard input\n", DefaultKeyboardGlob) - return nil - } - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - errs := make(chan error, len(paths)) - for _, path := range paths { - path := path - go func() { - errs <- readDevice(ctx, path, send) - }() - } - - for range paths { - select { - case <-ctx.Done(): - return nil - case err := <-errs: - if err != nil { - return err - } - } - } - - return nil -} - -func inputDevicePaths(patterns []string) ([]string, error) { - if len(patterns) == 0 { - patterns = []string{DefaultKeyboardGlob} - } - - seen := map[string]bool{} - paths := make([]string, 0, len(patterns)) - for _, pattern := range patterns { - if strings.ContainsAny(pattern, "*?[") { - matches, err := filepath.Glob(pattern) - if err != nil { - return nil, fmt.Errorf("expand input device path %q: %w", pattern, err) - } - sort.Strings(matches) - for _, match := range matches { - if !seen[match] { - seen[match] = true - paths = append(paths, match) - } - } - continue - } - - if !seen[pattern] { - seen[pattern] = true - paths = append(paths, pattern) - } + return Descriptor{}, fmt.Errorf("open hidraw device %s: %w", forwarder.Device, err) } + defer func() { + _ = unix.Close(fd) + }() - sort.Strings(paths) - return paths, nil + return readDescriptor(fd, forwarder.Device) } -func readDevice(ctx context.Context, path string, send func([]byte) error) error { - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) +func (forwarder Forwarder) Run(ctx context.Context, send func(Report) error) error { + fd, err := unix.Open(forwarder.Device, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) if err != nil { - return fmt.Errorf("open input device %s: %w", path, err) + return fmt.Errorf("open hidraw device %s: %w", forwarder.Device, err) } defer func() { _ = unix.Close(fd) }() - if err := unix.IoctlSetPointerInt(fd, evIOGrab, 1); err != nil { - return fmt.Errorf("grab input device %s: %w", path, err) - } - defer func() { - _ = unix.IoctlSetPointerInt(fd, evIOGrab, 0) - }() - var event inputEvent - eventSize := int(unsafe.Sizeof(event)) - typeOffset := uintptr(unsafe.Offsetof(event.Type)) - codeOffset := uintptr(unsafe.Offsetof(event.Code)) - valueOffset := uintptr(unsafe.Offsetof(event.Value)) - - buffer := make([]byte, eventSize*32) - state := KeyboardState{} + descriptor, err := readDescriptor(fd, forwarder.Device) + if err != nil { + return err + } + logf(forwarder.Log, "Forwarding HID reports from %s with %d byte report descriptor\n", forwarder.Device, len(descriptor.ReportMap)) + buffer := make([]byte, 4096) for { select { case <-ctx.Done(): @@ -141,7 +55,7 @@ func readDevice(ctx context.Context, path string, send func([]byte) error) error continue } if err != nil { - return fmt.Errorf("poll input device %s: %w", path, err) + return fmt.Errorf("poll hidraw device %s: %w", forwarder.Device, err) } if ready == 0 { continue @@ -152,40 +66,42 @@ func readDevice(ctx context.Context, path string, send func([]byte) error) error continue } if err != nil { - return fmt.Errorf("read input device %s: %w", path, err) + return fmt.Errorf("read hidraw device %s: %w", forwarder.Device, err) } if n == 0 { - return fmt.Errorf("input device %s closed", path) + return fmt.Errorf("hidraw device %s closed", forwarder.Device) } - for offset := 0; offset+eventSize <= n; offset += eventSize { - record := buffer[offset : offset+eventSize] - eventType := binary.NativeEndian.Uint16(record[typeOffset:]) - eventCode := binary.NativeEndian.Uint16(record[codeOffset:]) - eventValue := int32(binary.NativeEndian.Uint32(record[valueOffset:])) - - switch eventType { - case eventKey: - report, changed := state.Apply(eventCode, eventValue) - if changed { - if err := send(report.Bytes()); err != nil { - return err - } - } - case eventSync: - if eventCode == syncDropped { - report, changed := state.Reset() - if changed { - if err := send(report.Bytes()); err != nil { - return err - } - } - } - } + report, ok := descriptor.Report(buffer[:n]) + if !ok { + continue + } + if err := send(report); err != nil { + return err } } } +func readDescriptor(fd int, device string) (Descriptor, error) { + size, err := unix.IoctlRetInt(fd, uint(unix.HIDIOCGRDESCSIZE)) + if err != nil { + return Descriptor{}, fmt.Errorf("read hidraw descriptor size %s: %w", device, err) + } + if size <= 0 { + return Descriptor{}, fmt.Errorf("hidraw device %s returned empty report descriptor", device) + } + + raw := unix.HIDRawReportDescriptor{Size: uint32(size)} + if err := unix.IoctlHIDGetDesc(fd, &raw); err != nil { + return Descriptor{}, fmt.Errorf("read hidraw report descriptor %s: %w", device, err) + } + if int(raw.Size) > len(raw.Value) { + return Descriptor{}, fmt.Errorf("hidraw report descriptor %s is too large: %d bytes", device, raw.Size) + } + + return ParseDescriptor(raw.Value[:raw.Size]) +} + func logf(writer io.Writer, format string, args ...any) { if writer == nil { return diff --git a/internal/input/forwarder_other.go b/internal/input/forwarder_other.go index 80f7d6e..0c1bab8 100644 --- a/internal/input/forwarder_other.go +++ b/internal/input/forwarder_other.go @@ -8,10 +8,14 @@ import ( ) type Forwarder struct { - Paths []string - Log io.Writer + Device string + Log io.Writer } -func (forwarder Forwarder) Run(_ context.Context, _ func([]byte) error) error { +func (forwarder Forwarder) Descriptor() (Descriptor, error) { + return Descriptor{}, nil +} + +func (forwarder Forwarder) Run(_ context.Context, _ func(Report) error) error { return nil } diff --git a/internal/input/keyboard.go b/internal/input/keyboard.go deleted file mode 100644 index 440762a..0000000 --- a/internal/input/keyboard.go +++ /dev/null @@ -1,212 +0,0 @@ -package input - -import ( - "sort" - - "github.com/RarkHopper/RpiKeyboardSwitcher/internal/hidreport" -) - -const DefaultKeyboardGlob = "/dev/input/by-id/*-event-kbd" - -type KeyboardState struct { - modifiers byte - keys map[byte]bool -} - -func (state *KeyboardState) Apply(code uint16, value int32) (hidreport.Report, bool) { - if value == 2 { - return hidreport.Report{}, false - } - if value != 0 && value != 1 { - return hidreport.Report{}, false - } - - if mask, ok := modifierForCode(code); ok { - before := state.modifiers - if value == 1 { - state.modifiers |= mask - } else { - state.modifiers &^= mask - } - - return state.Report(), before != state.modifiers - } - - key, ok := keyForCode(code) - if !ok { - return hidreport.Report{}, false - } - if state.keys == nil { - state.keys = map[byte]bool{} - } - - before := state.keys[key] - if value == 1 { - state.keys[key] = true - } else { - delete(state.keys, key) - } - if before == (value == 1) { - return hidreport.Report{}, false - } - - return state.Report(), true -} - -func (state *KeyboardState) Reset() (hidreport.Report, bool) { - if state.modifiers == 0 && len(state.keys) == 0 { - return hidreport.Report{}, false - } - - state.modifiers = 0 - clear(state.keys) - - return state.Report(), true -} - -func (state KeyboardState) Report() hidreport.Report { - report := hidreport.Report{state.modifiers} - keys := make([]int, 0, len(state.keys)) - for code := range state.keys { - keys = append(keys, int(code)) - } - sort.Ints(keys) - - index := 2 - for _, code := range keys { - if index >= len(report) { - break - } - report[index] = byte(code) - index++ - } - - return report -} - -func modifierForCode(code uint16) (byte, bool) { - switch code { - case 29: - return 0x01, true - case 42: - return 0x02, true - case 56: - return 0x04, true - case 125: - return 0x08, true - case 97: - return 0x10, true - case 54: - return 0x20, true - case 100: - return 0x40, true - case 126: - return 0x80, true - default: - return 0x00, false - } -} - -func keyForCode(code uint16) (byte, bool) { - keys := map[uint16]byte{ - 1: 0x29, - 2: 0x1e, - 3: 0x1f, - 4: 0x20, - 5: 0x21, - 6: 0x22, - 7: 0x23, - 8: 0x24, - 9: 0x25, - 10: 0x26, - 11: 0x27, - 12: 0x2d, - 13: 0x2e, - 14: 0x2a, - 15: 0x2b, - 16: 0x14, - 17: 0x1a, - 18: 0x08, - 19: 0x15, - 20: 0x17, - 21: 0x1c, - 22: 0x18, - 23: 0x0c, - 24: 0x12, - 25: 0x13, - 26: 0x2f, - 27: 0x30, - 28: 0x28, - 30: 0x04, - 31: 0x16, - 32: 0x07, - 33: 0x09, - 34: 0x0a, - 35: 0x0b, - 36: 0x0d, - 37: 0x0e, - 38: 0x0f, - 39: 0x33, - 40: 0x34, - 41: 0x35, - 43: 0x31, - 44: 0x1d, - 45: 0x1b, - 46: 0x06, - 47: 0x19, - 48: 0x05, - 49: 0x11, - 50: 0x10, - 51: 0x36, - 52: 0x37, - 53: 0x38, - 55: 0x55, - 57: 0x2c, - 58: 0x39, - 59: 0x3a, - 60: 0x3b, - 61: 0x3c, - 62: 0x3d, - 63: 0x3e, - 64: 0x3f, - 65: 0x40, - 66: 0x41, - 67: 0x42, - 68: 0x43, - 69: 0x53, - 70: 0x47, - 71: 0x5f, - 72: 0x60, - 73: 0x61, - 74: 0x56, - 75: 0x5c, - 76: 0x5d, - 77: 0x5e, - 78: 0x57, - 79: 0x59, - 80: 0x5a, - 81: 0x5b, - 82: 0x62, - 83: 0x63, - 86: 0x64, - 87: 0x44, - 88: 0x45, - 96: 0x58, - 98: 0x54, - 99: 0x46, - 102: 0x4a, - 103: 0x52, - 104: 0x4b, - 105: 0x50, - 106: 0x4f, - 107: 0x4d, - 108: 0x51, - 109: 0x4e, - 110: 0x49, - 111: 0x4c, - 119: 0x48, - } - - key, ok := keys[code] - return key, ok -} diff --git a/internal/input/keyboard_test.go b/internal/input/keyboard_test.go deleted file mode 100644 index 24e2195..0000000 --- a/internal/input/keyboard_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package input_test - -import ( - "reflect" - "testing" - - "github.com/RarkHopper/RpiKeyboardSwitcher/internal/hidreport" - "github.com/RarkHopper/RpiKeyboardSwitcher/internal/input" -) - -func TestLinuxキー入力をHIDレポートへ変換する(t *testing.T) { - var state input.KeyboardState - - report, ok := state.Apply(30, 1) - if !ok { - t.Fatal("a press changed = false, want true") - } - if want := (hidreport.Report{0x00, 0x00, 0x04}); !reflect.DeepEqual(report, want) { - t.Fatalf("a press report = %#v, want %#v", report, want) - } - - report, ok = state.Apply(30, 0) - if !ok { - t.Fatal("a release changed = false, want true") - } - if want := (hidreport.Report{}); !reflect.DeepEqual(report, want) { - t.Fatalf("a release report = %#v, want %#v", report, want) - } -} - -func Test修飾キーと通常キーを同じレポートへ入れる(t *testing.T) { - var state input.KeyboardState - - if _, ok := state.Apply(42, 1); !ok { - t.Fatal("left shift press changed = false, want true") - } - report, ok := state.Apply(30, 1) - if !ok { - t.Fatal("a press changed = false, want true") - } - if want := (hidreport.Report{0x02, 0x00, 0x04}); !reflect.DeepEqual(report, want) { - t.Fatalf("shift+a report = %#v, want %#v", report, want) - } -} - -func Testキーリピートは無視する(t *testing.T) { - var state input.KeyboardState - - if _, ok := state.Apply(30, 1); !ok { - t.Fatal("a press changed = false, want true") - } - if _, ok := state.Apply(30, 2); ok { - t.Fatal("repeat changed = true, want false") - } -} - -func Test同期落ちでは解放レポートを返す(t *testing.T) { - var state input.KeyboardState - - if _, ok := state.Apply(30, 1); !ok { - t.Fatal("a press changed = false, want true") - } - report, ok := state.Reset() - if !ok { - t.Fatal("reset changed = false, want true") - } - if want := (hidreport.Report{}); !reflect.DeepEqual(report, want) { - t.Fatalf("reset report = %#v, want %#v", report, want) - } -} diff --git a/internal/rpiapp/app_test.go b/internal/rpiapp/app_test.go index 024510b..2644f42 100644 --- a/internal/rpiapp/app_test.go +++ b/internal/rpiapp/app_test.go @@ -272,6 +272,8 @@ targets: behavior: disconnect_others: true reconnect_wait_sec: 0 +hid: + hidraw_device: /dev/hidraw0 `) if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatal(err) @@ -292,6 +294,8 @@ targets: switch: name: Switch Named Target bluetooth_mac: AA:BB:CC:DD:EE:03 +hid: + hidraw_device: /dev/hidraw0 `) if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatal(err) From e6111ec35db058c5ceb02e271afd61867bce28ec Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Fri, 15 May 2026 19:31:22 +0900 Subject: [PATCH 04/37] =?UTF-8?q?docs:=20hidraw=E3=83=87=E3=83=90=E3=82=A4?= =?UTF-8?q?=E3=82=B9=E8=A8=AD=E5=AE=9A=E6=89=8B=E9=A0=86=E3=82=92=E5=8F=8D?= =?UTF-8?q?=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ja.md | 28 ++++++++++++++-------------- README.md | 28 ++++++++++++++-------------- examples/config.rpi.yaml | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/README.ja.md b/README.ja.md index 0b107c2..7b92dc9 100644 --- a/README.ja.md +++ b/README.ja.md @@ -4,7 +4,7 @@ RpiKeyboardSwitcher は、Raspberry Pi を Bluetooth HID キーボードの橋渡しとして使うための Go 製プロトタイプです。Raspberry Pi が BLE キーボードとして広告し、接続できたPCを BlueZ から読み取り、設定ファイルへ保存します。以後は PC 側の短い `kbd` コマンドから SSH 経由で Raspberry Pi に切替を指示します。 -`kbd-hid` デーモンは BLE HID キーボードとして広告し、ホストが HID 通知を有効にした後に Raspberry Pi の Linux 入力デバイスから読んだキー入力を送ります。初回確認用に固定のテスト文字も送れます。 +`kbd-hid` デーモンは BLE HID キーボードとして広告し、ホストが HID 通知を有効にした後に Raspberry Pi の hidraw デバイスから読んだ USB HID report を送ります。 ## コマンド @@ -19,14 +19,14 @@ RpiKeyboardSwitcher は、Raspberry Pi を Bluetooth HID キーボードの橋 | Raspberry Pi | `kbd-rpi`, `kbd-hid` | `/etc/kbd-switch/config.yaml` | BLE キーボードを広告し、疎通した Bluetooth 接続先を `targets` に保存し、切替を行います。 | | 切替コマンドを打つPC | `kbd` | `~/.config/kbd-switch/config.yaml` | Raspberry Pi への SSH 接続方法だけを持ちます。Bluetooth MAC アドレスは持ちません。 | | キーボード入力を受けるPC | 入力を受けるだけなら不要 | OS の Bluetooth 設定 | `Rpi Keyboard Switcher` とペアリングし、普通の BLE キーボードとして入力を受けます。このPCから切替も行うなら `kbd` も入れます。 | -| 有線キーボード | なし | なし | USB で Raspberry Pi に接続します。`kbd-hid` が Linux 入力デバイスからキー入力を読みます。 | +| 有線キーボード | なし | なし | USB で Raspberry Pi に接続します。`kbd-hid` が `/dev/hidraw*` から HID report を読みます。 | ## 処理の流れ まず Raspberry Pi に接続先を覚えさせます。 ```text -sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml --test-text a +sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml -> 対象PCのOS Bluetooth設定からペアリング/接続 -> ホストが HID 入力通知を有効にする -> kbd-hid が BlueZ Device1 の Address と Alias/Name を読む @@ -103,7 +103,7 @@ sudo apt-get install -y bluez `bluetoothctl list` または `ls /sys/class/bluetooth` で `hci0` が出ない場合は、Raspberry Pi 側で Bluetooth が無効になっていないかを先に確認します。 -`kbd-hid` は入力デバイスを読み取り、実行中は対象キーボードを Raspberry Pi の通常入力から外します。手動確認では `sudo kbd-hid ...` で実行し、systemd unit も root で起動します。 +`kbd-hid` は `/dev/hidraw*` を読みます。手動確認では `sudo kbd-hid ...` で実行し、systemd unit も root で起動します。 バイナリを `/usr/local/bin` に置きます。 @@ -134,7 +134,7 @@ hid: appearance: keyboard pairable: true discoverable: true - input_devices: [] + hidraw_device: /dev/hidraw0 ``` 接続先が保存されると、次のような項目が追加されます。 @@ -158,7 +158,7 @@ targets: - `hid.appearance`: HID の appearance。現在は `keyboard` のみ対応しています。 - `hid.pairable`: true または未指定なら、ペアリング要求を受け付けます。 - `hid.discoverable`: true または未指定なら、アダプタを discoverable にします。 -- `hid.input_devices`: 読み取る Linux 入力デバイス。空または未指定なら `/dev/input/by-id/*-event-kbd` を使います。特定のキーボードだけ読む場合は `/dev/input/by-id/...-event-kbd` を指定します。 +- `hid.hidraw_device`: 読み取る hidraw デバイス。USB キーボードに対応する `/dev/hidrawN` を指定します。 接続先名に使える文字は英数字、`_`、`-`、`.` だけです。未知の YAML フィールドはエラーにします。 @@ -170,32 +170,32 @@ Bluetooth に触る前に、`kbd-hid` が読む設定を確認します。 kbd-hid inspect --config /etc/kbd-switch/config.yaml ``` -USB キーボードを Raspberry Pi に挿し、入力デバイス名を確認します。 +USB キーボードを Raspberry Pi に挿し、hidraw デバイス名を確認します。 ```sh -ls -l /dev/input/by-id/*-event-kbd +ls -l /dev/hidraw* +udevadm info --query=all --name=/dev/hidraw0 ``` -複数のキーボードがあり、読む対象を固定したい場合は `hid.input_devices` に書きます。 +USB キーボードに対応する `hidraw` を `hid.hidraw_device` に書きます。`udevadm info` の `ID_INPUT_KEYBOARD=1` や `HID_NAME` を確認して選びます。 ```yaml hid: - input_devices: - - /dev/input/by-id/usb-Example_Keyboard-event-kbd + hidraw_device: /dev/hidraw0 ``` ### 4. 接続先を覚えさせる -初回は systemd ではなく手動で起動し、対象PCとのペアリングとテスト入力を確認します。 +初回は systemd ではなく手動で起動し、対象PCとのペアリングと USB キーボード入力を確認します。 ```sh sudo systemctl stop kbd-hid.service 2>/dev/null || true -sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml --test-text a +sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml ``` このコマンドは起動したまま待ちます。対象PCでテキストエディタなどの入力欄を開いてから、OS Bluetooth設定で `Rpi Keyboard Switcher` とペアリングします。ホストが HID 通知を有効にすると、`kbd-hid` が BlueZ の接続済みデバイスを読み取り、`targets` に保存します。同じ Bluetooth MAC アドレスがすでに保存済みなら、既存の接続先名と表示名を保ちます。 -対象PCで `a` が入力され、Raspberry Pi 側の `/etc/kbd-switch/config.yaml` に `targets` が増えたら、USB キーボードの入力も対象PCへ届くことを確認します。確認後は `Ctrl-C` で止めます。接続先名や表示名は、この時点で編集できます。Bluetooth MAC アドレスは通常そのままにします。 +Raspberry Pi 側の `/etc/kbd-switch/config.yaml` に `targets` が増え、USB キーボードの入力が対象PCへ届くことを確認します。確認後は `Ctrl-C` で止めます。接続先名や表示名は、この時点で編集できます。Bluetooth MAC アドレスは通常そのままにします。 ### 5. systemd で常駐させる diff --git a/README.md b/README.md index 3fb336e..01792b4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ RpiKeyboardSwitcher is a Go prototype for using a Raspberry Pi as a Bluetooth HID keyboard bridge. The Raspberry Pi advertises itself as a BLE keyboard, learns paired target PCs from BlueZ, caches them in its config, and later switches between those cached targets from a short `kbd` command over SSH. -The `kbd-hid` daemon advertises itself as a BLE HID keyboard and forwards key input read from Raspberry Pi Linux input devices after the host subscribes to HID notifications. It can also send fixed test text for the first pairing check. +The `kbd-hid` daemon advertises itself as a BLE HID keyboard and forwards USB HID reports read from a Raspberry Pi hidraw device after the host subscribes to HID notifications. ## Commands @@ -20,14 +20,14 @@ The `kbd-hid` daemon advertises itself as a BLE HID keyboard and forwards key in | Raspberry Pi | `kbd-rpi`, `kbd-hid` | `/etc/kbd-switch/config.yaml` | Advertises the BLE keyboard, caches confirmed Bluetooth targets, and switches targets. | | PC used to run switch commands | `kbd` | `~/.config/kbd-switch/config.yaml` | Knows how to SSH to the Raspberry Pi. It does not store Bluetooth MAC addresses. | | PC used as a keyboard target | nothing required for input | OS Bluetooth settings | Pairs with `Rpi Keyboard Switcher` as a normal BLE keyboard. Install `kbd` here only if this PC also runs switch commands. | -| Wired keyboard | none | none | Plugs into the Raspberry Pi over USB. `kbd-hid` reads key input from Linux input devices. | +| Wired keyboard | none | none | Plugs into the Raspberry Pi over USB. `kbd-hid` reads HID reports from `/dev/hidraw*`. | ## Flow First, learn a target on the Raspberry Pi: ```text -sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml --test-text a +sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml -> pair/connect from the target PC in the OS Bluetooth settings -> the host subscribes to HID input notifications -> kbd-hid reads BlueZ Device1 Address and Alias/Name @@ -103,7 +103,7 @@ sudo apt-get install -y bluez If `bluetoothctl list` or `ls /sys/class/bluetooth` does not show `hci0`, check the Raspberry Pi Bluetooth settings before continuing. -`kbd-hid` reads input devices and grabs the target keyboard while it is running so those key events do not also reach the Raspberry Pi console. The manual check uses `sudo kbd-hid ...`, and the systemd unit also runs as root. +`kbd-hid` reads `/dev/hidraw*`. The manual check uses `sudo kbd-hid ...`, and the systemd unit also runs as root. Install the binaries under `/usr/local/bin`. @@ -134,7 +134,7 @@ hid: appearance: keyboard pairable: true discoverable: true - input_devices: [] + hidraw_device: /dev/hidraw0 ``` After a target is learned, the file will contain entries like this: @@ -158,7 +158,7 @@ Fields: - `hid.appearance`: HID appearance. Currently only `keyboard` is supported. - `hid.pairable`: when true or omitted, allow incoming pairing requests. - `hid.discoverable`: when true or omitted, make the adapter discoverable. -- `hid.input_devices`: Linux input devices to read. When empty or omitted, `/dev/input/by-id/*-event-kbd` is used. To read only a specific keyboard, set one or more `/dev/input/by-id/...-event-kbd` paths. +- `hid.hidraw_device`: hidraw device to read. Set the `/dev/hidrawN` path for the USB keyboard. Target names may contain only letters, digits, `_`, `-`, and `.`. Unknown YAML fields are rejected. @@ -170,32 +170,32 @@ Check the settings read by `kbd-hid` before touching Bluetooth: kbd-hid inspect --config /etc/kbd-switch/config.yaml ``` -Plug the USB keyboard into the Raspberry Pi and check the input device name: +Plug the USB keyboard into the Raspberry Pi and check the hidraw device name: ```sh -ls -l /dev/input/by-id/*-event-kbd +ls -l /dev/hidraw* +udevadm info --query=all --name=/dev/hidraw0 ``` -If more than one keyboard exists and you want to pin the source, set `hid.input_devices`. +Set `hid.hidraw_device` to the hidraw device that belongs to the USB keyboard. Use `ID_INPUT_KEYBOARD=1` and `HID_NAME` from `udevadm info` to identify it. ```yaml hid: - input_devices: - - /dev/input/by-id/usb-Example_Keyboard-event-kbd + hidraw_device: /dev/hidraw0 ``` ### 4. Learn A Target -For the first check, start `kbd-hid` by hand instead of systemd and verify pairing plus test input from a target PC. +For the first check, start `kbd-hid` by hand instead of systemd and verify pairing plus USB keyboard input from a target PC. ```sh sudo systemctl stop kbd-hid.service 2>/dev/null || true -sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml --test-text a +sudo kbd-hid daemon --config /etc/kbd-switch/config.yaml ``` This command keeps running. On the target PC, open a text editor or another text field, then open the OS Bluetooth settings and pair with `Rpi Keyboard Switcher`. Once the host subscribes to HID notifications, `kbd-hid` reads the connected BlueZ device and adds it to `targets`. If the same Bluetooth MAC address is already present, the existing target key and name are kept. -When the target PC receives `a` and `/etc/kbd-switch/config.yaml` gains a `targets` entry, also confirm that USB keyboard input reaches the target PC. Then stop the command with `Ctrl-C`. You can edit the generated target key and display name at this point. Leave the Bluetooth MAC address unchanged unless you know it is wrong. +When `/etc/kbd-switch/config.yaml` gains a `targets` entry and USB keyboard input reaches the target PC, stop the command with `Ctrl-C`. You can edit the generated target key and display name at this point. Leave the Bluetooth MAC address unchanged unless you know it is wrong. ### 5. Run kbd-hid Under systemd diff --git a/examples/config.rpi.yaml b/examples/config.rpi.yaml index 81831aa..24bd2bc 100644 --- a/examples/config.rpi.yaml +++ b/examples/config.rpi.yaml @@ -10,4 +10,4 @@ hid: appearance: keyboard pairable: true discoverable: true - input_devices: [] + hidraw_device: /dev/hidraw0 From 597fe14eb91d42261ddae1bf9bf7e7adaebd07ab Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:08:37 +0900 Subject: [PATCH 05/37] =?UTF-8?q?fix:=20hidraw=20descriptor=20size?= =?UTF-8?q?=E5=8F=96=E5=BE=97=E3=82=92=E6=AD=A3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/input/forwarder_linux.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/input/forwarder_linux.go b/internal/input/forwarder_linux.go index ca66bdc..3c22d45 100644 --- a/internal/input/forwarder_linux.go +++ b/internal/input/forwarder_linux.go @@ -83,7 +83,7 @@ func (forwarder Forwarder) Run(ctx context.Context, send func(Report) error) err } func readDescriptor(fd int, device string) (Descriptor, error) { - size, err := unix.IoctlRetInt(fd, uint(unix.HIDIOCGRDESCSIZE)) + size, err := unix.IoctlGetInt(fd, uint(unix.HIDIOCGRDESCSIZE)) if err != nil { return Descriptor{}, fmt.Errorf("read hidraw descriptor size %s: %w", device, err) } From b2f11c9b8e3f9268b3f732567594d7c0cdc0d267 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:09:20 +0900 Subject: [PATCH 06/37] =?UTF-8?q?fix:=20BLE=E5=BA=83=E5=91=8A=E3=81=AEdisc?= =?UTF-8?q?overable=E6=8C=87=E5=AE=9A=E3=82=92=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bluez/hid.go | 15 +++++++++------ internal/bluez/hid_test.go | 6 ++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/bluez/hid.go b/internal/bluez/hid.go index 38b4885..5f8843d 100644 --- a/internal/bluez/hid.go +++ b/internal/bluez/hid.go @@ -97,8 +97,9 @@ type Descriptor struct { } type HIDAdvertisement struct { - name string - appearance uint16 + name string + appearance uint16 + discoverable bool } type DaemonOptions struct { @@ -185,10 +186,11 @@ func NewHIDApplication(options ...HIDApplicationOptions) *HIDApplication { return app } -func NewHIDAdvertisement(name string, appearance uint16) *HIDAdvertisement { +func NewHIDAdvertisement(name string, appearance uint16, discoverable bool) *HIDAdvertisement { return &HIDAdvertisement{ - name: name, - appearance: appearance, + name: name, + appearance: appearance, + discoverable: discoverable, } } @@ -308,6 +310,7 @@ func (advertisement *HIDAdvertisement) Properties() map[string]dbus.Variant { "ServiceUUIDs": dbus.MakeVariant([]string{HIDServiceUUID}), "LocalName": dbus.MakeVariant(advertisement.name), "Appearance": dbus.MakeVariant(advertisement.appearance), + "Discoverable": dbus.MakeVariant(advertisement.discoverable), } return props } @@ -352,7 +355,7 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { InputReportIDs: options.InputReportIDs, OutputReportIDs: options.OutputReportIDs, }) - advertisement := NewHIDAdvertisement(options.Name, options.Appearance) + advertisement := NewHIDAdvertisement(options.Name, options.Appearance, options.Discoverable) agent := NewAgent(options.Log) if err := app.Export(conn); err != nil { diff --git a/internal/bluez/hid_test.go b/internal/bluez/hid_test.go index ec0e1d2..5dcb0d4 100644 --- a/internal/bluez/hid_test.go +++ b/internal/bluez/hid_test.go @@ -38,7 +38,6 @@ func TestGATTのObjectManagerはHIDserviceとcharacteristicを返す(t *testing. if got := service["UUID"].Value(); got != HIDServiceUUID { t.Fatalf("service UUID = %#v, want %#v", got, HIDServiceUUID) } - for _, path := range []dbus.ObjectPath{ HIDInfoPath, ReportMapPath, @@ -59,7 +58,7 @@ func TestGATTのObjectManagerはHIDserviceとcharacteristicを返す(t *testing. } func TestAdvertisementはHIDserviceとkeyboardのappearanceを含む(t *testing.T) { - advertisement := NewHIDAdvertisement("Rpi Keyboard Switcher", KeyboardAppearance) + advertisement := NewHIDAdvertisement("Rpi Keyboard Switcher", KeyboardAppearance, true) properties := advertisement.Properties() if got := properties["Type"].Value(); got != "peripheral" { @@ -71,6 +70,9 @@ func TestAdvertisementはHIDserviceとkeyboardのappearanceを含む(t *testing. if got := properties["Appearance"].Value(); got != KeyboardAppearance { t.Fatalf("Appearance = %#v, want %#v", got, KeyboardAppearance) } + if got := properties["Discoverable"].Value(); got != true { + t.Fatalf("Discoverable = %#v, want true", got) + } if got := properties["ServiceUUIDs"].Value(); !reflect.DeepEqual(got, []string{HIDServiceUUID}) { t.Fatalf("ServiceUUIDs = %#v, want %#v", got, []string{HIDServiceUUID}) } From 51ea4c1fbe01f00ab40a7689b3408ae853e3df19 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:09:39 +0900 Subject: [PATCH 07/37] =?UTF-8?q?fix:=20Report=20ID=E4=BB=98=E3=81=8Dkeybo?= =?UTF-8?q?ard=20report=E3=82=92Boot=20Input=E3=81=B8=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bluez/hid.go | 2 +- internal/bluez/hid_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/bluez/hid.go b/internal/bluez/hid.go index 5f8843d..8308e48 100644 --- a/internal/bluez/hid.go +++ b/internal/bluez/hid.go @@ -540,7 +540,7 @@ func (app *HIDApplication) notifyInputLocked(report InputReport) error { return nil } fallbackPath := dbus.ObjectPath("") - if report.ID == 0x00 && len(report.Data) == 8 { + if len(report.Data) == 8 { fallbackPath = BootInputPath } if fallbackPath != "" { diff --git a/internal/bluez/hid_test.go b/internal/bluez/hid_test.go index 5dcb0d4..759282a 100644 --- a/internal/bluez/hid_test.go +++ b/internal/bluez/hid_test.go @@ -192,3 +192,35 @@ func TestBootProtocolではBootInputへだけreportを送る(t *testing.T) { t.Fatalf("signal path = %s, want %s", emitter.signals[0].path, BootInputPath) } } + +func TestBootInputだけ通知中ならreportID付きkeyboardReportをBootInputへ送る(t *testing.T) { + app := NewHIDApplication(HIDApplicationOptions{ + ReportMap: []byte{0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x85, 0x01, 0x81, 0x02, 0xc0}, + InputReportIDs: []byte{0x01}, + }) + emitter := &fakeEmitter{} + app.SetEmitter(emitter) + + if err := app.characteristics[BootInputPath].StartNotify(); err != nil { + t.Fatalf("BootInput StartNotify err = %v, want nil", err) + } + + report := []byte{0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00} + if err := app.SendInputReport(InputReport{ID: 0x01, Data: report}); err != nil { + t.Fatalf("SendInputReport err = %v, want nil", err) + } + + if len(emitter.signals) != 1 { + t.Fatalf("signals = %#v, want 1 signal", emitter.signals) + } + if emitter.signals[0].path != BootInputPath { + t.Fatalf("signal path = %s, want %s", emitter.signals[0].path, BootInputPath) + } + changed, ok := emitter.signals[0].values[1].(map[string]dbus.Variant) + if !ok { + t.Fatalf("changed properties = %#v", emitter.signals[0].values[1]) + } + if got := changed["Value"].Value(); !reflect.DeepEqual(got, report) { + t.Fatalf("Value = %#v, want %#v", got, report) + } +} From ac3fd35ccb312e9f377a508af0ca8395a3f681da Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:10:01 +0900 Subject: [PATCH 08/37] =?UTF-8?q?test:=20=E4=BB=AE=E6=83=B3HCI=E6=8E=A5?= =?UTF-8?q?=E7=B6=9Aproxy=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + tools/hci-proxy.py | 183 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100755 tools/hci-proxy.py diff --git a/.gitignore b/.gitignore index 71bbc64..3ae35ae 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.out *.test coverage.out +__pycache__/ .DS_Store .idea/ diff --git a/tools/hci-proxy.py b/tools/hci-proxy.py new file mode 100755 index 0000000..2604ab8 --- /dev/null +++ b/tools/hci-proxy.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +import argparse +import os +import selectors +import socket +import time + +HCI_PRIMARY = 0x00 + + +def h4_packet_length(buf): + if not buf: + return None + + packet_type = buf[0] + if packet_type == 0xFF: + return 2 if len(buf) >= 2 else None + if packet_type == 0x01: + if len(buf) < 4: + return None + return 4 + buf[3] + if packet_type == 0x02: + if len(buf) < 5: + return None + return 5 + buf[3] + (buf[4] << 8) + if packet_type == 0x03: + if len(buf) < 4: + return None + return 4 + buf[3] + if packet_type == 0x04: + if len(buf) < 3: + return None + return 3 + buf[2] + if packet_type == 0x05: + if len(buf) < 5: + return None + return 5 + buf[3] + ((buf[4] & 0x3F) << 8) + + raise ValueError(f"unknown H4 packet type 0x{packet_type:02x}") + + +def take_h4_packets(buf): + packets = [] + while buf: + try: + length = h4_packet_length(buf) + except ValueError: + buf = buf[1:] + continue + if length is None or len(buf) < length: + break + packets.append(bytes(buf[:length])) + buf = buf[length:] + return packets, buf + + +def write_all_fd(fd, data): + view = memoryview(data) + while view: + written = os.write(fd, view) + view = view[written:] + + +def open_vhci(): + fd = os.open("/dev/vhci", os.O_RDWR | os.O_CLOEXEC) + os.write(fd, bytes([0xFF, HCI_PRIMARY])) + return fd + + +def connect_tcp(host, port, timeout): + deadline = time.monotonic() + timeout + last_error = None + while time.monotonic() < deadline: + connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + connection.connect((host, port)) + connection.setblocking(False) + return connection + except OSError as error: + last_error = error + connection.close() + time.sleep(0.1) + raise TimeoutError(f"could not connect to {host}:{port}") from last_error + + +def raw_proxy(left, right): + left.setblocking(False) + right.setblocking(False) + selector = selectors.DefaultSelector() + selector.register(left, selectors.EVENT_READ, right) + selector.register(right, selectors.EVENT_READ, left) + + while True: + for key, _ in selector.select(): + try: + data = key.fileobj.recv(4096) + except BlockingIOError: + continue + if not data: + return + key.data.sendall(data) + + +def bridge(listen_host, listen_port, unix_path): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((listen_host, listen_port)) + server.listen() + print(f"bridge listening {listen_host}:{listen_port} -> {unix_path}", flush=True) + + while True: + client, _ = server.accept() + upstream = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + upstream.connect(unix_path) + pid = os.fork() + if pid == 0: + server.close() + raw_proxy(client, upstream) + os._exit(0) + client.close() + upstream.close() + + +def hci_proxy(vhci_fd, connection): + os.set_blocking(vhci_fd, False) + selector = selectors.DefaultSelector() + selector.register(vhci_fd, selectors.EVENT_READ, "vhci") + selector.register(connection, selectors.EVENT_READ, "sock") + vhci_buf = b"" + sock_buf = b"" + + while True: + for key, _ in selector.select(): + if key.data == "vhci": + try: + data = os.read(vhci_fd, 4096) + except BlockingIOError: + continue + if not data: + return + vhci_buf += data + packets, vhci_buf = take_h4_packets(vhci_buf) + for packet in packets: + if packet[:1] != b"\xff": + connection.sendall(packet) + else: + try: + data = connection.recv(4096) + except BlockingIOError: + continue + if not data: + return + sock_buf += data + packets, sock_buf = take_h4_packets(sock_buf) + for packet in packets: + if packet[:1] != b"\xff": + write_all_fd(vhci_fd, packet) + + +def main(): + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + client = subparsers.add_parser("client") + client.add_argument("host") + client.add_argument("--port", type=int, default=45550) + client.add_argument("--connect-timeout", type=float, default=10) + + bridge_parser = subparsers.add_parser("bridge") + bridge_parser.add_argument("--listen-host", default="127.0.0.1") + bridge_parser.add_argument("--port", type=int, default=45550) + bridge_parser.add_argument("--unix-path", default="/tmp/bt-server-le") + + args = parser.parse_args() + if args.command == "bridge": + bridge(args.listen_host, args.port, args.unix_path) + return + + hci_proxy(open_vhci(), connect_tcp(args.host, args.port, args.connect_timeout)) + + +if __name__ == "__main__": + main() From bbe9996ecb39c8f1931dbf84c45770939241049c Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:10:23 +0900 Subject: [PATCH 09/37] =?UTF-8?q?test:=20BlueZ=E3=81=AEpairing=20helper?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/bluez-agent.py | 90 ++++++++++++++++++++++++++++++ tools/bluez-pair.py | 130 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 tools/bluez-agent.py create mode 100644 tools/bluez-pair.py diff --git a/tools/bluez-agent.py b/tools/bluez-agent.py new file mode 100644 index 0000000..91ae7a1 --- /dev/null +++ b/tools/bluez-agent.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +import argparse +import signal +import sys + +import dbus +import dbus.mainloop.glib +import dbus.service +from gi.repository import GLib + + +BLUEZ = "org.bluez" +AGENT_MANAGER = "org.bluez.AgentManager1" +AGENT = "org.bluez.Agent1" +AGENT_PATH = "/com/rarkhopper/RpiKeyboardSwitcher/testagent" + + +class Agent(dbus.service.Object): + @dbus.service.method(AGENT, in_signature="", out_signature="") + def Release(self): + print("agent released", flush=True) + loop.quit() + + @dbus.service.method(AGENT, in_signature="o", out_signature="s") + def RequestPinCode(self, device): + print(f"request pin code device={device}", flush=True) + return "000000" + + @dbus.service.method(AGENT, in_signature="os", out_signature="") + def DisplayPinCode(self, device, pincode): + print(f"display pin code device={device} pincode={pincode}", flush=True) + + @dbus.service.method(AGENT, in_signature="ouq", out_signature="") + def DisplayPasskey(self, device, passkey, entered): + print(f"display passkey device={device} passkey={passkey:06d} entered={entered}", flush=True) + + @dbus.service.method(AGENT, in_signature="o", out_signature="u") + def RequestPasskey(self, device): + print(f"request passkey device={device}", flush=True) + return dbus.UInt32(0) + + @dbus.service.method(AGENT, in_signature="ou", out_signature="") + def RequestConfirmation(self, device, passkey): + print(f"confirm device={device} passkey={passkey:06d}", flush=True) + + @dbus.service.method(AGENT, in_signature="o", out_signature="") + def RequestAuthorization(self, device): + print(f"authorize pairing device={device}", flush=True) + + @dbus.service.method(AGENT, in_signature="os", out_signature="") + def AuthorizeService(self, device, uuid): + print(f"authorize service device={device} uuid={uuid}", flush=True) + + @dbus.service.method(AGENT, in_signature="", out_signature="") + def Cancel(self): + print("request canceled", flush=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--capability", default="KeyboardDisplay") + args = parser.parse_args() + + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SystemBus() + Agent(bus, AGENT_PATH) + + manager = dbus.Interface(bus.get_object(BLUEZ, "/org/bluez"), AGENT_MANAGER) + manager.RegisterAgent(AGENT_PATH, args.capability) + manager.RequestDefaultAgent(AGENT_PATH) + print(f"agent registered path={AGENT_PATH} capability={args.capability}", flush=True) + + def stop(_signum, _frame): + manager.UnregisterAgent(AGENT_PATH) + loop.quit() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + loop.run() + + +loop = GLib.MainLoop() + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"bluez-agent: {error}", file=sys.stderr, flush=True) + sys.exit(1) diff --git a/tools/bluez-pair.py b/tools/bluez-pair.py new file mode 100644 index 0000000..389abfe --- /dev/null +++ b/tools/bluez-pair.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +import argparse +import sys +import time + +import dbus + + +BLUEZ = "org.bluez" +OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager" +PROPERTIES = "org.freedesktop.DBus.Properties" +ADAPTER = "org.bluez.Adapter1" +DEVICE = "org.bluez.Device1" + + +def managed_objects(bus): + manager = dbus.Interface(bus.get_object(BLUEZ, "/"), OBJECT_MANAGER) + return manager.GetManagedObjects() + + +def adapter_path(adapter): + return f"/org/bluez/{adapter}" + + +def find_device(bus, address): + want = address.upper() + for path, interfaces in managed_objects(bus).items(): + props = interfaces.get(DEVICE) + if props and str(props.get("Address", "")).upper() == want: + return path, props + return None, None + + +def wait_for_device(bus, address, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + path, props = find_device(bus, address) + if path: + return path, props + time.sleep(0.2) + raise TimeoutError(f"device {address} was not discovered") + + +def get_props(bus, path, interface): + obj = bus.get_object(BLUEZ, path) + return dbus.Interface(obj, PROPERTIES).GetAll(interface) + + +def set_prop(bus, path, interface, name, value): + obj = bus.get_object(BLUEZ, path) + dbus.Interface(obj, PROPERTIES).Set(interface, name, value) + + +def bool_text(value): + return "yes" if bool(value) else "no" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("address") + parser.add_argument("--adapter", default="hci0") + parser.add_argument("--discover-timeout", type=float, default=45) + parser.add_argument("--connect-timeout", type=float, default=45) + args = parser.parse_args() + + bus = dbus.SystemBus() + adapter = adapter_path(args.adapter) + adapter_obj = bus.get_object(BLUEZ, adapter) + adapter_iface = dbus.Interface(adapter_obj, ADAPTER) + + existing_path, _ = find_device(bus, args.address) + if existing_path: + try: + adapter_iface.RemoveDevice(existing_path) + except dbus.DBusException: + pass + + set_prop(bus, adapter, ADAPTER, "Powered", dbus.Boolean(True)) + + adapter_iface.StartDiscovery() + try: + device_path, _ = wait_for_device(bus, args.address, args.discover_timeout) + finally: + try: + adapter_iface.StopDiscovery() + except dbus.DBusException: + pass + + device_obj = bus.get_object(BLUEZ, device_path) + device_iface = dbus.Interface(device_obj, DEVICE) + + props = get_props(bus, device_path, DEVICE) + if not bool(props.get("Paired", False)): + device_iface.Pair(timeout=args.connect_timeout) + + set_prop(bus, device_path, DEVICE, "Trusted", dbus.Boolean(True)) + + props = get_props(bus, device_path, DEVICE) + if not bool(props.get("Connected", False)): + device_iface.Connect(timeout=args.connect_timeout) + + deadline = time.monotonic() + args.connect_timeout + while time.monotonic() < deadline: + props = get_props(bus, device_path, DEVICE) + if bool(props.get("Paired", False)) and bool(props.get("Connected", False)): + break + time.sleep(0.2) + + props = get_props(bus, device_path, DEVICE) + print(f"Device: {args.address.upper()}", flush=True) + print(f"Name: {props.get('Name', '')}", flush=True) + print(f"Paired: {bool_text(props.get('Paired', False))}", flush=True) + print(f"Bonded: {bool_text(props.get('Bonded', False))}", flush=True) + print(f"Trusted: {bool_text(props.get('Trusted', False))}", flush=True) + print(f"Connected: {bool_text(props.get('Connected', False))}", flush=True) + + if not bool(props.get("Paired", False)): + raise RuntimeError("device is not paired") + if not bool(props.get("Trusted", False)): + raise RuntimeError("device is not trusted") + if not bool(props.get("Connected", False)): + raise RuntimeError("device is not connected") + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"bluez-pair: {error}", file=sys.stderr, flush=True) + sys.exit(1) From 7fcc7ff518557e16ef64376e6b91c930f01cd1fb Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:10:40 +0900 Subject: [PATCH 10/37] =?UTF-8?q?test:=20hidraw=E5=85=A5=E5=8A=9B=E3=82=92?= =?UTF-8?q?=E4=BD=9C=E3=82=8BCUSE=20helper=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/hidraw-cuse.c | 294 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 tools/hidraw-cuse.c diff --git a/tools/hidraw-cuse.c b/tools/hidraw-cuse.c new file mode 100644 index 0000000..eff3e4e --- /dev/null +++ b/tools/hidraw-cuse.c @@ -0,0 +1,294 @@ +#define FUSE_USE_VERSION 31 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *device_name = "RpiKeyboardSwitcher E2E Keyboard"; + +static const unsigned char report_descriptor[] = { + 0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x85, 0x01, + 0x05, 0x07, 0x19, 0xe0, 0x29, 0xe7, 0x15, 0x00, + 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, + 0x95, 0x01, 0x75, 0x08, 0x81, 0x01, 0x95, 0x05, + 0x75, 0x01, 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, + 0x91, 0x02, 0x95, 0x01, 0x75, 0x03, 0x91, 0x01, + 0x95, 0x06, 0x75, 0x08, 0x15, 0x00, 0x25, 0x65, + 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x81, 0x00, + 0xc0, +}; + +static const unsigned char input_reports[][9] = { + {0x01, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, +}; + +struct hidraw_state { + const char *devname; + const char *path_file; + const char *trigger_file; + pthread_mutex_t lock; + size_t next_report; + bool triggered; + struct fuse_pollhandle *pollhandle; +}; + +static void reply_error(fuse_req_t req, int err) +{ + fuse_reply_err(req, err < 0 ? -err : err); +} + +static void hidraw_open(fuse_req_t req, struct fuse_file_info *fi) +{ + fuse_reply_open(req, fi); +} + +static void hidraw_read(fuse_req_t req, size_t size, off_t off, + struct fuse_file_info *fi) +{ + struct hidraw_state *state = fuse_req_userdata(req); + const unsigned char *report = NULL; + size_t report_size = 0; + + (void)off; + (void)fi; + + pthread_mutex_lock(&state->lock); + if (state->triggered && + state->next_report < sizeof(input_reports) / sizeof(input_reports[0])) { + report = input_reports[state->next_report]; + report_size = sizeof(input_reports[state->next_report]); + fprintf(stderr, "read report %zu\n", state->next_report); + state->next_report++; + } + pthread_mutex_unlock(&state->lock); + + if (!report) { + reply_error(req, EAGAIN); + return; + } + if (size < report_size) + report_size = size; + + fuse_reply_buf(req, (const char *)report, report_size); +} + +static void hidraw_poll(fuse_req_t req, struct fuse_file_info *fi, + struct fuse_pollhandle *ph) +{ + struct hidraw_state *state = fuse_req_userdata(req); + unsigned revents = 0; + struct fuse_pollhandle *old = NULL; + + (void)fi; + + pthread_mutex_lock(&state->lock); + if (state->triggered && + state->next_report < sizeof(input_reports) / sizeof(input_reports[0])) { + revents = POLLIN; + fprintf(stderr, "poll ready\n"); + } else if (ph) { + old = state->pollhandle; + state->pollhandle = ph; + ph = NULL; + } + pthread_mutex_unlock(&state->lock); + + if (old) + fuse_pollhandle_destroy(old); + if (ph) + fuse_pollhandle_destroy(ph); + fuse_reply_poll(req, revents); +} + +static bool retry_output_ioctl(fuse_req_t req, void *arg, size_t size, + size_t out_bufsz) +{ + struct iovec out_iov; + + if (out_bufsz != 0) + return false; + + out_iov.iov_base = arg; + out_iov.iov_len = size; + fuse_reply_ioctl_retry(req, NULL, 0, &out_iov, 1); + + return true; +} + +static void hidraw_ioctl(fuse_req_t req, int cmd, void *arg, + struct fuse_file_info *fi, unsigned int flags, + const void *in_buf, size_t in_bufsz, + size_t out_bufsz) +{ + (void)arg; + (void)fi; + (void)flags; + (void)in_buf; + (void)in_bufsz; + (void)out_bufsz; + + if (_IOC_TYPE(cmd) != 'H') { + reply_error(req, ENOTTY); + return; + } + + switch (_IOC_NR(cmd)) { + case 0x01: { + int size = sizeof(report_descriptor); + if (retry_output_ioctl(req, arg, sizeof(size), out_bufsz)) + return; + fuse_reply_ioctl(req, 0, &size, sizeof(size)); + return; + } + case 0x02: { + struct hidraw_report_descriptor descriptor; + if (retry_output_ioctl(req, arg, sizeof(descriptor), out_bufsz)) + return; + memset(&descriptor, 0, sizeof(descriptor)); + descriptor.size = sizeof(report_descriptor); + memcpy(descriptor.value, report_descriptor, sizeof(report_descriptor)); + fuse_reply_ioctl(req, 0, &descriptor, sizeof(descriptor)); + return; + } + case 0x03: { + struct hidraw_devinfo info; + if (retry_output_ioctl(req, arg, sizeof(info), out_bufsz)) + return; + memset(&info, 0, sizeof(info)); + info.bustype = BUS_USB; + info.vendor = 0x1209; + info.product = 0x0001; + fuse_reply_ioctl(req, 0, &info, sizeof(info)); + return; + } + case 0x04: { + size_t size = _IOC_SIZE(cmd); + char name[256]; + if (size == 0 || size > sizeof(name)) + size = sizeof(name); + if (retry_output_ioctl(req, arg, size, out_bufsz)) + return; + memset(name, 0, sizeof(name)); + snprintf(name, sizeof(name), "%s", device_name); + fuse_reply_ioctl(req, 0, name, size); + return; + } + default: + reply_error(req, ENOTTY); + return; + } +} + +static void hidraw_init_done(void *userdata) +{ + struct hidraw_state *state = userdata; + FILE *file; + + if (!state->path_file) + return; + + file = fopen(state->path_file, "w"); + if (!file) + return; + fprintf(file, "/dev/%s\n", state->devname); + fclose(file); +} + +static void hidraw_destroy(void *userdata) +{ + struct hidraw_state *state = userdata; + struct fuse_pollhandle *ph = NULL; + + pthread_mutex_lock(&state->lock); + ph = state->pollhandle; + state->pollhandle = NULL; + pthread_mutex_unlock(&state->lock); + + if (ph) + fuse_pollhandle_destroy(ph); +} + +static void *trigger_thread(void *userdata) +{ + struct hidraw_state *state = userdata; + + while (access(state->trigger_file, F_OK) != 0) + usleep(50000); + + pthread_mutex_lock(&state->lock); + state->triggered = true; + state->next_report = 0; + struct fuse_pollhandle *ph = state->pollhandle; + state->pollhandle = NULL; + pthread_mutex_unlock(&state->lock); + + if (ph) { + fuse_lowlevel_notify_poll(ph); + fuse_pollhandle_destroy(ph); + } + fprintf(stderr, "input reports queued\n"); + + return NULL; +} + +static const struct cuse_lowlevel_ops hidraw_ops = { + .open = hidraw_open, + .read = hidraw_read, + .poll = hidraw_poll, + .ioctl = hidraw_ioctl, + .init_done = hidraw_init_done, + .destroy = hidraw_destroy, +}; + +static const char *arg_value(int argc, char **argv, const char *name, + const char *fallback) +{ + for (int i = 1; i + 1 < argc; i++) { + if (strcmp(argv[i], name) == 0) + return argv[i + 1]; + } + return fallback; +} + +int main(int argc, char **argv) +{ + struct hidraw_state state = { + .devname = arg_value(argc, argv, "--name", "rpi-hidraw-e2e"), + .path_file = arg_value(argc, argv, "--path-file", "/tmp/hidraw.path"), + .trigger_file = arg_value(argc, argv, "--trigger-file", "/tmp/send-report"), + .lock = PTHREAD_MUTEX_INITIALIZER, + }; + const char *dev_info_argv[1]; + char devname_arg[128]; + struct cuse_info cuse_info; + char *fuse_argv[] = {argv[0], "-f", "-s"}; + pthread_t thread; + + snprintf(devname_arg, sizeof(devname_arg), "DEVNAME=%s", state.devname); + dev_info_argv[0] = devname_arg; + + memset(&cuse_info, 0, sizeof(cuse_info)); + cuse_info.dev_info_argc = 1; + cuse_info.dev_info_argv = dev_info_argv; + cuse_info.flags = CUSE_UNRESTRICTED_IOCTL; + + if (pthread_create(&thread, NULL, trigger_thread, &state) != 0) { + perror("pthread_create"); + return 1; + } + pthread_detach(thread); + + return cuse_lowlevel_main(3, fuse_argv, &cuse_info, &hidraw_ops, &state); +} From b3ee4f13d076df51b51451c794149714d4b15802 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:10:53 +0900 Subject: [PATCH 11/37] =?UTF-8?q?test:=20Vagrant=E3=81=AEBLE=20HID?= =?UTF-8?q?=E6=A4=9C=E8=A8=BCVM=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Vagrantfile | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 Vagrantfile diff --git a/.gitignore b/.gitignore index 3ae35ae..0680d23 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ .DS_Store .idea/ .vscode/ +.vagrant/ diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..9a3b20e --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,74 @@ +GO_VERSION = "1.26.3" + +def provision_e2e_vm(config) + config.vm.synced_folder ".", "/vagrant" + config.vm.provision "shell", privileged: true, inline: <<-SHELL + set -eu + + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + bluez \ + bluez-test-tools \ + build-essential \ + ca-certificates \ + curl \ + dbus \ + git \ + kmod \ + libfuse3-dev \ + pkg-config \ + procps \ + python3 \ + "linux-modules-extra-$(uname -r)" + + go_archive="go#{GO_VERSION}.linux-arm64.tar.gz" + if ! /usr/local/go/bin/go version 2>/dev/null | grep -q "go#{GO_VERSION}"; then + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + curl -fsSL "https://go.dev/dl/${go_archive}" -o "${tmp_dir}/${go_archive}" + rm -rf /usr/local/go + tar -C /usr/local -xzf "${tmp_dir}/${go_archive}" + ln -sf /usr/local/go/bin/go /usr/local/bin/go + ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt + fi + + printf 'export PATH=/usr/local/go/bin:$PATH\\n' >/etc/profile.d/go.sh + chmod 0644 /etc/profile.d/go.sh + git config --global --add safe.directory /vagrant + sudo -u vagrant git config --global --add safe.directory /vagrant + + printf 'hci_vhci\\ncuse\\n' >/etc/modules-load.d/rpi-keyboard-switcher-e2e.conf + modprobe hci_vhci + modprobe cuse + test -e /dev/vhci + test -e /dev/cuse + SHELL +end + +def configure_utm(vm, name) + vm.vm.provider "utm" do |utm| + utm.name = name + utm.cpus = 2 + utm.memory = 4096 + utm.directory_share_mode = "virtFS" + end +end + +Vagrant.configure("2") do |config| + config.vm.box = "bento/ubuntu-24.04" + config.vm.box_architecture = "arm64" + + config.vm.define "central" do |central| + central.vm.hostname = "rpi-keyboard-switcher-central" + central.vm.network "forwarded_port", guest: 45550, host: 45560, auto_correct: false + configure_utm(central, "RpiKeyboardSwitcher E2E Central") + provision_e2e_vm(central) + end + + config.vm.define "peripheral" do |peripheral| + peripheral.vm.hostname = "rpi-keyboard-switcher-peripheral" + configure_utm(peripheral, "RpiKeyboardSwitcher E2E Peripheral") + provision_e2e_vm(peripheral) + end +end From 6b5a618fc742dd3bf2c6b03002bc157cbe11a1d0 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:11:09 +0900 Subject: [PATCH 12/37] =?UTF-8?q?test:=20BLE=20HID=20E2E=E3=82=92make?= =?UTF-8?q?=E3=81=8B=E3=82=89=E5=AE=9F=E8=A1=8C=E5=8F=AF=E8=83=BD=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 6 +- scripts/hid-e2e.sh | 362 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+), 1 deletion(-) create mode 100755 scripts/hid-e2e.sh diff --git a/Makefile b/Makefile index fe07470..4eff08d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: build fmt lint lint-config test check +.PHONY: build fmt lint lint-config test check e2e GOLANGCI_LINT := go tool golangci-lint +VAGRANT ?= vagrant LOCAL_GOOS ?= $(shell go env GOOS) LOCAL_GOARCH ?= $(shell go env GOARCH) RPI_GOOS ?= linux @@ -25,3 +26,6 @@ test: go test ./... check: lint-config lint test + +e2e: + VAGRANT=$(VAGRANT) scripts/hid-e2e.sh diff --git a/scripts/hid-e2e.sh b/scripts/hid-e2e.sh new file mode 100755 index 0000000..57b8973 --- /dev/null +++ b/scripts/hid-e2e.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +set -euo pipefail + +central_host="${KBD_E2E_CENTRAL_HOST:-10.0.2.2}" +central_port="${KBD_E2E_CENTRAL_PORT:-45560}" +vagrant_provider="${KBD_E2E_VAGRANT_PROVIDER:-utm}" +vagrant_cmd="${VAGRANT:-vagrant}" + +log() { + printf 'hid-e2e: %s\n' "$*" +} + +fail() { + printf 'hid-e2e: %s\n' "$*" >&2 + print_logs >&2 || true + exit 1 +} + +need_command() { + command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" +} + +vm_sudo() { + local vm="$1" + "${vagrant_cmd}" ssh "$vm" -c "sudo bash -s" +} + +print_logs() { + for vm in central peripheral; do + printf '\n===== %s logs =====\n' "$vm" + "${vagrant_cmd}" ssh "$vm" -c 'sudo bash -s' <<'REMOTE' || true +for file in \ + /tmp/hid-e2e-events.log \ + /tmp/hid-e2e-reader.log \ + /tmp/btmon-report.log \ + /tmp/bluez-agent.log \ + /tmp/kbd-hid.log \ + /tmp/hidraw-cuse.log \ + /tmp/bluetoothd.log \ + /tmp/btvirt.log \ + /tmp/hci-bridge.log \ + /tmp/hci-client.log \ + /tmp/btmgmt.log \ + /tmp/bluetoothctl-pair.log; do + if [ -s "$file" ]; then + printf '\n--- %s ---\n' "$file" + tail -200 "$file" + fi +done +REMOTE + done +} + +start_vms() { + need_command "${vagrant_cmd}" + log "starting Vagrant VMs" + "${vagrant_cmd}" up --provider="${vagrant_provider}" central peripheral +} + +reset_bluetooth_host() { + local vm="$1" + vm_sudo "$vm" <<'REMOTE' +set -euo pipefail + +systemctl stop bluetooth.service bluetooth.target >/dev/null 2>&1 || true +systemctl mask --runtime bluetooth.service >/dev/null 2>&1 || true +systemctl stop bluetooth.service bluetooth.target >/dev/null 2>&1 || true +pkill -x bluetoothctl >/dev/null 2>&1 || true +pkill -x bluetoothd >/dev/null 2>&1 || true +pkill -x btvirt >/dev/null 2>&1 || true +pkill -x btmon >/dev/null 2>&1 || true +pkill -x kbd-hid >/dev/null 2>&1 || true +pkill -x hidraw-cuse >/dev/null 2>&1 || true +pkill -x python3 >/dev/null 2>&1 || true +sleep 1 +rmmod hci_vhci >/dev/null 2>&1 || true +modprobe hci_vhci +rm -rf /var/lib/bluetooth/* +REMOTE +} + +start_bluez_adapter() { + local vm="$1" + vm_sudo "$vm" <<'REMOTE' +set -euo pipefail + +for _ in $(seq 1 100); do + [ -d /sys/class/bluetooth/hci0 ] && break + sleep 0.1 +done +[ -d /sys/class/bluetooth/hci0 ] + +if command -v bluetoothd >/dev/null 2>&1; then + bluetoothd_path="$(command -v bluetoothd)" +else + bluetoothd_path="/usr/libexec/bluetooth/bluetoothd" +fi +"$bluetoothd_path" -n -d >/tmp/bluetoothd.log 2>&1 & + +for _ in $(seq 1 100); do + busctl --system get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Address >/tmp/bluez-adapter.log 2>&1 && + break + sleep 0.1 +done +busctl --system get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Address >/tmp/bluez-adapter.log + +btmgmt_cmd() { + { + printf 'select 0\n' + printf '%s\n' "$1" + printf 'quit\n' + } | script -qfec btmgmt /dev/null >>/tmp/btmgmt.log 2>&1 || true +} + +btmgmt_cmd 'power off' +btmgmt_cmd 'le on' +btmgmt_cmd 'bredr off' +btmgmt_cmd 'power on' +btmgmt_cmd 'connectable on' +REMOTE +} + +start_central() { + log "starting central Bluetooth host" + reset_bluetooth_host central + vm_sudo central <<'REMOTE' +set -euo pipefail + +rm -f /tmp/hid-e2e-events.log /tmp/hid-e2e-reader.log /tmp/bluetoothctl-pair.log \ + /tmp/bluez-agent.log /tmp/bluetoothd.log /tmp/btvirt.log /tmp/hci-bridge.log \ + /tmp/hci-client.log /tmp/btmgmt.log + +rm -f /tmp/bt-server-le +btvirt -s >/tmp/btvirt.log 2>&1 & + +for _ in $(seq 1 100); do + [ -S /tmp/bt-server-le ] && break + sleep 0.1 +done +[ -S /tmp/bt-server-le ] + +python3 /vagrant/tools/hci-proxy.py bridge \ + --listen-host 0.0.0.0 \ + --port 45550 \ + --unix-path /tmp/bt-server-le >/tmp/hci-bridge.log 2>&1 & +python3 /vagrant/tools/hci-proxy.py client 127.0.0.1 --port 45550 >/tmp/hci-client.log 2>&1 & +REMOTE + + start_bluez_adapter central + + vm_sudo central <<'REMOTE' +set -euo pipefail +python3 /vagrant/tools/bluez-agent.py --capability KeyboardDisplay >/tmp/bluez-agent.log 2>&1 & +for _ in $(seq 1 50); do + grep -q '^agent registered ' /tmp/bluez-agent.log 2>/dev/null && break + sleep 0.1 +done +grep -q '^agent registered ' /tmp/bluez-agent.log +REMOTE +} + +start_peripheral() { + log "starting peripheral BLE keyboard" + reset_bluetooth_host peripheral + vm_sudo peripheral </tmp/hci-client.log 2>&1 & +REMOTE + + start_bluez_adapter peripheral + + vm_sudo peripheral <<'REMOTE' +set -euo pipefail +cd /vagrant +GOCACHE=/tmp/go-cache GOMODCACHE=/tmp/go-mod /usr/local/go/bin/go build -o /tmp/kbd-hid ./cmd/kbd-hid +cflags="$(pkg-config fuse3 --cflags)" +libs="$(pkg-config fuse3 --libs)" +cc -Wall -Wextra -O2 -o /tmp/hidraw-cuse ./tools/hidraw-cuse.c $cflags $libs -pthread + +/tmp/hidraw-cuse --name rpi-hidraw-e2e --path-file /tmp/hidraw.path --trigger-file /tmp/send-report >/tmp/hidraw-cuse.log 2>&1 & +for _ in $(seq 1 50); do + [ -s /tmp/hidraw.path ] && break + sleep 0.1 +done +hidraw_device="$(cat /tmp/hidraw.path)" + +cat >/tmp/kbd-e2e.yaml </tmp/kbd-hid.log 2>&1 & +for _ in $(seq 1 100); do + grep -q 'GATT application registered' /tmp/bluetoothd.log 2>/dev/null && + grep -q 'Advertisement registered' /tmp/bluetoothd.log 2>/dev/null && + break + sleep 0.2 +done +grep -q 'GATT application registered' /tmp/bluetoothd.log +grep -q 'Advertisement registered' /tmp/bluetoothd.log +REMOTE +} + +peripheral_address() { + vm_sudo peripheral <<'REMOTE' | awk '/^addr / { print $2; exit }' +set -euo pipefail +btmgmt info | awk ' + $1 == "hci0:" { found = 1; next } + found && $1 == "addr" { print "addr " $2; exit } +' +REMOTE +} + +pair_central() { + local mac="$1" + log "pairing central with ${mac}" + vm_sudo central </tmp/bluetoothctl-pair.log 2>&1 + +grep -q 'Paired: yes' /tmp/bluetoothctl-pair.log +grep -q 'Connected: yes' /tmp/bluetoothctl-pair.log +grep -q 'Trusted: yes' /tmp/bluetoothctl-pair.log +REMOTE +} + +wait_for_central_input() { + local mac_lower + mac_lower="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + log "waiting for central evdev keyboard" + vm_sudo central </tmp/hid-e2e-event.path + sleep 3 + exit 0 + fi + sleep 0.2 +done +exit 1 +REMOTE +} + +capture_input() { + log "capturing central evdev and hidraw" + vm_sudo central <<'REMOTE' +set -euo pipefail + +event_path="$(cat /tmp/hid-e2e-event.path)" +hidraw_path="$(find /sys/devices/virtual/misc/uhid -maxdepth 3 -type d -name 'hidraw*' | sort | tail -1)" +hidraw_path="/dev/$(basename "$hidraw_path")" + +(timeout 25s btmon >/tmp/btmon-report.log 2>&1) & +timeout 22s python3 - "$event_path" "$hidraw_path" >/tmp/hid-e2e-events.log 2>/tmp/hid-e2e-reader.log <<'PY' & +import binascii +import os +import select +import struct +import sys +import time + +event_path = sys.argv[1] +hidraw_path = sys.argv[2] +event_fd = os.open(event_path, os.O_RDONLY | os.O_NONBLOCK) +hidraw_fd = os.open(hidraw_path, os.O_RDONLY | os.O_NONBLOCK) +fmt = "llHHI" +size = struct.calcsize(fmt) +end = time.time() + 21 +print(f"ready event={event_path} hidraw={hidraw_path}", flush=True) + +while time.time() < end: + readable, _, _ = select.select([event_fd, hidraw_fd], [], [], 0.5) + for fd in readable: + data = os.read(fd, 4096) + if fd == hidraw_fd: + print(f"hidraw {binascii.hexlify(data).decode()}", flush=True) + continue + for offset in range(0, len(data) // size * size, size): + _, _, event_type, code, value = struct.unpack(fmt, data[offset:offset + size]) + print(f"event type={event_type} code={code} value={value}", flush=True) +PY + +for _ in $(seq 1 50); do + grep -q '^ready ' /tmp/hid-e2e-events.log 2>/dev/null && exit 0 + sleep 0.1 +done +exit 1 +REMOTE +} + +trigger_input() { + log "triggering fake hidraw keyboard" + vm_sudo peripheral <<'REMOTE' +set -euo pipefail +rm -f /tmp/send-report +touch /tmp/send-report +REMOTE +} + +verify_input() { + log "verifying central input events" + vm_sudo central <<'REMOTE' +set -euo pipefail + +for _ in $(seq 1 100); do + grep -q 'event type=1 code=30 value=1' /tmp/hid-e2e-events.log 2>/dev/null && + grep -q 'event type=1 code=30 value=0' /tmp/hid-e2e-events.log 2>/dev/null && + grep -q 'hidraw 010000040000000000' /tmp/hid-e2e-events.log 2>/dev/null && + grep -q 'hidraw 010000000000000000' /tmp/hid-e2e-events.log 2>/dev/null && + exit 0 + sleep 0.1 +done +exit 1 +REMOTE +} + +main() { + start_vms + start_central + start_peripheral + mac="$(peripheral_address)" + [ -n "$mac" ] || fail "peripheral address was empty" + pair_central "$mac" + wait_for_central_input "$mac" + capture_input + trigger_input + verify_input + log "passed: virtual HCI pair, BLE HID notification, hidraw report, and evdev KEY_A press/release" +} + +main "$@" From 85302f812860e39c164e659e92fea62fa5542a35 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:11:22 +0900 Subject: [PATCH 13/37] =?UTF-8?q?chore:=20Docker=20Compose=E3=81=AEE2E?= =?UTF-8?q?=E5=AE=9A=E7=BE=A9=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 6 - compose.yaml | 18 --- docker/hid-e2e/Dockerfile | 15 --- docker/hid-e2e/run.sh | 273 -------------------------------------- 4 files changed, 312 deletions(-) delete mode 100644 .dockerignore delete mode 100644 compose.yaml delete mode 100644 docker/hid-e2e/Dockerfile delete mode 100644 docker/hid-e2e/run.sh diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index fd03363..0000000 --- a/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -.git -.gitignore -tmp -dist -bin -coverage.out diff --git a/compose.yaml b/compose.yaml deleted file mode 100644 index 385f99b..0000000 --- a/compose.yaml +++ /dev/null @@ -1,18 +0,0 @@ -services: - hid-e2e: - build: - context: . - dockerfile: docker/hid-e2e/Dockerfile - command: ["check"] - dns: - - 1.1.1.1 - - 8.8.8.8 - environment: - KBD_E2E_ADAPTER: hci0 - KBD_E2E_CENTRAL_ADAPTER: hci1 - KBD_E2E_NAME: Rpi Keyboard Switcher - KBD_E2E_TEXT: a - privileged: true - volumes: - - .:/work:ro - working_dir: /work diff --git a/docker/hid-e2e/Dockerfile b/docker/hid-e2e/Dockerfile deleted file mode 100644 index 0de425d..0000000 --- a/docker/hid-e2e/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM golang:1.26-bookworm - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - bluez \ - bluez-test-tools \ - dbus \ - kmod \ - procps \ - && rm -rf /var/lib/apt/lists/* - -COPY docker/hid-e2e/run.sh /usr/local/bin/hid-e2e -RUN chmod +x /usr/local/bin/hid-e2e - -ENTRYPOINT ["/usr/local/bin/hid-e2e"] diff --git a/docker/hid-e2e/run.sh b/docker/hid-e2e/run.sh deleted file mode 100644 index 874038b..0000000 --- a/docker/hid-e2e/run.sh +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -adapter="${KBD_E2E_ADAPTER:-hci0}" -central_adapter="${KBD_E2E_CENTRAL_ADAPTER:-hci1}" -adapter_index="${adapter#hci}" -central_adapter_index="${central_adapter#hci}" -device_name="${KBD_E2E_NAME:-Rpi Keyboard Switcher}" -test_text="${KBD_E2E_TEXT:-a}" -repo_dir="${KBD_E2E_REPO:-/work}" - -btvirt_pid="" -bluetoothd_pid="" -hid_pid="" - -log() { - printf 'hid-e2e: %s\n' "$*" -} - -fail() { - printf 'hid-e2e: %s\n' "$*" >&2 - print_logs - exit 1 -} - -need_command() { - command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" -} - -cleanup() { - set +e - for pid in "$hid_pid" "$bluetoothd_pid" "$btvirt_pid"; do - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - kill "$pid" 2>/dev/null - wait "$pid" 2>/dev/null - fi - done -} -trap cleanup EXIT - -print_logs() { - set +e - for file in /tmp/kbd-hid.log /tmp/bluetoothd.log /tmp/btvirt.log /tmp/btmgmt-peripheral.log /tmp/btmgmt-central.log /tmp/bluetoothctl-scan.log /tmp/bluetoothctl-connect.log /tmp/bluetoothctl-gatt.log; do - if [ -s "$file" ]; then - printf '\n===== %s =====\n' "$file" >&2 - tail -200 "$file" >&2 - fi - done -} - -check_prerequisites() { - need_command btvirt - need_command bluetoothctl - need_command btmgmt - need_command dbus-daemon - need_command go - need_command modprobe - - case "$adapter_index:$central_adapter_index" in - *[!0-9:]* | :* | *:) - fail "KBD_E2E_ADAPTER and KBD_E2E_CENTRAL_ADAPTER must look like hci0 and hci1" - ;; - esac - - log "kernel: $(uname -r)" - log "checking hci_vhci and uhid" - - if ! modprobe hci_vhci >/tmp/modprobe-hci-vhci.log 2>&1; then - cat /tmp/modprobe-hci-vhci.log >&2 - fail "hci_vhci is unavailable; use a Linux VM whose kernel has CONFIG_BT_HCIVHCI" - fi - if ! modprobe uhid >/tmp/modprobe-uhid.log 2>&1; then - cat /tmp/modprobe-uhid.log >&2 - fail "uhid is unavailable; use a Linux VM whose kernel has CONFIG_UHID" - fi - - [ -e /dev/vhci ] || fail "/dev/vhci was not created after loading hci_vhci" - [ -e /dev/uhid ] || fail "/dev/uhid was not created after loading uhid" - - log "kernel prerequisites are present" -} - -start_system_bus() { - mkdir -p /run/dbus - rm -f /run/dbus/system_bus_socket /run/dbus/pid - dbus-daemon --system --fork - export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket -} - -wait_for_path() { - path="$1" - name="$2" - for _ in $(seq 1 50); do - [ -e "$path" ] && return 0 - sleep 0.1 - done - fail "$name did not appear: $path" -} - -wait_for_bluetoothctl() { - for _ in $(seq 1 50); do - if bluetoothctl list >/tmp/bluetoothctl-list.log 2>&1; then - return 0 - fi - sleep 0.1 - done - cat /tmp/bluetoothctl-list.log >&2 || true - fail "bluetoothctl could not talk to bluetoothd" -} - -bluetoothd_path() { - if command -v bluetoothd >/dev/null 2>&1; then - command -v bluetoothd - return - fi - for path in /usr/lib/bluetooth/bluetoothd /usr/libexec/bluetooth/bluetoothd; do - if [ -x "$path" ]; then - printf '%s\n' "$path" - return - fi - done - fail "missing bluetoothd" -} - -start_bluez_lab() { - start_system_bus - - btvirt -l2 -L >/tmp/btvirt.log 2>&1 & - btvirt_pid="$!" - wait_for_path "/sys/class/bluetooth/$adapter" "$adapter" - wait_for_path "/sys/class/bluetooth/$central_adapter" "$central_adapter" - - "$(bluetoothd_path)" -n -E >/tmp/bluetoothd.log 2>&1 & - bluetoothd_pid="$!" - wait_for_bluetoothctl - - btmgmt --index "$adapter_index" power off >/tmp/btmgmt-peripheral.log 2>&1 || true - btmgmt --index "$central_adapter_index" power off >/tmp/btmgmt-central.log 2>&1 || true - btmgmt --index "$adapter_index" le on >>/tmp/btmgmt-peripheral.log 2>&1 || true - btmgmt --index "$central_adapter_index" le on >>/tmp/btmgmt-central.log 2>&1 || true - btmgmt --index "$adapter_index" bredr off >>/tmp/btmgmt-peripheral.log 2>&1 || true - btmgmt --index "$central_adapter_index" bredr off >>/tmp/btmgmt-central.log 2>&1 || true - btmgmt --index "$adapter_index" power on >>/tmp/btmgmt-peripheral.log 2>&1 || true - btmgmt --index "$central_adapter_index" power on >>/tmp/btmgmt-central.log 2>&1 || true -} - -write_config() { - cat >/tmp/kbd-e2e.yaml </tmp/kbd-hid.log 2>&1 & - hid_pid="$!" -} - -scan_for_device() { - bluetoothctl --timeout 10 >/tmp/bluetoothctl-scan.log 2>&1 </tmp/bluetoothctl-connect.log 2>&1 </tmp/bluetoothctl-gatt.log 2>&1 <>/tmp/bluetoothctl-gatt.log 2>&1 < Date: Sun, 17 May 2026 14:11:37 +0900 Subject: [PATCH 14/37] =?UTF-8?q?docs:=20=E4=BB=AE=E6=83=B3BLE=20HID?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E6=89=8B=E9=A0=86=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ja.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/README.ja.md b/README.ja.md index 7b92dc9..b4f168f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -307,6 +307,41 @@ eval "$(kbd-rpi completion bash)" Raspberry Pi はキー入力の経路上に置かれます。信頼できない Raspberry Pi を使うと、キー入力の読み取り、変更、注入が可能になります。業務PCや管理対象PCでは、所有者または管理者の許可なしに使わないでください。 +## 仮想検証 + +Vagrant と UTM で作った Ubuntu arm64 VM 2台で、物理キーボードや物理 Bluetooth アダプタを使わずに次の経路を確認できます。 + +```text +peripheral VM: + CUSE の fake hidraw -> kbd-hid -> BlueZ GATT server -> 仮想 HCI + +central VM: + 仮想 HCI -> BlueZ HoG client -> hidraw -> evdev KEY_A +``` + +Mac 側に Vagrant、UTM、UTM provider を入れます。 + +```sh +brew tap hashicorp/tap +brew install hashicorp/tap/hashicorp-vagrant +brew install --cask utm +vagrant plugin install vagrant_utm +``` + +Mac 側から検証を実行します。このコマンドは VM の作成または起動をしてから、BLE HID の検証を実行します。 + +```sh +make e2e +``` + +この検証は central VM の `btvirt` と peripheral VM の `/dev/vhci` を `tools/hci-proxy.py` でつなぎます。peripheral VM では CUSE で hidraw 互換のキーボードを作り、`kbd-hid` が BLE HID keyboard として広告します。central VM はペアリング後に Linux の HoG client で受け、`/dev/hidraw*` に report ID 付きの report が届くことと、`/dev/input/event*` に `KEY_A` の押下と解放が出ることを確認します。 + +スクリプトは Vagrant provider に `utm` を使います。UTM の NAT で peripheral VM から Mac 側へ出る IP が `10.0.2.2` ではない環境では、central VM の proxy を指す宛先とポートを指定します。 + +```sh +KBD_E2E_CENTRAL_HOST= KBD_E2E_CENTRAL_PORT=45560 make e2e +``` + ## 開発 ```sh diff --git a/README.md b/README.md index 01792b4..35276f4 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,41 @@ Completion candidates are read on each completion request. `kbd` asks the Raspbe The Raspberry Pi sits in the key input path. A compromised or untrusted Raspberry Pi could read, modify, or inject key input. Do not use this with a work PC or managed PC without approval from the owner or administrator. +## Virtual Check + +With two Ubuntu arm64 VMs created by Vagrant and UTM, you can check the following path without a physical keyboard or physical Bluetooth adapter: + +```text +peripheral VM: + CUSE fake hidraw -> kbd-hid -> BlueZ GATT server -> virtual HCI + +central VM: + virtual HCI -> BlueZ HoG client -> hidraw -> evdev KEY_A +``` + +Install Vagrant, UTM, and the UTM provider on the Mac: + +```sh +brew tap hashicorp/tap +brew install hashicorp/tap/hashicorp-vagrant +brew install --cask utm +vagrant plugin install vagrant_utm +``` + +Run the check from the Mac. This command creates or starts the VMs before running the BLE HID check: + +```sh +make e2e +``` + +The check connects central VM `btvirt` to peripheral VM `/dev/vhci` through `tools/hci-proxy.py`. The peripheral VM creates a CUSE hidraw-compatible keyboard and advertises `kbd-hid` as a BLE HID keyboard. The central VM pairs with it through the Linux HoG client, then verifies both the report-ID-bearing report on `/dev/hidraw*` and `KEY_A` press/release events on `/dev/input/event*`. + +The script uses the `utm` Vagrant provider by default. If UTM NAT does not expose the Mac as `10.0.2.2` from the peripheral VM, pass the host and port that reach the central proxy: + +```sh +KBD_E2E_CENTRAL_HOST= KBD_E2E_CENTRAL_PORT=45560 make e2e +``` + ## Development ```sh From 11341ad71175b1cd572520b4c3813014d794393b Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:15:02 +0900 Subject: [PATCH 15/37] =?UTF-8?q?chore:=20internal=20docs=E3=81=AEignore?= =?UTF-8?q?=E6=96=B9=E9=87=9D=E3=82=92=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/internal/.gitignore diff --git a/docs/internal/.gitignore b/docs/internal/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/docs/internal/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore From f12826c32a71efa70675670e9d049c5eb30750c3 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 14:53:57 +0900 Subject: [PATCH 16/37] =?UTF-8?q?ci:=20Go=E4=BB=A5=E5=A4=96=E3=81=AE?= =?UTF-8?q?=E6=A4=9C=E6=9F=BB=E3=82=82CI=E3=81=A7=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actionsの入口をmake ciに変更し、Goの通常検査に加えてrace test、ビルド、go.mod/go.sumの整合性、shell/Python/Vagrantfile/C helperの構文検査をCIで落とせるようにする。 --- .github/workflows/ci.yml | 14 +++++++++++++- Makefile | 38 ++++++++++++++++++++++++++++++++++++-- README.ja.md | 6 ++++++ README.md | 6 ++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdcfd3a..b7cc20e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: check: name: Check runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout @@ -24,5 +25,16 @@ jobs: go.mod go.sum + - name: Install check dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + libfuse3-dev \ + pkg-config \ + python3 \ + ruby \ + shellcheck + - name: Check - run: make check + run: make ci diff --git a/Makefile b/Makefile index 4eff08d..4f95984 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build fmt lint lint-config test check e2e +.PHONY: build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-check vagrant-check cuse-check ci e2e GOLANGCI_LINT := go tool golangci-lint VAGRANT ?= vagrant @@ -6,6 +6,11 @@ LOCAL_GOOS ?= $(shell go env GOOS) LOCAL_GOARCH ?= $(shell go env GOARCH) RPI_GOOS ?= linux RPI_GOARCH ?= arm64 +SHELLCHECK ?= shellcheck +PYTHON ?= python3 +SHELL_SCRIPTS := scripts/hid-e2e.sh +PYTHON_TOOLS := tools/hci-proxy.py tools/bluez-agent.py tools/bluez-pair.py +CUSE_TOOL := tools/hidraw-cuse.c build: mkdir -p dist @@ -16,16 +21,45 @@ build: fmt: $(GOLANGCI_LINT) fmt +fmt-check: + $(GOLANGCI_LINT) fmt --diff + lint: $(GOLANGCI_LINT) run ./... lint-config: $(GOLANGCI_LINT) config verify +vet: + go vet ./... + test: go test ./... -check: lint-config lint test +race-test: + go test -race ./... + +check: lint-config fmt-check lint vet test + +mod-check: + go mod tidy + git diff --exit-code -- go.mod go.sum + +script-check: + bash -n $(SHELL_SCRIPTS) + $(SHELLCHECK) $(SHELL_SCRIPTS) + +python-check: + $(PYTHON) -c 'import pathlib, sys; [compile(pathlib.Path(path).read_text(), path, "exec") for path in sys.argv[1:]]' $(PYTHON_TOOLS) + +vagrant-check: + ruby -c Vagrantfile + +cuse-check: + pkg-config --exists fuse3 + cc -Wall -Wextra -fsyntax-only $$(pkg-config --cflags fuse3) $(CUSE_TOOL) + +ci: check race-test build mod-check script-check python-check vagrant-check cuse-check e2e: VAGRANT=$(VAGRANT) scripts/hid-e2e.sh diff --git a/README.ja.md b/README.ja.md index b4f168f..8d4789a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -348,3 +348,9 @@ KBD_E2E_CENTRAL_HOST= KBD_E2E_CENTRAL_POR make fmt make check ``` + +GitHub Actions と同じ検査を Linux 環境で実行する場合は、次を使います。`shellcheck`、`ruby`、`pkg-config`、`libfuse3-dev` が必要です。 + +```sh +make ci +``` diff --git a/README.md b/README.md index 35276f4..3a9ce86 100644 --- a/README.md +++ b/README.md @@ -348,3 +348,9 @@ KBD_E2E_CENTRAL_HOST= KBD_E2E_CENTRAL_PORT=45560 make e2 make fmt make check ``` + +To run the same checks as GitHub Actions on Linux, use the following command. It needs `shellcheck`, `ruby`, `pkg-config`, and `libfuse3-dev`. + +```sh +make ci +``` From 083977c21aa16cc2169b8fc0e084e4ec4db3e148 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 15:28:50 +0900 Subject: [PATCH 17/37] =?UTF-8?q?fix:=20HID=20report=E5=87=A6=E7=90=86?= =?UTF-8?q?=E3=81=AE=E7=95=B0=E5=B8=B8=E7=B3=BB=E3=82=92=E6=98=8E=E7=A4=BA?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bluez/hid.go | 80 +++++++++++++++++++------------ internal/bluez/hid_test.go | 19 ++++++++ internal/input/descriptor.go | 27 ++++++++--- internal/input/descriptor_test.go | 14 ++++++ internal/input/forwarder_other.go | 11 +++-- 5 files changed, 109 insertions(+), 42 deletions(-) diff --git a/internal/bluez/hid.go b/internal/bluez/hid.go index 8308e48..7758c9f 100644 --- a/internal/bluez/hid.go +++ b/internal/bluez/hid.go @@ -52,6 +52,20 @@ const ( KeyboardAppearance uint16 = 0x03c1 ) +var ( + errUnknownInputReportID = errors.New("unknown input report ID") + errConnectedPeerNotFound = errors.New("connected Bluetooth device was not found") + errMultipleConnectedPeers = errors.New("multiple connected Bluetooth devices were found") + errMissingNotifyCharacteristic = errors.New("missing notify characteristic") + errCharacteristicNotWritable = errors.New("characteristic is not writable") + errProtocolModeSize = errors.New("protocol mode must be one byte") + errProtocolModeValue = errors.New("protocol mode must be 0 or 1") + errCharacteristicNotifyUnsupported = errors.New("characteristic does not support notify") + errDescriptorNotWritable = errors.New("descriptor is not writable") + errReadOffsetBeyondValue = errors.New("offset is beyond value length") + errMissingBluetoothDeviceProperty = errors.New("missing Bluetooth device property") +) + type emitter interface { Emit(path dbus.ObjectPath, name string, values ...any) error } @@ -232,7 +246,7 @@ func (app *HIDApplication) WaitForSubscription(ctx context.Context) error { case <-app.subscribed: return nil case <-ctx.Done(): - return ctx.Err() + return fmt.Errorf("wait for HID subscription: %w", ctx.Err()) } } @@ -275,10 +289,10 @@ func (app *HIDApplication) Export(conn *dbus.Conn) error { app.SetEmitter(conn) if err := conn.Export(app, AppPath, ObjectManagerInterface); err != nil { - return err + return fmt.Errorf("export HID object manager: %w", err) } if err := conn.ExportMethodTable(map[string]any{}, ServicePath, GATTServiceInterface); err != nil { - return err + return fmt.Errorf("export HID service method table: %w", err) } if err := exportProperties(conn, ServicePath, app.serviceProperties); err != nil { return err @@ -286,7 +300,7 @@ func (app *HIDApplication) Export(conn *dbus.Conn) error { for path, characteristic := range app.characteristics { if err := conn.Export(characteristic, path, GATTCharacteristicInterface); err != nil { - return err + return fmt.Errorf("export HID characteristic %s: %w", path, err) } if err := exportProperties(conn, path, characteristic.propertiesForInterface); err != nil { return err @@ -294,7 +308,7 @@ func (app *HIDApplication) Export(conn *dbus.Conn) error { } for path, descriptor := range app.descriptors { if err := conn.Export(descriptor, path, GATTDescriptorInterface); err != nil { - return err + return fmt.Errorf("export HID descriptor %s: %w", path, err) } if err := exportProperties(conn, path, descriptor.propertiesForInterface); err != nil { return err @@ -315,13 +329,13 @@ func (advertisement *HIDAdvertisement) Properties() map[string]dbus.Variant { return props } -func (advertisement *HIDAdvertisement) Release() *dbus.Error { +func (*HIDAdvertisement) Release() *dbus.Error { return nil } func (advertisement *HIDAdvertisement) Export(conn *dbus.Conn) error { if err := conn.Export(advertisement, AdvertisementPath, LEAdvertisementInterface); err != nil { - return err + return fmt.Errorf("export HID advertisement: %w", err) } return exportProperties(conn, AdvertisementPath, func(interfaceName string) (map[string]dbus.Variant, bool) { @@ -338,16 +352,16 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { conn, err := dbus.SystemBusPrivate() if err != nil { - return err + return fmt.Errorf("connect to system bus: %w", err) } defer func() { _ = conn.Close() }() if err := conn.Auth(nil); err != nil { - return err + return fmt.Errorf("authenticate system bus: %w", err) } if err := conn.Hello(); err != nil { - return err + return fmt.Errorf("send system bus hello: %w", err) } app := NewHIDApplication(HIDApplicationOptions{ @@ -371,13 +385,13 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { adapterPath := dbus.ObjectPath("/org/bluez/" + options.Adapter) adapter := conn.Object("org.bluez", adapterPath) if err := adapter.SetProperty(AdapterInterface+".Powered", dbus.MakeVariant(true)); err != nil { - return err + return fmt.Errorf("power Bluetooth adapter: %w", err) } if err := adapter.SetProperty(AdapterInterface+".Pairable", dbus.MakeVariant(options.Pairable)); err != nil { - return err + return fmt.Errorf("set Bluetooth adapter pairable: %w", err) } if err := adapter.SetProperty(AdapterInterface+".Discoverable", dbus.MakeVariant(options.Discoverable)); err != nil { - return err + return fmt.Errorf("set Bluetooth adapter discoverable: %w", err) } bluez := conn.Object("org.bluez", "/org/bluez") @@ -444,10 +458,10 @@ func ConnectedPeer(ctx context.Context, conn *dbus.Conn, adapter string) (Peer, var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant call := conn.Object("org.bluez", "/").CallWithContext(ctx, ObjectManagerInterface+".GetManagedObjects", 0) if call.Err != nil { - return Peer{}, call.Err + return Peer{}, fmt.Errorf("get managed Bluetooth objects: %w", call.Err) } if err := call.Store(&objects); err != nil { - return Peer{}, err + return Peer{}, fmt.Errorf("store managed Bluetooth objects: %w", err) } adapterPrefix := "/org/bluez/" + adapter + "/dev_" @@ -487,13 +501,13 @@ func ConnectedPeer(ctx context.Context, conn *dbus.Conn, adapter string) (Peer, } if len(candidates) == 0 { - return Peer{}, fmt.Errorf("connected Bluetooth device was not found on %s", adapter) + return Peer{}, fmt.Errorf("%w on %s", errConnectedPeerNotFound, adapter) } sort.Slice(candidates, func(left, right int) bool { return candidates[left].path < candidates[right].path }) if len(candidates) > 1 { - return Peer{}, fmt.Errorf("multiple connected Bluetooth devices were found on %s", adapter) + return Peer{}, fmt.Errorf("%w on %s", errMultipleConnectedPeers, adapter) } return candidates[0].peer, nil @@ -537,7 +551,7 @@ func (app *HIDApplication) serviceProperties(interfaceName string) (map[string]d func (app *HIDApplication) notifyInputLocked(report InputReport) error { preferredPath, ok := app.inputReportPath[report.ID] if !ok { - return nil + return fmt.Errorf("%w: 0x%02X", errUnknownInputReportID, report.ID) } fallbackPath := dbus.ObjectPath("") if len(report.Data) == 8 { @@ -598,7 +612,7 @@ func (app *HIDApplication) isNotifyingLocked(path dbus.ObjectPath) bool { func (app *HIDApplication) notifyLocked(path dbus.ObjectPath, report []byte) error { characteristic := app.characteristics[path] if characteristic == nil { - return fmt.Errorf("missing notify characteristic: %s", path) + return fmt.Errorf("%w: %s", errMissingNotifyCharacteristic, path) } characteristic.value = append(characteristic.value[:0], report...) @@ -606,13 +620,17 @@ func (app *HIDApplication) notifyLocked(path dbus.ObjectPath, report []byte) err return nil } - return app.emitter.Emit( + if err := app.emitter.Emit( path, PropertiesInterface+".PropertiesChanged", GATTCharacteristicInterface, map[string]dbus.Variant{"Value": dbus.MakeVariant(append([]byte(nil), report...))}, []string{}, - ) + ); err != nil { + return fmt.Errorf("emit HID characteristic notification: %w", err) + } + + return nil } func (service *Service) properties() map[string]dbus.Variant { @@ -639,13 +657,13 @@ func (characteristic *Characteristic) WriteValue(value []byte, _ map[string]dbus defer characteristic.app.mu.Unlock() if !characteristic.writable { - return dbusError("org.bluez.Error.NotPermitted", errors.New("characteristic is not writable")) + return dbusError("org.bluez.Error.NotPermitted", errCharacteristicNotWritable) } if characteristic.protocolMode && len(value) != 1 { - return dbusError("org.bluez.Error.InvalidValueLength", errors.New("protocol mode must be one byte")) + return dbusError("org.bluez.Error.InvalidValueLength", errProtocolModeSize) } if characteristic.protocolMode && value[0] > 1 { - return dbusError("org.bluez.Error.InvalidValueLength", errors.New("protocol mode must be 0 or 1")) + return dbusError("org.bluez.Error.InvalidValueLength", errProtocolModeValue) } characteristic.value = append(characteristic.value[:0], value...) @@ -657,7 +675,7 @@ func (characteristic *Characteristic) StartNotify() *dbus.Error { defer characteristic.app.mu.Unlock() if !characteristic.notify { - return dbusError("org.bluez.Error.NotSupported", errors.New("characteristic does not support notify")) + return dbusError("org.bluez.Error.NotSupported", errCharacteristicNotifyUnsupported) } characteristic.notifying = true characteristic.app.subscribeOnce.Do(func() { @@ -676,7 +694,7 @@ func (characteristic *Characteristic) StopNotify() *dbus.Error { return nil } -func (characteristic *Characteristic) Confirm() *dbus.Error { +func (*Characteristic) Confirm() *dbus.Error { return nil } @@ -714,8 +732,8 @@ func (descriptor *Descriptor) ReadValue(options map[string]dbus.Variant) ([]byte return value, nil } -func (descriptor *Descriptor) WriteValue(_ []byte, _ map[string]dbus.Variant) *dbus.Error { - return dbusError("org.bluez.Error.NotPermitted", errors.New("descriptor is not writable")) +func (*Descriptor) WriteValue(_ []byte, _ map[string]dbus.Variant) *dbus.Error { + return dbusError("org.bluez.Error.NotPermitted", errDescriptorNotWritable) } func (descriptor *Descriptor) propertiesForInterface(interfaceName string) (map[string]dbus.Variant, bool) { @@ -743,7 +761,7 @@ func readWithOffset(value []byte, options map[string]dbus.Variant) ([]byte, erro } } if int(offset) > len(value) { - return nil, fmt.Errorf("offset %d is beyond value length %d", offset, len(value)) + return nil, fmt.Errorf("%w: offset %d, value length %d", errReadOffsetBeyondValue, offset, len(value)) } return append([]byte(nil), value[offset:]...), nil @@ -794,7 +812,7 @@ func boolProperty(properties map[string]dbus.Variant, name string) (bool, error) var value bool variant, ok := properties[name] if !ok { - return false, fmt.Errorf("missing Bluetooth device property: %s", name) + return false, fmt.Errorf("%w: %s", errMissingBluetoothDeviceProperty, name) } if err := variant.Store(&value); err != nil { return false, fmt.Errorf("read Bluetooth device property %s: %w", name, err) @@ -807,7 +825,7 @@ func stringProperty(properties map[string]dbus.Variant, name string) (string, er var value string variant, ok := properties[name] if !ok { - return "", fmt.Errorf("missing Bluetooth device property: %s", name) + return "", fmt.Errorf("%w: %s", errMissingBluetoothDeviceProperty, name) } if err := variant.Store(&value); err != nil { return "", fmt.Errorf("read Bluetooth device property %s: %w", name, err) diff --git a/internal/bluez/hid_test.go b/internal/bluez/hid_test.go index 759282a..b63b3ef 100644 --- a/internal/bluez/hid_test.go +++ b/internal/bluez/hid_test.go @@ -165,6 +165,25 @@ func Test可変長reportを通知する(t *testing.T) { } } +func Test未知のInputReportIDはエラーを返す(t *testing.T) { + app := NewHIDApplication(HIDApplicationOptions{ + ReportMap: []byte{0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x85, 0x02, 0x81, 0x02, 0xc0}, + InputReportIDs: []byte{0x02}, + }) + emitter := &fakeEmitter{} + app.SetEmitter(emitter) + + if err := app.characteristics[ReportPath].StartNotify(); err != nil { + t.Fatalf("StartNotify err = %v, want nil", err) + } + if err := app.SendInputReport(InputReport{ID: 0x03, Data: []byte{0x11}}); err == nil { + t.Fatal("SendInputReport err = nil, want error") + } + if len(emitter.signals) != 0 { + t.Fatalf("signals = %#v, want none", emitter.signals) + } +} + func TestBootProtocolではBootInputへだけreportを送る(t *testing.T) { app := NewHIDApplication() emitter := &fakeEmitter{} diff --git a/internal/input/descriptor.go b/internal/input/descriptor.go index 8b85086..5e867dd 100644 --- a/internal/input/descriptor.go +++ b/internal/input/descriptor.go @@ -1,6 +1,16 @@ package input -import "fmt" +import "errors" + +var ( + errEmptyReportDescriptor = errors.New("HID report descriptor is empty") + errTruncatedLongItem = errors.New("HID long item is truncated") + errTruncatedLongItemPayload = errors.New("HID long item payload is truncated") + errTruncatedShortItemPayload = errors.New("HID short item payload is truncated") + errInvalidReportIDSize = errors.New("HID report ID item must be one byte") + errZeroReportID = errors.New("HID report ID must not be zero") + errNoInputReports = errors.New("HID report descriptor has no input reports") +) type Descriptor struct { ReportMap []byte @@ -16,7 +26,7 @@ type Report struct { func ParseDescriptor(reportMap []byte) (Descriptor, error) { if len(reportMap) == 0 { - return Descriptor{}, fmt.Errorf("HID report descriptor is empty") + return Descriptor{}, errEmptyReportDescriptor } descriptor := Descriptor{ @@ -31,12 +41,12 @@ func ParseDescriptor(reportMap []byte) (Descriptor, error) { index++ if prefix == 0xfe { if index+2 > len(reportMap) { - return Descriptor{}, fmt.Errorf("HID long item is truncated") + return Descriptor{}, errTruncatedLongItem } size := int(reportMap[index]) index += 2 if index+size > len(reportMap) { - return Descriptor{}, fmt.Errorf("HID long item payload is truncated") + return Descriptor{}, errTruncatedLongItemPayload } index += size continue @@ -49,14 +59,17 @@ func ParseDescriptor(reportMap []byte) (Descriptor, error) { itemType := (prefix >> 2) & 0x03 tag := (prefix >> 4) & 0x0f if index+size > len(reportMap) { - return Descriptor{}, fmt.Errorf("HID short item payload is truncated") + return Descriptor{}, errTruncatedShortItemPayload } value := reportMap[index : index+size] index += size if itemType == 1 && tag == 8 { if len(value) != 1 { - return Descriptor{}, fmt.Errorf("HID report ID item must be one byte") + return Descriptor{}, errInvalidReportIDSize + } + if value[0] == 0x00 { + return Descriptor{}, errZeroReportID } reportID = value[0] descriptor.UsesReportID = true @@ -73,7 +86,7 @@ func ParseDescriptor(reportMap []byte) (Descriptor, error) { } if len(descriptor.InputReportIDs) == 0 { - return Descriptor{}, fmt.Errorf("HID report descriptor has no input reports") + return Descriptor{}, errNoInputReports } return descriptor, nil diff --git a/internal/input/descriptor_test.go b/internal/input/descriptor_test.go index 18db67c..3332d60 100644 --- a/internal/input/descriptor_test.go +++ b/internal/input/descriptor_test.go @@ -81,3 +81,17 @@ func Test壊れたHIDreportDescriptorは拒否する(t *testing.T) { t.Fatal("err = nil, want error") } } + +func TestHIDreportDescriptorはゼロのReportIDを拒否する(t *testing.T) { + _, err := input.ParseDescriptor([]byte{ + 0x05, 0x01, + 0x09, 0x06, + 0xa1, 0x01, + 0x85, 0x00, + 0x81, 0x02, + 0xc0, + }) + if err == nil { + t.Fatal("err = nil, want error") + } +} diff --git a/internal/input/forwarder_other.go b/internal/input/forwarder_other.go index 0c1bab8..8c06f67 100644 --- a/internal/input/forwarder_other.go +++ b/internal/input/forwarder_other.go @@ -4,18 +4,21 @@ package input import ( "context" + "errors" "io" ) +var ErrUnsupportedOS = errors.New("input forwarder is not supported on non-linux") + type Forwarder struct { Device string Log io.Writer } -func (forwarder Forwarder) Descriptor() (Descriptor, error) { - return Descriptor{}, nil +func (Forwarder) Descriptor() (Descriptor, error) { + return Descriptor{}, ErrUnsupportedOS } -func (forwarder Forwarder) Run(_ context.Context, _ func(Report) error) error { - return nil +func (Forwarder) Run(_ context.Context, _ func(Report) error) error { + return ErrUnsupportedOS } From 569a12c76882aa5d35226debaaa3383f1339428c Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 15:29:07 +0900 Subject: [PATCH 18/37] =?UTF-8?q?fix:=20E2E=E8=A3=9C=E5=8A=A9=E5=87=A6?= =?UTF-8?q?=E7=90=86=E3=81=AE=E5=81=9C=E6=AD=A2=E3=81=A8=E9=80=81=E4=BF=A1?= =?UTF-8?q?=E3=82=92=E5=AE=89=E5=AE=9A=E3=81=95=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vagrantfile | 2 ++ scripts/hid-e2e.sh | 4 +++- tools/hci-proxy.py | 44 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/Vagrantfile b/Vagrantfile index 9a3b20e..be83dfb 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -1,4 +1,5 @@ GO_VERSION = "1.26.3" +GO_LINUX_ARM64_SHA256 = "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565" def provision_e2e_vm(config) config.vm.synced_folder ".", "/vagrant" @@ -27,6 +28,7 @@ def provision_e2e_vm(config) tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT curl -fsSL "https://go.dev/dl/${go_archive}" -o "${tmp_dir}/${go_archive}" + printf '%s %s\n' '#{GO_LINUX_ARM64_SHA256}' "${tmp_dir}/${go_archive}" | sha256sum -c - rm -rf /usr/local/go tar -C /usr/local -xzf "${tmp_dir}/${go_archive}" ln -sf /usr/local/go/bin/go /usr/local/bin/go diff --git a/scripts/hid-e2e.sh b/scripts/hid-e2e.sh index 57b8973..4197987 100755 --- a/scripts/hid-e2e.sh +++ b/scripts/hid-e2e.sh @@ -71,7 +71,9 @@ pkill -x btvirt >/dev/null 2>&1 || true pkill -x btmon >/dev/null 2>&1 || true pkill -x kbd-hid >/dev/null 2>&1 || true pkill -x hidraw-cuse >/dev/null 2>&1 || true -pkill -x python3 >/dev/null 2>&1 || true +pkill -f '/vagrant/tools/hci-proxy.py' >/dev/null 2>&1 || true +pkill -f '/vagrant/tools/bluez-agent.py' >/dev/null 2>&1 || true +pkill -f '/vagrant/tools/bluez-pair.py' >/dev/null 2>&1 || true sleep 1 rmmod hci_vhci >/dev/null 2>&1 || true modprobe hci_vhci diff --git a/tools/hci-proxy.py b/tools/hci-proxy.py index 2604ab8..e52d3ba 100755 --- a/tools/hci-proxy.py +++ b/tools/hci-proxy.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import argparse import os +import select import selectors +import signal import socket import time @@ -57,10 +59,33 @@ def take_h4_packets(buf): def write_all_fd(fd, data): view = memoryview(data) while view: - written = os.write(fd, view) + try: + written = os.write(fd, view) + except BlockingIOError: + select.select([], [fd], []) + continue + except InterruptedError: + continue + if written == 0: + raise BrokenPipeError("short write to fd") view = view[written:] +def send_all_socket(connection, data): + view = memoryview(data) + while view: + try: + sent = connection.send(view) + except BlockingIOError: + select.select([], [connection], []) + continue + except InterruptedError: + continue + if sent == 0: + raise BrokenPipeError("socket closed while sending") + view = view[sent:] + + def open_vhci(): fd = os.open("/dev/vhci", os.O_RDWR | os.O_CLOEXEC) os.write(fd, bytes([0xFF, HCI_PRIMARY])) @@ -98,10 +123,23 @@ def raw_proxy(left, right): continue if not data: return - key.data.sendall(data) + send_all_socket(key.data, data) + + +def reap_children(_signum, _frame): + while True: + try: + pid, _ = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + return + except InterruptedError: + continue + if pid == 0: + return def bridge(listen_host, listen_port, unix_path): + signal.signal(signal.SIGCHLD, reap_children) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((listen_host, listen_port)) @@ -142,7 +180,7 @@ def hci_proxy(vhci_fd, connection): packets, vhci_buf = take_h4_packets(vhci_buf) for packet in packets: if packet[:1] != b"\xff": - connection.sendall(packet) + send_all_socket(connection, packet) else: try: data = connection.recv(4096) From 4dbe592f68b40640cac389b91033f93a1758fd70 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 15:29:28 +0900 Subject: [PATCH 19/37] =?UTF-8?q?chore:=20Go=20lint=E8=A6=8F=E5=89=87?= =?UTF-8?q?=E3=82=92=E5=BC=B7=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .golangci.yml | 97 ++++++++++++++++++++++++++++++++++++ cmd/kbd-hid/main.go | 5 +- internal/bluez/agent.go | 6 ++- internal/bluez/properties.go | 6 ++- internal/config/config.go | 52 ++++++++++++------- internal/execx/runner.go | 7 ++- internal/hidapp/app.go | 20 ++++++-- internal/localapp/app.go | 10 +++- internal/rpiapp/app.go | 18 +++++-- internal/state/state.go | 8 +-- 10 files changed, 193 insertions(+), 36 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 67b824f..6dccbac 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,7 +2,104 @@ version: "2" linters: default: standard + enable: + # Input hygiene: catch confusing Unicode control bytes, stale loop copies, + # suspicious durations, unsafe type assertions, and wasted assignments. + - bidichk + - copyloopvar + - durationcheck + - forcetypeassert + - intrange + - mirror + - wastedassign + + # Error handling: prefer wrapped errors, Go 1.13 error checks, static + # sentinel errors, and safer JSON error handling. + - err113 + - errchkjson + - errname + - errorlint + - nilerr + - nilnil + - wrapcheck + + # Security and resource handling: catch unchecked response bodies and + # common security mistakes in production code. + - bodyclose + - gosec + + # Static bug checks: keep the standard analyzer set plus extra vet checks. + - govet + - ineffassign + - staticcheck + - unused + + # Code quality: remove redundant conversions, catch misspellings, enforce + # useful nolint comments, and find simple allocation and stdlib cleanups. + - exptostd + - gocritic + - makezero + - misspell + - nolintlint + - prealloc + - predeclared + - revive + - unconvert + - unparam + - usestdlibvars + + # Coverage of domain-like branches: useful for enum-style switches even in + # a small command line application. + - exhaustive + + # Complexity: keep very complex functions visible without forcing a large + # refactor of existing command dispatch in this PR. + - cyclop + - gocognit + + settings: + cyclop: + max-complexity: 30 + package-average: 10 + gocognit: + min-complexity: 45 + govet: + enable: + - nilness + - shadow + misspell: + locale: US + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + revive: + enable-default-rules: true + rules: + # The repository already has a compact internal package style. Requiring + # comments for every exported test helper and DBus method would add + # noise without catching behavior bugs. + - name: exported + disabled: true + - name: package-comments + disabled: true + - name: unused-parameter + - name: unused-receiver + + exclusions: + generated: strict + presets: + - comments + - common-false-positives + - std-error-handling + rules: + # Test fixtures intentionally use broad file permissions and temporary + # paths; production files still go through gosec. + - path: _test\.go + linters: + - gosec formatters: enable: + # Keep the existing formatter gate stricter than gofmt. - gofumpt diff --git a/cmd/kbd-hid/main.go b/cmd/kbd-hid/main.go index 7b6457b..16298b5 100644 --- a/cmd/kbd-hid/main.go +++ b/cmd/kbd-hid/main.go @@ -11,7 +11,8 @@ import ( func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - os.Exit(hidapp.App{Context: ctx}.Run(os.Args[1:])) + code := hidapp.App{Context: ctx}.Run(os.Args[1:]) + stop() + os.Exit(code) } diff --git a/internal/bluez/agent.go b/internal/bluez/agent.go index 7afe72c..6af71a5 100644 --- a/internal/bluez/agent.go +++ b/internal/bluez/agent.go @@ -16,7 +16,11 @@ func NewAgent(log io.Writer) *Agent { } func (agent *Agent) Export(conn *dbus.Conn) error { - return conn.Export(agent, AgentPath, AgentInterface) + if err := conn.Export(agent, AgentPath, AgentInterface); err != nil { + return fmt.Errorf("export pairing agent: %w", err) + } + + return nil } func (agent *Agent) Release() *dbus.Error { diff --git a/internal/bluez/properties.go b/internal/bluez/properties.go index 0929d8c..506f221 100644 --- a/internal/bluez/properties.go +++ b/internal/bluez/properties.go @@ -11,7 +11,11 @@ type propertiesObject struct { } func exportProperties(conn *dbus.Conn, path dbus.ObjectPath, getProperties func(string) (map[string]dbus.Variant, bool)) error { - return conn.Export(propertiesObject{getProperties: getProperties}, path, PropertiesInterface) + if err := conn.Export(propertiesObject{getProperties: getProperties}, path, PropertiesInterface); err != nil { + return fmt.Errorf("export properties for %s: %w", path, err) + } + + return nil } func (object propertiesObject) Get(interfaceName string, propertyName string) (dbus.Variant, *dbus.Error) { diff --git a/internal/config/config.go b/internal/config/config.go index b64836f..1612a84 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,24 @@ var ( macPattern = regexp.MustCompile(`^[0-9A-F]{2}(:[0-9A-F]{2}){5}$`) ) +var ( + errRPIHostRequired = errors.New("rpi.host is required") + errRPIHostWhitespace = errors.New("rpi.host must not contain whitespace") + errRPIUserRequired = errors.New("rpi.user is required") + errRPIUserWhitespace = errors.New("rpi.user must not contain whitespace") + errRPIRemoteCommandRequired = errors.New("rpi.remote_command is required") + errRPIRemoteCommandWhitespace = errors.New("rpi.remote_command must not contain whitespace") + errReconnectWaitNegative = errors.New("behavior.reconnect_wait_sec must not be negative") + errTargetNameRequired = errors.New("target name is required") + errTargetBluetoothMACInvalid = errors.New("target bluetooth_mac must be uppercase Bluetooth MAC address") + errHIDNameRequired = errors.New("hid.name is required") + errHIDNameControlCharacter = errors.New("hid.name must not contain control characters") + errHIDAppearanceInvalid = errors.New("hid.appearance must be keyboard") + errHIDRawDeviceRequired = errors.New("hid.hidraw_device is required") + errHIDRawDeviceControlChar = errors.New("hid.hidraw_device must not contain control characters") + errNameContainsInvalidChars = errors.New("name must contain only letters, digits, '_', '-', '.'") +) + type LocalConfig struct { RPI LocalRPIConfig `yaml:"rpi"` } @@ -109,7 +127,7 @@ func SaveRPI(path string, cfg RPIConfig) error { } dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("create config directory: %w", err) } @@ -135,7 +153,7 @@ func SaveRPI(path string, cfg RPIConfig) error { if err := file.Close(); err != nil { return fmt.Errorf("close temporary config: %w", err) } - if err := os.Chmod(tempPath, 0o644); err != nil { + if err := os.Chmod(tempPath, 0o600); err != nil { return fmt.Errorf("chmod temporary config: %w", err) } if err := os.Rename(tempPath, path); err != nil { @@ -147,29 +165,29 @@ func SaveRPI(path string, cfg RPIConfig) error { func (cfg LocalConfig) Validate() error { if strings.TrimSpace(cfg.RPI.Host) == "" { - return errors.New("rpi.host is required") + return errRPIHostRequired } if hasSpace(cfg.RPI.Host) { - return errors.New("rpi.host must not contain whitespace") + return errRPIHostWhitespace } if strings.TrimSpace(cfg.RPI.User) == "" { - return errors.New("rpi.user is required") + return errRPIUserRequired } if hasSpace(cfg.RPI.User) { - return errors.New("rpi.user must not contain whitespace") + return errRPIUserWhitespace } if strings.TrimSpace(cfg.RPI.RemoteCommand) == "" { - return errors.New("rpi.remote_command is required") + return errRPIRemoteCommandRequired } if hasSpace(cfg.RPI.RemoteCommand) { - return errors.New("rpi.remote_command must not contain whitespace") + return errRPIRemoteCommandWhitespace } return nil } func (cfg RPIConfig) Validate() error { if cfg.Behavior.ReconnectWaitSec < 0 { - return errors.New("behavior.reconnect_wait_sec must not be negative") + return errReconnectWaitNegative } if err := cfg.HID.Validate(); err != nil { return err @@ -188,10 +206,10 @@ func (cfg RPIConfig) Validate() error { func ValidateTarget(field string, target Target) error { if strings.TrimSpace(target.Name) == "" { - return fmt.Errorf("%s.name is required", field) + return fmt.Errorf("%w: %s", errTargetNameRequired, field) } if !macPattern.MatchString(target.BluetoothMAC) { - return fmt.Errorf("%s.bluetooth_mac must be uppercase Bluetooth MAC address", field) + return fmt.Errorf("%w: %s", errTargetBluetoothMACInvalid, field) } return nil @@ -214,19 +232,19 @@ func (hid HIDConfig) Validate() error { return err } if strings.TrimSpace(hid.Name) == "" { - return errors.New("hid.name is required") + return errHIDNameRequired } if hasControl(hid.Name) { - return errors.New("hid.name must not contain control characters") + return errHIDNameControlCharacter } if hid.Appearance != HIDAppearanceKeyboard { - return errors.New("hid.appearance must be keyboard") + return errHIDAppearanceInvalid } if strings.TrimSpace(hid.HIDRawDevice) == "" { - return errors.New("hid.hidraw_device is required") + return errHIDRawDeviceRequired } if hasControl(hid.HIDRawDevice) { - return errors.New("hid.hidraw_device must not contain control characters") + return errHIDRawDeviceControlChar } return nil @@ -264,7 +282,7 @@ func loadYAML(path string, out any) error { func validateName(field string, value string) error { if !namePattern.MatchString(value) { - return fmt.Errorf("%s must contain only letters, digits, '_', '-', '.'", field) + return fmt.Errorf("%w: %s", errNameContainsInvalidChars, field) } return nil diff --git a/internal/execx/runner.go b/internal/execx/runner.go index fa4df04..150ef9a 100644 --- a/internal/execx/runner.go +++ b/internal/execx/runner.go @@ -3,6 +3,7 @@ package execx import ( "context" "errors" + "fmt" "io" "os/exec" ) @@ -19,7 +20,11 @@ func (OSRunner) Run(ctx context.Context, stdin io.Reader, stdout io.Writer, stde cmd.Stdout = stdout cmd.Stderr = stderr - return cmd.Run() + if err := cmd.Run(); err != nil { + return fmt.Errorf("run %s: %w", name, err) + } + + return nil } type exitCoder interface { diff --git a/internal/hidapp/app.go b/internal/hidapp/app.go index 7ddeb33..8da05eb 100644 --- a/internal/hidapp/app.go +++ b/internal/hidapp/app.go @@ -2,6 +2,7 @@ package hidapp import ( "context" + "errors" "fmt" "io" "os" @@ -12,6 +13,11 @@ import ( "github.com/RarkHopper/RpiKeyboardSwitcher/internal/input" ) +var ( + errUnknownFlag = errors.New("unknown flag") + errFlagRequiresValue = errors.New("flag requires a value") +) + type HIDDaemon interface { Run(ctx context.Context, options bluez.DaemonOptions) error } @@ -84,7 +90,7 @@ func parseArgs(args []string) (cliOptions, error) { case strings.HasPrefix(arg, "--config="): options.configPath = strings.TrimPrefix(arg, "--config=") case strings.HasPrefix(arg, "-"): - return cliOptions{}, fmt.Errorf("unknown flag: %s", arg) + return cliOptions{}, fmt.Errorf("%w: %s", errUnknownFlag, arg) case options.command == "": options.command = arg default: @@ -98,7 +104,7 @@ func parseArgs(args []string) (cliOptions, error) { func requireFlagValue(args []string, index int, name string) (string, int, error) { next := index + 1 if next >= len(args) || strings.HasPrefix(args[next], "-") { - return "", index, fmt.Errorf("%s requires a value", name) + return "", index, fmt.Errorf("%w: %s", errFlagRequiresValue, name) } return args[next], next, nil @@ -188,10 +194,10 @@ func (app App) daemonOptions(configPath string, cfg config.RPIConfig, descriptor } } -func (app App) cachePeer(configPath string, peer bluez.Peer) error { +func (App) cachePeer(configPath string, peer bluez.Peer) error { cfg, err := config.LoadRPI(configPath) if err != nil { - return err + return fmt.Errorf("load Raspberry Pi config: %w", err) } if cfg.Targets == nil { cfg.Targets = map[string]config.Target{} @@ -208,7 +214,11 @@ func (app App) cachePeer(configPath string, peer bluez.Peer) error { BluetoothMAC: peer.BluetoothMAC, } - return config.SaveRPI(configPath, cfg) + if err := config.SaveRPI(configPath, cfg); err != nil { + return fmt.Errorf("save Raspberry Pi config: %w", err) + } + + return nil } func uniqueTargetKey(targets map[string]config.Target, name string) string { diff --git a/internal/localapp/app.go b/internal/localapp/app.go index 83d11af..4ecac61 100644 --- a/internal/localapp/app.go +++ b/internal/localapp/app.go @@ -100,7 +100,8 @@ func (app App) switchTarget(cfg config.LocalConfig, target string) int { } func (app App) runSSH(cfg config.LocalConfig, args ...string) int { - sshArgs := []string{cfg.RPI.User + "@" + cfg.RPI.Host, cfg.RPI.RemoteCommand} + sshArgs := make([]string, 0, 2+len(args)) + sshArgs = append(sshArgs, cfg.RPI.User+"@"+cfg.RPI.Host, cfg.RPI.RemoteCommand) sshArgs = append(sshArgs, args...) if err := app.runner().Run(app.context(), app.stdin(), app.stdout(), app.stderr(), "ssh", sshArgs...); err != nil { @@ -118,7 +119,12 @@ func resolveConfigPath(path string) (string, error) { return envPath, nil } - return config.DefaultLocalConfigPath() + defaultPath, err := config.DefaultLocalConfigPath() + if err != nil { + return "", fmt.Errorf("resolve default local config path: %w", err) + } + + return defaultPath, nil } func printLocalCompletion(stdout io.Writer, shell string) int { diff --git a/internal/rpiapp/app.go b/internal/rpiapp/app.go index 6b88af5..4e26d3f 100644 --- a/internal/rpiapp/app.go +++ b/internal/rpiapp/app.go @@ -2,6 +2,7 @@ package rpiapp import ( "context" + "errors" "flag" "fmt" "io" @@ -15,6 +16,11 @@ import ( "github.com/RarkHopper/RpiKeyboardSwitcher/internal/state" ) +var ( + errMissingTarget = errors.New("missing target") + errUnknownSwitchOption = errors.New("unknown switch option") +) + type App struct { Runner execx.Runner Context context.Context @@ -123,18 +129,18 @@ type switchRequest struct { func parseSwitchRequest(args []string) (switchRequest, error) { if len(args) == 0 { - return switchRequest{}, fmt.Errorf("missing target") + return switchRequest{}, errMissingTarget } req := switchRequest{target: args[0]} if err := config.ValidateName("switch target", req.target); err != nil { - return switchRequest{}, err + return switchRequest{}, fmt.Errorf("validate switch target: %w", err) } if len(args) == 1 { return req, nil } - return switchRequest{}, fmt.Errorf("unknown switch option: %s", args[1]) + return switchRequest{}, fmt.Errorf("%w: %s", errUnknownSwitchOption, args[1]) } func (app App) switchTarget(configPath string, statePath string, req switchRequest) int { @@ -376,7 +382,11 @@ _kbd_rpi "$@" ` func (app App) runBluetoothctl(args ...string) error { - return app.runner().Run(app.context(), app.stdin(), app.stdout(), app.stderr(), "bluetoothctl", args...) + if err := app.runner().Run(app.context(), app.stdin(), app.stdout(), app.stderr(), "bluetoothctl", args...); err != nil { + return fmt.Errorf("run bluetoothctl: %w", err) + } + + return nil } func (app App) runner() execx.Runner { diff --git a/internal/state/state.go b/internal/state/state.go index fa2de77..3a17280 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -9,6 +9,8 @@ import ( "time" ) +var errIncompleteState = errors.New("state is incomplete") + type State struct { Target string `json:"target"` BluetoothMAC string `json:"bluetooth_mac"` @@ -32,15 +34,15 @@ func Load(path string) (State, bool, error) { return State{}, true, fmt.Errorf("decode state: %w", err) } if current.Target == "" || current.BluetoothMAC == "" || current.UpdatedAt.IsZero() { - return State{}, true, errors.New("state is incomplete") + return State{}, true, errIncompleteState } return current, true, nil } func Save(path string, current State) (err error) { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("create state directory: %w", err) + if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o750); mkdirErr != nil { + return fmt.Errorf("create state directory: %w", mkdirErr) } file, err := os.Create(path) From 3c18afef631ce81104b2ace59f8f0168ab25cc30 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 16:06:03 +0900 Subject: [PATCH 20/37] =?UTF-8?q?fix:=20Linux=20HIDraw=E8=AA=AD=E3=81=BF?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E3=81=AElint=E9=81=95=E5=8F=8D=E3=82=92?= =?UTF-8?q?=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/input/forwarder_linux.go | 38 +++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/internal/input/forwarder_linux.go b/internal/input/forwarder_linux.go index 3c22d45..67a1f4a 100644 --- a/internal/input/forwarder_linux.go +++ b/internal/input/forwarder_linux.go @@ -4,12 +4,22 @@ package input import ( "context" + "errors" "fmt" "io" + "math" "golang.org/x/sys/unix" ) +var ( + errHIDRawClosed = errors.New("hidraw device closed") + errEmptyHIDRawReportDescriptor = errors.New("hidraw device returned empty report descriptor") + errHIDRawReportDescriptorTooLarge = errors.New("hidraw report descriptor is too large") + errFileDescriptorOutOfRange = errors.New("hidraw file descriptor is out of range") + errDescriptorSizeOutOfRange = errors.New("hidraw report descriptor size is out of range") +) + type Forwarder struct { Device string Log io.Writer @@ -42,6 +52,10 @@ func (forwarder Forwarder) Run(ctx context.Context, send func(Report) error) err } logf(forwarder.Log, "Forwarding HID reports from %s with %d byte report descriptor\n", forwarder.Device, len(descriptor.ReportMap)) + pollFD, err := pollFileDescriptor(fd) + if err != nil { + return err + } buffer := make([]byte, 4096) for { select { @@ -50,8 +64,8 @@ func (forwarder Forwarder) Run(ctx context.Context, send func(Report) error) err default: } - ready, err := unix.Poll([]unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}, 250) - if err == unix.EINTR { + ready, err := unix.Poll([]unix.PollFd{{Fd: pollFD, Events: unix.POLLIN}}, 250) + if errors.Is(err, unix.EINTR) { continue } if err != nil { @@ -62,14 +76,14 @@ func (forwarder Forwarder) Run(ctx context.Context, send func(Report) error) err } n, err := unix.Read(fd, buffer) - if err == unix.EINTR || err == unix.EAGAIN { + if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) { continue } if err != nil { return fmt.Errorf("read hidraw device %s: %w", forwarder.Device, err) } if n == 0 { - return fmt.Errorf("hidraw device %s closed", forwarder.Device) + return fmt.Errorf("%w: %s", errHIDRawClosed, forwarder.Device) } report, ok := descriptor.Report(buffer[:n]) @@ -88,20 +102,30 @@ func readDescriptor(fd int, device string) (Descriptor, error) { return Descriptor{}, fmt.Errorf("read hidraw descriptor size %s: %w", device, err) } if size <= 0 { - return Descriptor{}, fmt.Errorf("hidraw device %s returned empty report descriptor", device) + return Descriptor{}, fmt.Errorf("%w: %s", errEmptyHIDRawReportDescriptor, device) + } + if size > math.MaxUint32 { + return Descriptor{}, fmt.Errorf("%w: %s has %d bytes", errDescriptorSizeOutOfRange, device, size) } raw := unix.HIDRawReportDescriptor{Size: uint32(size)} if err := unix.IoctlHIDGetDesc(fd, &raw); err != nil { return Descriptor{}, fmt.Errorf("read hidraw report descriptor %s: %w", device, err) } - if int(raw.Size) > len(raw.Value) { - return Descriptor{}, fmt.Errorf("hidraw report descriptor %s is too large: %d bytes", device, raw.Size) + if raw.Size > uint32(len(raw.Value)) { + return Descriptor{}, fmt.Errorf("%w: %s has %d bytes", errHIDRawReportDescriptorTooLarge, device, raw.Size) } return ParseDescriptor(raw.Value[:raw.Size]) } +func pollFileDescriptor(fd int) (int32, error) { + if fd < 0 || fd > math.MaxInt32 { + return 0, fmt.Errorf("%w: %d", errFileDescriptorOutOfRange, fd) + } + return int32(fd), nil +} + func logf(writer io.Writer, format string, args ...any) { if writer == nil { return From 3fb7784b46c59cb0a7b5f220948109eb7a15534c Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 16:06:16 +0900 Subject: [PATCH 21/37] =?UTF-8?q?build:=20Python=20tools=E3=82=92uv?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=81=A8=E5=9E=8B=E6=A4=9C=E6=9F=BB=E3=81=AB?= =?UTF-8?q?=E7=A7=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 11 ++- Makefile | 32 +++++-- Vagrantfile | 46 ++++++++--- scripts/hid-e2e.sh | 41 ++++++--- tools/.gitignore | 4 + tools/bluez-agent.py | 76 +++++++++++------ tools/bluez-pair.py | 174 +++++++++++++++++++++++++++------------ tools/hci-proxy.py | 54 ++++++++---- tools/lib/bluez_dbus.py | 58 +++++++++++++ tools/pyproject.toml | 77 +++++++++++++++++ tools/uv.lock | 154 ++++++++++++++++++++++++++++++++++ 11 files changed, 603 insertions(+), 124 deletions(-) create mode 100644 tools/.gitignore create mode 100644 tools/lib/bluez_dbus.py create mode 100644 tools/pyproject.toml create mode 100644 tools/uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7cc20e..c2ff1ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,14 +25,23 @@ jobs: go.mod go.sum + - name: Setup uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.9.22" + enable-cache: true + - name: Install check dependencies run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential \ + gobject-introspection \ + libcairo2-dev \ + libdbus-1-dev \ libfuse3-dev \ + libgirepository-2.0-dev \ pkg-config \ - python3 \ ruby \ shellcheck diff --git a/Makefile b/Makefile index 4f95984..fadfa23 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-check vagrant-check cuse-check ci e2e +.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check vagrant-check cuse-check ci e2e GOLANGCI_LINT := go tool golangci-lint VAGRANT ?= vagrant @@ -7,11 +7,20 @@ LOCAL_GOARCH ?= $(shell go env GOARCH) RPI_GOOS ?= linux RPI_GOARCH ?= arm64 SHELLCHECK ?= shellcheck -PYTHON ?= python3 +UV ?= uv +TOOLS_PYTHON ?= 3.12 +TOOLS_DIR := tools +TOOLS_UV := $(UV) --project $(TOOLS_DIR) --directory $(TOOLS_DIR) SHELL_SCRIPTS := scripts/hid-e2e.sh -PYTHON_TOOLS := tools/hci-proxy.py tools/bluez-agent.py tools/bluez-pair.py +PYTHON_TOOLS := hci-proxy.py bluez-agent.py bluez-pair.py +PYTHON_SOURCES := $(PYTHON_TOOLS) lib CUSE_TOOL := tools/hidraw-cuse.c +all: build + +clean: + rm -rf dist tools/.mypy_cache tools/.ruff_cache tools/.venv tools/__pycache__ tools/lib/__pycache__ + build: mkdir -p dist GOOS=$(LOCAL_GOOS) GOARCH=$(LOCAL_GOARCH) go build -o dist/kbd ./cmd/kbd @@ -20,9 +29,11 @@ build: fmt: $(GOLANGCI_LINT) fmt + $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) ruff format $(PYTHON_SOURCES) fmt-check: $(GOLANGCI_LINT) fmt --diff + $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) ruff format --check $(PYTHON_SOURCES) lint: $(GOLANGCI_LINT) run ./... @@ -39,7 +50,7 @@ test: race-test: go test -race ./... -check: lint-config fmt-check lint vet test +check: lint-config fmt-check lint vet test python-check mod-check: go mod tidy @@ -49,8 +60,17 @@ script-check: bash -n $(SHELL_SCRIPTS) $(SHELLCHECK) $(SHELL_SCRIPTS) +python-fmt: + $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) ruff format $(PYTHON_SOURCES) + python-check: - $(PYTHON) -c 'import pathlib, sys; [compile(pathlib.Path(path).read_text(), path, "exec") for path in sys.argv[1:]]' $(PYTHON_TOOLS) + $(TOOLS_UV) lock --check --python $(TOOLS_PYTHON) --managed-python + $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) ruff check $(PYTHON_SOURCES) + $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) mypy $(PYTHON_SOURCES) + $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) python -m compileall -q $(PYTHON_SOURCES) + +python-runtime-check: + $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) --extra runtime --no-dev python -c 'import dbus; import gi; from gi.repository import GLib; print(GLib.MainLoop)' vagrant-check: ruby -c Vagrantfile @@ -59,7 +79,7 @@ cuse-check: pkg-config --exists fuse3 cc -Wall -Wextra -fsyntax-only $$(pkg-config --cflags fuse3) $(CUSE_TOOL) -ci: check race-test build mod-check script-check python-check vagrant-check cuse-check +ci: check race-test build mod-check script-check python-runtime-check vagrant-check cuse-check e2e: VAGRANT=$(VAGRANT) scripts/hid-e2e.sh diff --git a/Vagrantfile b/Vagrantfile index be83dfb..042c1bd 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -1,5 +1,7 @@ GO_VERSION = "1.26.3" GO_LINUX_ARM64_SHA256 = "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565" +UV_VERSION = "0.9.22" +UV_LINUX_ARM64_SHA256 = "2f8716c407d5da21b8a3e8609ed358147216aaab28b96b1d6d7f48e9bcc6254e" def provision_e2e_vm(config) config.vm.synced_folder ".", "/vagrant" @@ -16,31 +18,49 @@ def provision_e2e_vm(config) curl \ dbus \ git \ + gobject-introspection \ kmod \ + libcairo2-dev \ + libdbus-1-dev \ libfuse3-dev \ + libgirepository-2.0-dev \ pkg-config \ procps \ - python3 \ "linux-modules-extra-$(uname -r)" + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + go_archive="go#{GO_VERSION}.linux-arm64.tar.gz" - if ! /usr/local/go/bin/go version 2>/dev/null | grep -q "go#{GO_VERSION}"; then - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT - curl -fsSL "https://go.dev/dl/${go_archive}" -o "${tmp_dir}/${go_archive}" - printf '%s %s\n' '#{GO_LINUX_ARM64_SHA256}' "${tmp_dir}/${go_archive}" | sha256sum -c - - rm -rf /usr/local/go - tar -C /usr/local -xzf "${tmp_dir}/${go_archive}" - ln -sf /usr/local/go/bin/go /usr/local/bin/go - ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt - fi + curl -fsSL "https://go.dev/dl/${go_archive}" -o "${tmp_dir}/${go_archive}" + printf '%s %s\n' '#{GO_LINUX_ARM64_SHA256}' "${tmp_dir}/${go_archive}" | sha256sum -c - + rm -rf /usr/local/go + tar -C /usr/local -xzf "${tmp_dir}/${go_archive}" + ln -sf /usr/local/go/bin/go /usr/local/bin/go + ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt + + uv_archive="uv-aarch64-unknown-linux-gnu.tar.gz" + curl -fsSL "https://github.com/astral-sh/uv/releases/download/#{UV_VERSION}/${uv_archive}" -o "${tmp_dir}/${uv_archive}" + printf '%s %s\n' '#{UV_LINUX_ARM64_SHA256}' "${tmp_dir}/${uv_archive}" | sha256sum -c - + tar -C "$tmp_dir" -xzf "${tmp_dir}/${uv_archive}" + install -m 0755 "${tmp_dir}/uv-aarch64-unknown-linux-gnu/uv" /usr/local/bin/uv + install -m 0755 "${tmp_dir}/uv-aarch64-unknown-linux-gnu/uvx" /usr/local/bin/uvx + + sudo -u vagrant env UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + /usr/local/bin/uv --project /vagrant/tools --directory /vagrant/tools sync \ + --locked --managed-python --python 3.12 --extra runtime --no-dev - printf 'export PATH=/usr/local/go/bin:$PATH\\n' >/etc/profile.d/go.sh + cat >/etc/profile.d/go.sh <<'PROFILE' +export PATH=/usr/local/go/bin:$PATH +PROFILE chmod 0644 /etc/profile.d/go.sh git config --global --add safe.directory /vagrant sudo -u vagrant git config --global --add safe.directory /vagrant - printf 'hci_vhci\\ncuse\\n' >/etc/modules-load.d/rpi-keyboard-switcher-e2e.conf + cat >/etc/modules-load.d/rpi-keyboard-switcher-e2e.conf <<'MODULES' +hci_vhci +cuse +MODULES modprobe hci_vhci modprobe cuse test -e /dev/vhci diff --git a/scripts/hid-e2e.sh b/scripts/hid-e2e.sh index 4197987..1199c6b 100755 --- a/scripts/hid-e2e.sh +++ b/scripts/hid-e2e.sh @@ -71,9 +71,9 @@ pkill -x btvirt >/dev/null 2>&1 || true pkill -x btmon >/dev/null 2>&1 || true pkill -x kbd-hid >/dev/null 2>&1 || true pkill -x hidraw-cuse >/dev/null 2>&1 || true -pkill -f '/vagrant/tools/hci-proxy.py' >/dev/null 2>&1 || true -pkill -f '/vagrant/tools/bluez-agent.py' >/dev/null 2>&1 || true -pkill -f '/vagrant/tools/bluez-pair.py' >/dev/null 2>&1 || true +pkill -f '(^|[ /])hci-proxy\.py( |$)' >/dev/null 2>&1 || true +pkill -f '(^|[ /])bluez-agent\.py( |$)' >/dev/null 2>&1 || true +pkill -f '(^|[ /])bluez-pair\.py( |$)' >/dev/null 2>&1 || true sleep 1 rmmod hci_vhci >/dev/null 2>&1 || true modprobe hci_vhci @@ -111,7 +111,7 @@ btmgmt_cmd() { printf 'select 0\n' printf '%s\n' "$1" printf 'quit\n' - } | script -qfec btmgmt /dev/null >>/tmp/btmgmt.log 2>&1 || true + } | script -qfec btmgmt /dev/null >>/tmp/btmgmt.log 2>&1 } btmgmt_cmd 'power off' @@ -134,6 +134,11 @@ rm -f /tmp/hid-e2e-events.log /tmp/hid-e2e-reader.log /tmp/bluetoothctl-pair.log rm -f /tmp/bt-server-le btvirt -s >/tmp/btvirt.log 2>&1 & +tools_uv() { + UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + uv --project /vagrant/tools --directory /vagrant/tools run \ + --locked --managed-python --python 3.12 --extra runtime --no-dev "$@" +} for _ in $(seq 1 100); do [ -S /tmp/bt-server-le ] && break @@ -141,18 +146,23 @@ for _ in $(seq 1 100); do done [ -S /tmp/bt-server-le ] -python3 /vagrant/tools/hci-proxy.py bridge \ +tools_uv python hci-proxy.py bridge \ --listen-host 0.0.0.0 \ --port 45550 \ --unix-path /tmp/bt-server-le >/tmp/hci-bridge.log 2>&1 & -python3 /vagrant/tools/hci-proxy.py client 127.0.0.1 --port 45550 >/tmp/hci-client.log 2>&1 & +tools_uv python hci-proxy.py client 127.0.0.1 --port 45550 >/tmp/hci-client.log 2>&1 & REMOTE start_bluez_adapter central vm_sudo central <<'REMOTE' set -euo pipefail -python3 /vagrant/tools/bluez-agent.py --capability KeyboardDisplay >/tmp/bluez-agent.log 2>&1 & +tools_uv() { + UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + uv --project /vagrant/tools --directory /vagrant/tools run \ + --locked --managed-python --python 3.12 --extra runtime --no-dev "$@" +} +tools_uv python bluez-agent.py --capability KeyboardDisplay >/tmp/bluez-agent.log 2>&1 & for _ in $(seq 1 50); do grep -q '^agent registered ' /tmp/bluez-agent.log 2>/dev/null && break sleep 0.1 @@ -171,7 +181,12 @@ rm -f /tmp/hidraw.path /tmp/send-report /tmp/kbd-e2e.yaml /tmp/kbd-hid.log \ /tmp/hidraw-cuse.log /tmp/bluetoothd.log /tmp/hci-client.log /tmp/btmgmt.log modprobe cuse -python3 /vagrant/tools/hci-proxy.py client "${central_host}" --port "${central_port}" >/tmp/hci-client.log 2>&1 & +tools_uv() { + UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + uv --project /vagrant/tools --directory /vagrant/tools run \ + --locked --managed-python --python 3.12 --extra runtime --no-dev "$@" +} +tools_uv python hci-proxy.py client "${central_host}" --port "${central_port}" >/tmp/hci-client.log 2>&1 & REMOTE start_bluez_adapter peripheral @@ -233,7 +248,10 @@ pair_central() { set -euo pipefail { - python3 /vagrant/tools/bluez-pair.py --adapter hci0 "${mac}" + UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + uv --project /vagrant/tools --directory /vagrant/tools run \ + --locked --managed-python --python 3.12 --extra runtime --no-dev \ + python bluez-pair.py --adapter hci0 "${mac}" } >/tmp/bluetoothctl-pair.log 2>&1 grep -q 'Paired: yes' /tmp/bluetoothctl-pair.log @@ -284,7 +302,10 @@ hidraw_path="$(find /sys/devices/virtual/misc/uhid -maxdepth 3 -type d -name 'hi hidraw_path="/dev/$(basename "$hidraw_path")" (timeout 25s btmon >/tmp/btmon-report.log 2>&1) & -timeout 22s python3 - "$event_path" "$hidraw_path" >/tmp/hid-e2e-events.log 2>/tmp/hid-e2e-reader.log <<'PY' & +UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + timeout 22s uv --project /vagrant/tools --directory /vagrant/tools run \ + --locked --managed-python --python 3.12 --no-dev \ + python - "$event_path" "$hidraw_path" >/tmp/hid-e2e-events.log 2>/tmp/hid-e2e-reader.log <<'PY' & import binascii import os import select diff --git a/tools/.gitignore b/tools/.gitignore new file mode 100644 index 0000000..569ce23 --- /dev/null +++ b/tools/.gitignore @@ -0,0 +1,4 @@ +.mypy_cache/ +.ruff_cache/ +.venv/ +__pycache__/ diff --git a/tools/bluez-agent.py b/tools/bluez-agent.py index 91ae7a1..9c511ae 100644 --- a/tools/bluez-agent.py +++ b/tools/bluez-agent.py @@ -1,13 +1,26 @@ #!/usr/bin/env python3 +from __future__ import annotations + import argparse import signal import sys +from types import FrameType +from typing import cast import dbus import dbus.mainloop.glib import dbus.service from gi.repository import GLib +from lib.bluez_dbus import ( + DBusConnection, + GMainLoop, + bluez_object, + call_dbus, + call_loop, + dbus_interface, + system_bus, +) BLUEZ = "org.bluez" AGENT_MANAGER = "org.bluez.AgentManager1" @@ -15,71 +28,88 @@ AGENT_PATH = "/com/rarkhopper/RpiKeyboardSwitcher/testagent" +class AgentManager: + def __init__(self, bus: DBusConnection) -> None: + self._proxy = dbus_interface(bluez_object(bus, "/org/bluez"), AGENT_MANAGER) + + def register_agent(self, path: str, capability: str) -> None: + call_dbus(self._proxy, "RegisterAgent", path, capability) + + def request_default_agent(self, path: str) -> None: + call_dbus(self._proxy, "RequestDefaultAgent", path) + + def unregister_agent(self, path: str) -> None: + call_dbus(self._proxy, "UnregisterAgent", path) + + class Agent(dbus.service.Object): @dbus.service.method(AGENT, in_signature="", out_signature="") - def Release(self): + def Release(self) -> None: print("agent released", flush=True) - loop.quit() + call_loop(loop, "quit") @dbus.service.method(AGENT, in_signature="o", out_signature="s") - def RequestPinCode(self, device): + def RequestPinCode(self, device: str) -> str: print(f"request pin code device={device}", flush=True) return "000000" @dbus.service.method(AGENT, in_signature="os", out_signature="") - def DisplayPinCode(self, device, pincode): + def DisplayPinCode(self, device: str, pincode: str) -> None: print(f"display pin code device={device} pincode={pincode}", flush=True) @dbus.service.method(AGENT, in_signature="ouq", out_signature="") - def DisplayPasskey(self, device, passkey, entered): - print(f"display passkey device={device} passkey={passkey:06d} entered={entered}", flush=True) + def DisplayPasskey(self, device: str, passkey: int, entered: int) -> None: + print( + f"display passkey device={device} passkey={passkey:06d} entered={entered}", flush=True + ) @dbus.service.method(AGENT, in_signature="o", out_signature="u") - def RequestPasskey(self, device): + def RequestPasskey(self, device: str) -> int: print(f"request passkey device={device}", flush=True) - return dbus.UInt32(0) + return cast(int, dbus.UInt32(0)) @dbus.service.method(AGENT, in_signature="ou", out_signature="") - def RequestConfirmation(self, device, passkey): + def RequestConfirmation(self, device: str, passkey: int) -> None: print(f"confirm device={device} passkey={passkey:06d}", flush=True) @dbus.service.method(AGENT, in_signature="o", out_signature="") - def RequestAuthorization(self, device): + def RequestAuthorization(self, device: str) -> None: print(f"authorize pairing device={device}", flush=True) @dbus.service.method(AGENT, in_signature="os", out_signature="") - def AuthorizeService(self, device, uuid): + def AuthorizeService(self, device: str, uuid: str) -> None: print(f"authorize service device={device} uuid={uuid}", flush=True) @dbus.service.method(AGENT, in_signature="", out_signature="") - def Cancel(self): + def Cancel(self) -> None: print("request canceled", flush=True) -def main(): +def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--capability", default="KeyboardDisplay") args = parser.parse_args() + capability = cast(str, args.capability) dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) - bus = dbus.SystemBus() + bus = system_bus() Agent(bus, AGENT_PATH) - manager = dbus.Interface(bus.get_object(BLUEZ, "/org/bluez"), AGENT_MANAGER) - manager.RegisterAgent(AGENT_PATH, args.capability) - manager.RequestDefaultAgent(AGENT_PATH) - print(f"agent registered path={AGENT_PATH} capability={args.capability}", flush=True) + manager = AgentManager(bus) + manager.register_agent(AGENT_PATH, capability) + manager.request_default_agent(AGENT_PATH) + print(f"agent registered path={AGENT_PATH} capability={capability}", flush=True) - def stop(_signum, _frame): - manager.UnregisterAgent(AGENT_PATH) - loop.quit() + def stop(_signum: int, _frame: FrameType | None) -> None: + manager.unregister_agent(AGENT_PATH) + call_loop(loop, "quit") signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) - loop.run() + call_loop(loop, "run") -loop = GLib.MainLoop() +loop = GMainLoop(GLib.MainLoop()) if __name__ == "__main__": diff --git a/tools/bluez-pair.py b/tools/bluez-pair.py index 389abfe..f60f22d 100644 --- a/tools/bluez-pair.py +++ b/tools/bluez-pair.py @@ -1,61 +1,130 @@ #!/usr/bin/env python3 +from __future__ import annotations + import argparse +import contextlib import sys import time +from dataclasses import dataclass +from typing import cast import dbus +from lib.bluez_dbus import ( + DBusConnection, + DBusProxy, + DBusValue, + ManagedObjects, + Properties, + bluez_object, + call_dbus, + call_dbus_with_timeout, + dbus_interface, + dbus_true, + system_bus, +) -BLUEZ = "org.bluez" OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager" PROPERTIES = "org.freedesktop.DBus.Properties" ADAPTER = "org.bluez.Adapter1" DEVICE = "org.bluez.Device1" -def managed_objects(bus): - manager = dbus.Interface(bus.get_object(BLUEZ, "/"), OBJECT_MANAGER) - return manager.GetManagedObjects() +@dataclass(frozen=True) +class DeviceSnapshot: + path: str + props: Properties + + +class BlueZClient: + def __init__(self, bus: DBusConnection) -> None: + self._bus = bus + + @classmethod + def from_system_bus(cls) -> BlueZClient: + return cls(system_bus()) + + def _interface(self, path: str, interface: str) -> DBusProxy: + return dbus_interface(bluez_object(self._bus, path), interface) + + def managed_objects(self) -> ManagedObjects: + manager = self._interface("/", OBJECT_MANAGER) + return cast(ManagedObjects, call_dbus(manager, "GetManagedObjects")) + + def adapter(self, name: str) -> AdapterProxy: + return AdapterProxy(self._interface(adapter_path(name), ADAPTER)) + + def device(self, path: str) -> DeviceProxy: + return DeviceProxy(self._interface(path, DEVICE)) + + def get_props(self, path: str, interface: str) -> Properties: + props = self._interface(path, PROPERTIES) + return cast(Properties, call_dbus(props, "GetAll", interface)) + + def set_prop(self, path: str, interface: str, name: str, value: DBusValue) -> None: + props = self._interface(path, PROPERTIES) + call_dbus(props, "Set", interface, name, value) + + +class AdapterProxy: + def __init__(self, proxy: DBusProxy) -> None: + self._proxy = proxy + def remove_device(self, path: str) -> None: + call_dbus(self._proxy, "RemoveDevice", path) -def adapter_path(adapter): + def start_discovery(self) -> None: + call_dbus(self._proxy, "StartDiscovery") + + def stop_discovery(self) -> None: + call_dbus(self._proxy, "StopDiscovery") + + +class DeviceProxy: + def __init__(self, proxy: DBusProxy) -> None: + self._proxy = proxy + + def pair(self, timeout: float) -> None: + call_dbus_with_timeout(self._proxy, "Pair", timeout) + + def connect(self, timeout: float) -> None: + call_dbus_with_timeout(self._proxy, "Connect", timeout) + + +def adapter_path(adapter: str) -> str: return f"/org/bluez/{adapter}" -def find_device(bus, address): +def find_device(client: BlueZClient, address: str) -> DeviceSnapshot | None: want = address.upper() - for path, interfaces in managed_objects(bus).items(): + for path, interfaces in client.managed_objects().items(): props = interfaces.get(DEVICE) if props and str(props.get("Address", "")).upper() == want: - return path, props - return None, None + return DeviceSnapshot(path, props) + return None -def wait_for_device(bus, address, timeout): +def wait_for_device(client: BlueZClient, address: str, timeout: float) -> DeviceSnapshot: deadline = time.monotonic() + timeout while time.monotonic() < deadline: - path, props = find_device(bus, address) - if path: - return path, props + device = find_device(client, address) + if device is not None: + return device time.sleep(0.2) raise TimeoutError(f"device {address} was not discovered") -def get_props(bus, path, interface): - obj = bus.get_object(BLUEZ, path) - return dbus.Interface(obj, PROPERTIES).GetAll(interface) - - -def set_prop(bus, path, interface, name, value): - obj = bus.get_object(BLUEZ, path) - dbus.Interface(obj, PROPERTIES).Set(interface, name, value) +def bool_text(value: DBusValue) -> str: + return "yes" if bool(value) else "no" -def bool_text(value): - return "yes" if bool(value) else "no" +def prop_text(value: DBusValue) -> str: + if isinstance(value, bytes): + return value.decode() + return str(value) -def main(): +def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("address") parser.add_argument("--adapter", default="hci0") @@ -63,52 +132,51 @@ def main(): parser.add_argument("--connect-timeout", type=float, default=45) args = parser.parse_args() - bus = dbus.SystemBus() - adapter = adapter_path(args.adapter) - adapter_obj = bus.get_object(BLUEZ, adapter) - adapter_iface = dbus.Interface(adapter_obj, ADAPTER) + address = cast(str, args.address) + adapter_name = cast(str, args.adapter) + discover_timeout = cast(float, args.discover_timeout) + connect_timeout = cast(float, args.connect_timeout) + + client = BlueZClient.from_system_bus() + adapter = adapter_path(adapter_name) + adapter_proxy = client.adapter(adapter_name) - existing_path, _ = find_device(bus, args.address) - if existing_path: - try: - adapter_iface.RemoveDevice(existing_path) - except dbus.DBusException: - pass + existing_device = find_device(client, address) + if existing_device is not None: + with contextlib.suppress(dbus.DBusException): + adapter_proxy.remove_device(existing_device.path) - set_prop(bus, adapter, ADAPTER, "Powered", dbus.Boolean(True)) + client.set_prop(adapter, ADAPTER, "Powered", dbus_true()) - adapter_iface.StartDiscovery() + adapter_proxy.start_discovery() try: - device_path, _ = wait_for_device(bus, args.address, args.discover_timeout) + device = wait_for_device(client, address, discover_timeout) finally: - try: - adapter_iface.StopDiscovery() - except dbus.DBusException: - pass + with contextlib.suppress(dbus.DBusException): + adapter_proxy.stop_discovery() - device_obj = bus.get_object(BLUEZ, device_path) - device_iface = dbus.Interface(device_obj, DEVICE) + device_proxy = client.device(device.path) - props = get_props(bus, device_path, DEVICE) + props = client.get_props(device.path, DEVICE) if not bool(props.get("Paired", False)): - device_iface.Pair(timeout=args.connect_timeout) + device_proxy.pair(timeout=connect_timeout) - set_prop(bus, device_path, DEVICE, "Trusted", dbus.Boolean(True)) + client.set_prop(device.path, DEVICE, "Trusted", dbus_true()) - props = get_props(bus, device_path, DEVICE) + props = client.get_props(device.path, DEVICE) if not bool(props.get("Connected", False)): - device_iface.Connect(timeout=args.connect_timeout) + device_proxy.connect(timeout=connect_timeout) - deadline = time.monotonic() + args.connect_timeout + deadline = time.monotonic() + connect_timeout while time.monotonic() < deadline: - props = get_props(bus, device_path, DEVICE) + props = client.get_props(device.path, DEVICE) if bool(props.get("Paired", False)) and bool(props.get("Connected", False)): break time.sleep(0.2) - props = get_props(bus, device_path, DEVICE) - print(f"Device: {args.address.upper()}", flush=True) - print(f"Name: {props.get('Name', '')}", flush=True) + props = client.get_props(device.path, DEVICE) + print(f"Device: {address.upper()}", flush=True) + print(f"Name: {prop_text(props.get('Name', ''))}", flush=True) print(f"Paired: {bool_text(props.get('Paired', False))}", flush=True) print(f"Bonded: {bool_text(props.get('Bonded', False))}", flush=True) print(f"Trusted: {bool_text(props.get('Trusted', False))}", flush=True) diff --git a/tools/hci-proxy.py b/tools/hci-proxy.py index e52d3ba..d90434d 100755 --- a/tools/hci-proxy.py +++ b/tools/hci-proxy.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +from __future__ import annotations + import argparse import os import select @@ -6,11 +8,13 @@ import signal import socket import time +from types import FrameType +from typing import cast HCI_PRIMARY = 0x00 -def h4_packet_length(buf): +def h4_packet_length(buf: bytes) -> int | None: if not buf: return None @@ -41,8 +45,8 @@ def h4_packet_length(buf): raise ValueError(f"unknown H4 packet type 0x{packet_type:02x}") -def take_h4_packets(buf): - packets = [] +def take_h4_packets(buf: bytes) -> tuple[list[bytes], bytes]: + packets: list[bytes] = [] while buf: try: length = h4_packet_length(buf) @@ -56,7 +60,7 @@ def take_h4_packets(buf): return packets, buf -def write_all_fd(fd, data): +def write_all_fd(fd: int, data: bytes) -> None: view = memoryview(data) while view: try: @@ -71,7 +75,7 @@ def write_all_fd(fd, data): view = view[written:] -def send_all_socket(connection, data): +def send_all_socket(connection: socket.socket, data: bytes) -> None: view = memoryview(data) while view: try: @@ -86,29 +90,35 @@ def send_all_socket(connection, data): view = view[sent:] -def open_vhci(): +def open_vhci() -> int: fd = os.open("/dev/vhci", os.O_RDWR | os.O_CLOEXEC) os.write(fd, bytes([0xFF, HCI_PRIMARY])) return fd -def connect_tcp(host, port, timeout): +def connect_tcp(host: str, port: int, timeout: float) -> socket.socket: deadline = time.monotonic() + timeout last_error = None while time.monotonic() < deadline: connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: + remaining = deadline - time.monotonic() + if remaining <= 0: + connection.close() + break + connection.settimeout(remaining) connection.connect((host, port)) - connection.setblocking(False) - return connection except OSError as error: last_error = error connection.close() - time.sleep(0.1) + time.sleep(min(0.1, max(0.0, deadline - time.monotonic()))) + else: + connection.setblocking(False) + return connection raise TimeoutError(f"could not connect to {host}:{port}") from last_error -def raw_proxy(left, right): +def raw_proxy(left: socket.socket, right: socket.socket) -> None: left.setblocking(False) right.setblocking(False) selector = selectors.DefaultSelector() @@ -117,16 +127,18 @@ def raw_proxy(left, right): while True: for key, _ in selector.select(): + source = cast(socket.socket, key.fileobj) + destination = cast(socket.socket, key.data) try: - data = key.fileobj.recv(4096) + data = source.recv(4096) except BlockingIOError: continue if not data: return - send_all_socket(key.data, data) + send_all_socket(destination, data) -def reap_children(_signum, _frame): +def reap_children(_signum: int, _frame: FrameType | None) -> None: while True: try: pid, _ = os.waitpid(-1, os.WNOHANG) @@ -138,7 +150,7 @@ def reap_children(_signum, _frame): return -def bridge(listen_host, listen_port, unix_path): +def bridge(listen_host: str, listen_port: int, unix_path: str) -> None: signal.signal(signal.SIGCHLD, reap_children) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -149,7 +161,13 @@ def bridge(listen_host, listen_port, unix_path): while True: client, _ = server.accept() upstream = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - upstream.connect(unix_path) + try: + upstream.connect(unix_path) + except OSError as error: + print(f"upstream connect failed {unix_path}: {error}", flush=True) + client.close() + upstream.close() + continue pid = os.fork() if pid == 0: server.close() @@ -159,7 +177,7 @@ def bridge(listen_host, listen_port, unix_path): upstream.close() -def hci_proxy(vhci_fd, connection): +def hci_proxy(vhci_fd: int, connection: socket.socket) -> None: os.set_blocking(vhci_fd, False) selector = selectors.DefaultSelector() selector.register(vhci_fd, selectors.EVENT_READ, "vhci") @@ -195,7 +213,7 @@ def hci_proxy(vhci_fd, connection): write_all_fd(vhci_fd, packet) -def main(): +def main() -> None: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/tools/lib/bluez_dbus.py b/tools/lib/bluez_dbus.py new file mode 100644 index 0000000..b73475b --- /dev/null +++ b/tools/lib/bluez_dbus.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import NewType, cast + +import dbus + +BLUEZ = "org.bluez" + +type DBusValue = str | bool | int | float | bytes | None | list[DBusValue] | Mapping[str, DBusValue] +type Properties = Mapping[str, DBusValue] +type Interfaces = Mapping[str, Properties] +type ManagedObjects = Mapping[str, Interfaces] + +DBusConnection = NewType("DBusConnection", object) +DBusRemoteObject = NewType("DBusRemoteObject", object) +DBusProxy = NewType("DBusProxy", object) +GMainLoop = NewType("GMainLoop", object) + + +def system_bus() -> DBusConnection: + return DBusConnection(dbus.SystemBus()) + + +def bluez_object(bus: DBusConnection, path: str) -> DBusRemoteObject: + get_object = getattr(bus, "get_object", None) + if not callable(get_object): + raise TypeError("DBus connection does not expose callable get_object") + return DBusRemoteObject(get_object(BLUEZ, path)) + + +def dbus_interface(obj: DBusRemoteObject, interface: str) -> DBusProxy: + return DBusProxy(dbus.Interface(obj, interface)) + + +def call_dbus(proxy: DBusProxy, method_name: str, *args: DBusValue) -> DBusValue: + method = getattr(proxy, method_name, None) + if not callable(method): + raise TypeError(f"DBus proxy does not expose callable {method_name}") + return cast(DBusValue, method(*args)) + + +def call_dbus_with_timeout(proxy: DBusProxy, method_name: str, timeout: float) -> None: + method = getattr(proxy, method_name, None) + if not callable(method): + raise TypeError(f"DBus proxy does not expose callable {method_name}") + method(timeout=timeout) + + +def call_loop(loop: GMainLoop, method_name: str) -> None: + method = getattr(loop, method_name, None) + if not callable(method): + raise TypeError(f"GLib main loop does not expose callable {method_name}") + method() + + +def dbus_true() -> DBusValue: + return cast(DBusValue, dbus.Boolean(True)) diff --git a/tools/pyproject.toml b/tools/pyproject.toml new file mode 100644 index 0000000..c6e90c9 --- /dev/null +++ b/tools/pyproject.toml @@ -0,0 +1,77 @@ +[project] +name = "rpi-keyboard-switcher-tools" +version = "0.1.0" +requires-python = ">=3.12,<3.13" +dependencies = [] + +[project.optional-dependencies] +runtime = [ + "dbus-python>=1.4.0,<2", + "PyGObject>=3.50.0,<3.51", +] + +[dependency-groups] +dev = [ + "mypy>=1.19.0,<2", + "ruff>=0.15.12,<0.16", +] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["."] + +[tool.ruff.format] +quote-style = "double" + +[tool.ruff.lint] +select = [ + "A", + "ANN", + "ARG", + "B", + "C4", + "E", + "F", + "FBT", + "I", + "PERF", + "PIE", + "PLC", + "PLE", + "PLW", + "PTH", + "RET", + "RUF", + "SIM", + "TRY", + "UP", +] +ignore = [ + # The tools call third-party APIs whose positional boolean arguments are fixed. + "FBT003", + # These scripts intentionally print status lines consumed by the E2E shell test. + "T201", + # Short command-line scripts do not need custom exception classes for each message. + "TRY003", +] + +[tool.mypy] +python_version = "3.12" +strict = true +explicit_package_bases = true +mypy_path = "." +disallow_subclassing_any = false +disallow_untyped_decorators = false + +[[tool.mypy.overrides]] +module = [ + "dbus", + "dbus.*", + "gi", + "gi.*", +] +ignore_missing_imports = true diff --git a/tools/uv.lock b/tools/uv.lock new file mode 100644 index 0000000..fdc77ee --- /dev/null +++ b/tools/uv.lock @@ -0,0 +1,154 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "dbus-python" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/24/63118050c7dd7be04b1ccd60eab53fef00abe844442e1b6dec92dae505d6/dbus-python-1.4.0.tar.gz", hash = "sha256:991666e498f60dbf3e49b8b7678f5559b8a65034fdf61aae62cdecdb7d89c770", size = 232490, upload-time = "2025-03-13T19:57:54.212Z" } + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pycairo" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, +] + +[[package]] +name = "pygobject" +version = "3.50.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/5d/f2946cc6c1baf56dee6e942af8cfa16472538a8ad9d780d9f484e7554288/pygobject-3.50.2.tar.gz", hash = "sha256:ece6b860aab77cb649fdfc6e88d8a83765e7a62f7ffd39a628d6e2a0d397a7ff", size = 1085854, upload-time = "2025-10-18T13:44:45.634Z" } + +[[package]] +name = "rpi-keyboard-switcher-tools" +version = "0.1.0" +source = { virtual = "." } + +[package.optional-dependencies] +runtime = [ + { name = "dbus-python" }, + { name = "pygobject" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "dbus-python", marker = "extra == 'runtime'", specifier = ">=1.4.0,<2" }, + { name = "pygobject", marker = "extra == 'runtime'", specifier = ">=3.50.0,<3.51" }, +] +provides-extras = ["runtime"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.19.0,<2" }, + { name = "ruff", specifier = ">=0.15.12,<0.16" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] From c50ee20c8e82c499a324441eb8d7879022e89a71 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 16:06:31 +0900 Subject: [PATCH 22/37] =?UTF-8?q?docs:=20=E9=96=8B=E7=99=BA=E3=81=AB?= =?UTF-8?q?=E5=BF=85=E8=A6=81=E3=81=AADBus=E4=BE=9D=E5=AD=98=E3=82=92?= =?UTF-8?q?=E6=98=8E=E8=A8=98=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ja.md | 30 +++++++++++++++++++++++++++++- README.md | 30 +++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/README.ja.md b/README.ja.md index 8d4789a..0c86570 100644 --- a/README.ja.md +++ b/README.ja.md @@ -344,12 +344,40 @@ KBD_E2E_CENTRAL_HOST= KBD_E2E_CENTRAL_POR ## 開発 +`make check` は Go toolchain と `uv` を使います。Python tools は `tools/pyproject.toml` と `tools/uv.lock` で管理し、`uv` が Python 3.12 を用意します。 + +```sh +brew install go uv +``` + +`make ci` や `make python-runtime-check` は、DBus/GLib 連携の Python 依存を実際にビルドして import します。Linux では GitHub Actions と同じ前提として次のパッケージが必要です。 + +```sh +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + build-essential \ + gobject-introspection \ + libcairo2-dev \ + libdbus-1-dev \ + libfuse3-dev \ + libgirepository-2.0-dev \ + pkg-config \ + ruby \ + shellcheck +``` + +macOS で `make python-runtime-check` まで実行する場合は、DBus と GObject Introspection の開発ファイルも入れます。 + +```sh +brew install cairo dbus gobject-introspection pkg-config +``` + ```sh make fmt make check ``` -GitHub Actions と同じ検査を Linux 環境で実行する場合は、次を使います。`shellcheck`、`ruby`、`pkg-config`、`libfuse3-dev` が必要です。 +GitHub Actions と同じ検査を Linux 環境で実行する場合は、次を使います。 ```sh make ci diff --git a/README.md b/README.md index 3a9ce86..9dd2117 100644 --- a/README.md +++ b/README.md @@ -344,12 +344,40 @@ KBD_E2E_CENTRAL_HOST= KBD_E2E_CENTRAL_PORT=45560 make e2 ## Development +`make check` uses the Go toolchain and `uv`. The Python tools are managed by `tools/pyproject.toml` and `tools/uv.lock`; `uv` provides Python 3.12. + +```sh +brew install go uv +``` + +`make ci` and `make python-runtime-check` build and import the Python dependencies used for DBus/GLib integration. On Linux, install the same packages as GitHub Actions: + +```sh +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + build-essential \ + gobject-introspection \ + libcairo2-dev \ + libdbus-1-dev \ + libfuse3-dev \ + libgirepository-2.0-dev \ + pkg-config \ + ruby \ + shellcheck +``` + +To run `make python-runtime-check` on macOS, install the DBus and GObject Introspection development files too: + +```sh +brew install cairo dbus gobject-introspection pkg-config +``` + ```sh make fmt make check ``` -To run the same checks as GitHub Actions on Linux, use the following command. It needs `shellcheck`, `ruby`, `pkg-config`, and `libfuse3-dev`. +To run the same checks as GitHub Actions on Linux, use the following command: ```sh make ci From 00c587e92e59fb80441a807776c638f26636d709 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 16:15:23 +0900 Subject: [PATCH 23/37] =?UTF-8?q?fix:=20E2E=E3=82=B9=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=97=E3=83=88=E3=81=AEshellcheck=E9=81=95=E5=8F=8D?= =?UTF-8?q?=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/hid-e2e.sh | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/hid-e2e.sh b/scripts/hid-e2e.sh index 1199c6b..ebfa023 100755 --- a/scripts/hid-e2e.sh +++ b/scripts/hid-e2e.sh @@ -181,12 +181,10 @@ rm -f /tmp/hidraw.path /tmp/send-report /tmp/kbd-e2e.yaml /tmp/kbd-hid.log \ /tmp/hidraw-cuse.log /tmp/bluetoothd.log /tmp/hci-client.log /tmp/btmgmt.log modprobe cuse -tools_uv() { - UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ - uv --project /vagrant/tools --directory /vagrant/tools run \ - --locked --managed-python --python 3.12 --extra runtime --no-dev "$@" -} -tools_uv python hci-proxy.py client "${central_host}" --port "${central_port}" >/tmp/hci-client.log 2>&1 & +UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + uv --project /vagrant/tools --directory /vagrant/tools run \ + --locked --managed-python --python 3.12 --extra runtime --no-dev \ + python hci-proxy.py client "${central_host}" --port "${central_port}" >/tmp/hci-client.log 2>&1 & REMOTE start_bluez_adapter peripheral From 350d230d22eb258f55cc03aa064ddf5ff3ad1532 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 16:21:59 +0900 Subject: [PATCH 24/37] =?UTF-8?q?build:=20PyGObject=E3=81=AE=E3=83=93?= =?UTF-8?q?=E3=83=AB=E3=83=89=E4=BE=9D=E5=AD=98=E3=82=92=E6=98=8E=E7=A4=BA?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 1 + README.ja.md | 1 + README.md | 1 + Vagrantfile | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2ff1ac..fbc477d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: libcairo2-dev \ libdbus-1-dev \ libfuse3-dev \ + libgirepository1.0-dev \ libgirepository-2.0-dev \ pkg-config \ ruby \ diff --git a/README.ja.md b/README.ja.md index 0c86570..401eb3d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -360,6 +360,7 @@ sudo apt-get install -y --no-install-recommends \ libcairo2-dev \ libdbus-1-dev \ libfuse3-dev \ + libgirepository1.0-dev \ libgirepository-2.0-dev \ pkg-config \ ruby \ diff --git a/README.md b/README.md index 9dd2117..a8f6049 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,7 @@ sudo apt-get install -y --no-install-recommends \ libcairo2-dev \ libdbus-1-dev \ libfuse3-dev \ + libgirepository1.0-dev \ libgirepository-2.0-dev \ pkg-config \ ruby \ diff --git a/Vagrantfile b/Vagrantfile index 042c1bd..d9ef6b8 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -23,6 +23,7 @@ def provision_e2e_vm(config) libcairo2-dev \ libdbus-1-dev \ libfuse3-dev \ + libgirepository1.0-dev \ libgirepository-2.0-dev \ pkg-config \ procps \ From 0733b326440266a1d28abe8b6e8d1ae9490880b7 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 16:27:25 +0900 Subject: [PATCH 25/37] =?UTF-8?q?chore:=20Python=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A5=E7=84=A1=E8=A6=96=E3=82=92tools?= =?UTF-8?q?=E3=81=B8=E5=AF=84=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0680d23..bbc4756 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ *.out *.test coverage.out -__pycache__/ .DS_Store .idea/ From 4d2d0bbb52918dd009376cd64165e0387b8579de Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 16:37:52 +0900 Subject: [PATCH 26/37] =?UTF-8?q?build:=20Python=20tools=E3=81=AE=E5=9E=8B?= =?UTF-8?q?=E6=A4=9C=E6=9F=BB=E3=82=92=E5=AE=9F=E4=BD=93=E5=8C=96=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 3 +- tools/bluez-agent.py | 5 +- tools/lib/bluez_dbus.py | 115 +++++++++++++++++++------ tools/pyproject.toml | 12 +-- tools/pyrightconfig.json | 8 ++ tools/stubs/dbus/__init__.pyi | 16 ++++ tools/stubs/dbus/mainloop/__init__.pyi | 1 + tools/stubs/dbus/mainloop/glib.pyi | 4 + tools/stubs/dbus/service.pyi | 19 ++++ tools/stubs/gi/__init__.pyi | 1 + tools/stubs/gi/repository/GLib.pyi | 5 ++ tools/stubs/gi/repository/__init__.pyi | 1 + tools/uv.lock | 24 ++++++ 13 files changed, 177 insertions(+), 37 deletions(-) create mode 100644 tools/pyrightconfig.json create mode 100644 tools/stubs/dbus/__init__.pyi create mode 100644 tools/stubs/dbus/mainloop/__init__.pyi create mode 100644 tools/stubs/dbus/mainloop/glib.pyi create mode 100644 tools/stubs/dbus/service.pyi create mode 100644 tools/stubs/gi/__init__.pyi create mode 100644 tools/stubs/gi/repository/GLib.pyi create mode 100644 tools/stubs/gi/repository/__init__.pyi diff --git a/Makefile b/Makefile index fadfa23..d7de03b 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ TOOLS_DIR := tools TOOLS_UV := $(UV) --project $(TOOLS_DIR) --directory $(TOOLS_DIR) SHELL_SCRIPTS := scripts/hid-e2e.sh PYTHON_TOOLS := hci-proxy.py bluez-agent.py bluez-pair.py -PYTHON_SOURCES := $(PYTHON_TOOLS) lib +PYTHON_SOURCES := $(PYTHON_TOOLS) lib stubs CUSE_TOOL := tools/hidraw-cuse.c all: build @@ -67,6 +67,7 @@ python-check: $(TOOLS_UV) lock --check --python $(TOOLS_PYTHON) --managed-python $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) ruff check $(PYTHON_SOURCES) $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) mypy $(PYTHON_SOURCES) + $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) pyright $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) python -m compileall -q $(PYTHON_SOURCES) python-runtime-check: diff --git a/tools/bluez-agent.py b/tools/bluez-agent.py index 9c511ae..16a00fe 100644 --- a/tools/bluez-agent.py +++ b/tools/bluez-agent.py @@ -15,6 +15,7 @@ from lib.bluez_dbus import ( DBusConnection, GMainLoop, + GMainLoopProxy, bluez_object, call_dbus, call_loop, @@ -93,7 +94,7 @@ def main() -> None: dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) bus = system_bus() - Agent(bus, AGENT_PATH) + Agent(bus.raw, AGENT_PATH) manager = AgentManager(bus) manager.register_agent(AGENT_PATH, capability) @@ -109,7 +110,7 @@ def stop(_signum: int, _frame: FrameType | None) -> None: call_loop(loop, "run") -loop = GMainLoop(GLib.MainLoop()) +loop: GMainLoop = GMainLoopProxy(GLib.MainLoop()) if __name__ == "__main__": diff --git a/tools/lib/bluez_dbus.py b/tools/lib/bluez_dbus.py index b73475b..45dba7b 100644 --- a/tools/lib/bluez_dbus.py +++ b/tools/lib/bluez_dbus.py @@ -1,57 +1,124 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import NewType, cast +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast import dbus +if TYPE_CHECKING: + from gi.repository import GLib + BLUEZ = "org.bluez" type DBusValue = str | bool | int | float | bytes | None | list[DBusValue] | Mapping[str, DBusValue] type Properties = Mapping[str, DBusValue] type Interfaces = Mapping[str, Properties] type ManagedObjects = Mapping[str, Interfaces] +type DBusCallable = Callable[..., DBusValue] + + +class DBusConnection(ABC): + @property + @abstractmethod + def raw(self) -> dbus.SystemBus: ... + + @abstractmethod + def get_object(self, path: str) -> DBusRemoteObject: ... + + +@dataclass(frozen=True) +class SystemBusConnection(DBusConnection): + _raw: dbus.SystemBus + + @property + def raw(self) -> dbus.SystemBus: + return self._raw + + def get_object(self, path: str) -> DBusRemoteObject: + return DBusRemoteObject(self.raw.get_object(BLUEZ, path)) + + +@dataclass(frozen=True) +class DBusRemoteObject: + raw: dbus.RemoteObject + + +class DBusProxy(ABC): + @abstractmethod + def call(self, method_name: str, *args: DBusValue) -> DBusValue: ... + + @abstractmethod + def call_with_timeout(self, method_name: str, timeout: float) -> None: ... + + +@dataclass(frozen=True) +class DBusInterface(DBusProxy): + raw: dbus.Interface + + def call(self, method_name: str, *args: DBusValue) -> DBusValue: + candidate = getattr(self.raw, method_name, None) + if not callable(candidate): + raise TypeError(f"DBus proxy does not expose callable {method_name}") + method = cast(DBusCallable, candidate) + return method(*args) + + def call_with_timeout(self, method_name: str, timeout: float) -> None: + candidate = getattr(self.raw, method_name, None) + if not callable(candidate): + raise TypeError(f"DBus proxy does not expose callable {method_name}") + method = cast(DBusCallable, candidate) + method(timeout=timeout) + + +class GMainLoop(ABC): + @abstractmethod + def run(self) -> None: ... + + @abstractmethod + def quit(self) -> None: ... + + +@dataclass(frozen=True) +class GMainLoopProxy(GMainLoop): + raw: GLib.MainLoop + + def run(self) -> None: + self.raw.run() -DBusConnection = NewType("DBusConnection", object) -DBusRemoteObject = NewType("DBusRemoteObject", object) -DBusProxy = NewType("DBusProxy", object) -GMainLoop = NewType("GMainLoop", object) + def quit(self) -> None: + self.raw.quit() def system_bus() -> DBusConnection: - return DBusConnection(dbus.SystemBus()) + return SystemBusConnection(dbus.SystemBus()) def bluez_object(bus: DBusConnection, path: str) -> DBusRemoteObject: - get_object = getattr(bus, "get_object", None) - if not callable(get_object): - raise TypeError("DBus connection does not expose callable get_object") - return DBusRemoteObject(get_object(BLUEZ, path)) + return bus.get_object(path) def dbus_interface(obj: DBusRemoteObject, interface: str) -> DBusProxy: - return DBusProxy(dbus.Interface(obj, interface)) + return DBusInterface(dbus.Interface(obj.raw, interface)) def call_dbus(proxy: DBusProxy, method_name: str, *args: DBusValue) -> DBusValue: - method = getattr(proxy, method_name, None) - if not callable(method): - raise TypeError(f"DBus proxy does not expose callable {method_name}") - return cast(DBusValue, method(*args)) + return proxy.call(method_name, *args) def call_dbus_with_timeout(proxy: DBusProxy, method_name: str, timeout: float) -> None: - method = getattr(proxy, method_name, None) - if not callable(method): - raise TypeError(f"DBus proxy does not expose callable {method_name}") - method(timeout=timeout) + proxy.call_with_timeout(method_name, timeout) def call_loop(loop: GMainLoop, method_name: str) -> None: - method = getattr(loop, method_name, None) - if not callable(method): - raise TypeError(f"GLib main loop does not expose callable {method_name}") - method() + if method_name == "run": + loop.run() + return + if method_name == "quit": + loop.quit() + return + raise TypeError(f"GLib main loop does not expose callable {method_name}") def dbus_true() -> DBusValue: diff --git a/tools/pyproject.toml b/tools/pyproject.toml index c6e90c9..55a425d 100644 --- a/tools/pyproject.toml +++ b/tools/pyproject.toml @@ -13,6 +13,7 @@ runtime = [ [dependency-groups] dev = [ "mypy>=1.19.0,<2", + "pyright>=1.1.407,<2", "ruff>=0.15.12,<0.16", ] @@ -63,15 +64,6 @@ ignore = [ python_version = "3.12" strict = true explicit_package_bases = true -mypy_path = "." +mypy_path = "stubs:." disallow_subclassing_any = false disallow_untyped_decorators = false - -[[tool.mypy.overrides]] -module = [ - "dbus", - "dbus.*", - "gi", - "gi.*", -] -ignore_missing_imports = true diff --git a/tools/pyrightconfig.json b/tools/pyrightconfig.json new file mode 100644 index 0000000..5988fbf --- /dev/null +++ b/tools/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "include": ["*.py", "lib", "stubs"], + "exclude": [".mypy_cache", ".ruff_cache", ".venv", "__pycache__"], + "pythonVersion": "3.12", + "stubPath": "stubs", + "typeCheckingMode": "strict", + "reportMissingModuleSource": "none" +} diff --git a/tools/stubs/dbus/__init__.pyi b/tools/stubs/dbus/__init__.pyi new file mode 100644 index 0000000..82ac417 --- /dev/null +++ b/tools/stubs/dbus/__init__.pyi @@ -0,0 +1,16 @@ +from __future__ import annotations + +class DBusException(Exception): ... +class RemoteObject: ... + +class SystemBus: + def get_object(self, bus_name: str, object_path: str) -> RemoteObject: ... + +class Interface: + def __init__(self, obj: RemoteObject, dbus_interface: str) -> None: ... + +class Boolean(int): + def __new__(cls, value: bool) -> Boolean: ... + +class UInt32(int): + def __new__(cls, value: int) -> UInt32: ... diff --git a/tools/stubs/dbus/mainloop/__init__.pyi b/tools/stubs/dbus/mainloop/__init__.pyi new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tools/stubs/dbus/mainloop/__init__.pyi @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tools/stubs/dbus/mainloop/glib.pyi b/tools/stubs/dbus/mainloop/glib.pyi new file mode 100644 index 0000000..f85790a --- /dev/null +++ b/tools/stubs/dbus/mainloop/glib.pyi @@ -0,0 +1,4 @@ +from __future__ import annotations + +class DBusGMainLoop: + def __init__(self, set_as_default: bool) -> None: ... diff --git a/tools/stubs/dbus/service.pyi b/tools/stubs/dbus/service.pyi new file mode 100644 index 0000000..3e24e2e --- /dev/null +++ b/tools/stubs/dbus/service.pyi @@ -0,0 +1,19 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import ParamSpec, TypeVar + +from dbus import SystemBus + +_P = ParamSpec("_P") +_R = TypeVar("_R") + +class Object: + def __init__(self, bus: SystemBus, object_path: str) -> None: ... + +def method( + dbus_interface: str, + *, + in_signature: str, + out_signature: str, +) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... diff --git a/tools/stubs/gi/__init__.pyi b/tools/stubs/gi/__init__.pyi new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tools/stubs/gi/__init__.pyi @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tools/stubs/gi/repository/GLib.pyi b/tools/stubs/gi/repository/GLib.pyi new file mode 100644 index 0000000..86a435e --- /dev/null +++ b/tools/stubs/gi/repository/GLib.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +class MainLoop: + def run(self) -> None: ... + def quit(self) -> None: ... diff --git a/tools/stubs/gi/repository/__init__.pyi b/tools/stubs/gi/repository/__init__.pyi new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tools/stubs/gi/repository/__init__.pyi @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tools/uv.lock b/tools/uv.lock index fdc77ee..473741d 100644 --- a/tools/uv.lock +++ b/tools/uv.lock @@ -60,6 +60,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -89,6 +98,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/a7/5d/f2946cc6c1baf56dee6e942af8cfa16472538a8ad9d780d9f484e7554288/pygobject-3.50.2.tar.gz", hash = "sha256:ece6b860aab77cb649fdfc6e88d8a83765e7a62f7ffd39a628d6e2a0d397a7ff", size = 1085854, upload-time = "2025-10-18T13:44:45.634Z" } +[[package]] +name = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + [[package]] name = "rpi-keyboard-switcher-tools" version = "0.1.0" @@ -103,6 +125,7 @@ runtime = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pyright" }, { name = "ruff" }, ] @@ -116,6 +139,7 @@ provides-extras = ["runtime"] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.19.0,<2" }, + { name = "pyright", specifier = ">=1.1.407,<2" }, { name = "ruff", specifier = ">=0.15.12,<0.16" }, ] From cbc8c211901c4fda2406c100262f28b3f7f5ce4f Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 16:38:45 +0900 Subject: [PATCH 27/37] =?UTF-8?q?docs:=20tools=E3=81=AE=E9=96=8B=E7=99=BA?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E3=82=92=E5=88=86=E3=81=91=E3=81=A6=E6=9B=B8?= =?UTF-8?q?=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/README.ja.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++ tools/README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tools/README.ja.md create mode 100644 tools/README.md diff --git a/tools/README.ja.md b/tools/README.ja.md new file mode 100644 index 0000000..4f5984a --- /dev/null +++ b/tools/README.ja.md @@ -0,0 +1,51 @@ +# Tools + +このディレクトリには、Vagrant の Bluetooth HID E2E で使う Python/C 補助ツールを置いています。 + +## Python 環境 + +Python tools はこのディレクトリ内の `uv` 設定で管理します。 + +```sh +uv --project tools --directory tools sync --managed-python --python 3.12 +``` + +DBus/GLib の実行時依存は必要な時だけ extra で入れます。 + +```sh +uv --project tools --directory tools sync --managed-python --python 3.12 --extra runtime +``` + +## ネイティブ依存 + +Linux: + +```sh +sudo apt-get install -y --no-install-recommends \ + build-essential \ + gobject-introspection \ + libcairo2-dev \ + libdbus-1-dev \ + libgirepository1.0-dev \ + libgirepository-2.0-dev \ + pkg-config +``` + +macOS: + +```sh +brew install cairo dbus gobject-introspection pkg-config +``` + +## チェック + +プロジェクトルートから実行します。 + +```sh +make python-check +make python-runtime-check +``` + +`python-check` は Ruff、mypy、Pyright、`compileall` を実行します。`tools/stubs` のローカル stub で、このスクリプトが使う DBus と GLib の API を明示しているため、`dbus` や `gi` の import が解決できない状態を無視しません。 + +`python-runtime-check` は runtime extra 経由で `dbus`、`gi`、`GLib` を import し、DBus/GLib の開発ファイルが入っていることを確認します。 diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..972617e --- /dev/null +++ b/tools/README.md @@ -0,0 +1,51 @@ +# Tools + +This directory contains the Python and C helpers used by the Vagrant Bluetooth HID E2E flow. + +## Python Environment + +Python tools are managed in this directory with `uv`. + +```sh +uv --project tools --directory tools sync --managed-python --python 3.12 +``` + +Runtime DBus/GLib dependencies are optional and are installed only when needed: + +```sh +uv --project tools --directory tools sync --managed-python --python 3.12 --extra runtime +``` + +## Native Dependencies + +Linux: + +```sh +sudo apt-get install -y --no-install-recommends \ + build-essential \ + gobject-introspection \ + libcairo2-dev \ + libdbus-1-dev \ + libgirepository1.0-dev \ + libgirepository-2.0-dev \ + pkg-config +``` + +macOS: + +```sh +brew install cairo dbus gobject-introspection pkg-config +``` + +## Checks + +Run the Python checks from the project root: + +```sh +make python-check +make python-runtime-check +``` + +`python-check` runs Ruff, mypy, Pyright, and `compileall`. The local stubs in `tools/stubs` describe the DBus and GLib APIs used by these scripts, so missing `dbus` or `gi` imports fail in type checking instead of being ignored. + +`python-runtime-check` imports `dbus`, `gi`, and `GLib` through the runtime extra to verify the native DBus/GLib development files are installed. From cda8f71c17d2075dd91729739cf1a1adfd0fe170 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 20:41:07 +0900 Subject: [PATCH 28/37] =?UTF-8?q?build:=20E2E=E7=94=A8UTM=20provider?= =?UTF-8?q?=E3=82=92submodule=E3=81=A7=E5=9B=BA=E5=AE=9A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitmodules | 4 +++ Makefile | 9 +++-- README.ja.md | 5 ++- README.md | 5 ++- scripts/install-vagrant-utm-plugin.sh | 51 +++++++++++++++++++++++++++ third_party/vagrant_utm | 1 + 6 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 .gitmodules create mode 100755 scripts/install-vagrant-utm-plugin.sh create mode 160000 third_party/vagrant_utm diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9683150 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "third_party/vagrant_utm"] + path = third_party/vagrant_utm + url = https://github.com/RarkHopper/vagrant_utm.git + branch = fix/remove-applescript-continuation-chars diff --git a/Makefile b/Makefile index d7de03b..261bc90 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check vagrant-check cuse-check ci e2e +.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check vagrant-check vagrant-utm-plugin cuse-check ci e2e GOLANGCI_LINT := go tool golangci-lint VAGRANT ?= vagrant @@ -11,7 +11,7 @@ UV ?= uv TOOLS_PYTHON ?= 3.12 TOOLS_DIR := tools TOOLS_UV := $(UV) --project $(TOOLS_DIR) --directory $(TOOLS_DIR) -SHELL_SCRIPTS := scripts/hid-e2e.sh +SHELL_SCRIPTS := scripts/hid-e2e.sh scripts/install-vagrant-utm-plugin.sh PYTHON_TOOLS := hci-proxy.py bluez-agent.py bluez-pair.py PYTHON_SOURCES := $(PYTHON_TOOLS) lib stubs CUSE_TOOL := tools/hidraw-cuse.c @@ -76,11 +76,14 @@ python-runtime-check: vagrant-check: ruby -c Vagrantfile +vagrant-utm-plugin: + VAGRANT=$(VAGRANT) scripts/install-vagrant-utm-plugin.sh + cuse-check: pkg-config --exists fuse3 cc -Wall -Wextra -fsyntax-only $$(pkg-config --cflags fuse3) $(CUSE_TOOL) ci: check race-test build mod-check script-check python-runtime-check vagrant-check cuse-check -e2e: +e2e: vagrant-utm-plugin VAGRANT=$(VAGRANT) scripts/hid-e2e.sh diff --git a/README.ja.md b/README.ja.md index 401eb3d..615e37f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -319,16 +319,15 @@ central VM: 仮想 HCI -> BlueZ HoG client -> hidraw -> evdev KEY_A ``` -Mac 側に Vagrant、UTM、UTM provider を入れます。 +Mac 側に Vagrant と UTM を入れます。 ```sh brew tap hashicorp/tap brew install hashicorp/tap/hashicorp-vagrant brew install --cask utm -vagrant plugin install vagrant_utm ``` -Mac 側から検証を実行します。このコマンドは VM の作成または起動をしてから、BLE HID の検証を実行します。 +Mac 側から検証を実行します。このコマンドは `third_party/vagrant_utm` から UTM provider をビルドして Vagrant の project-local plugin として入れ、VM の作成または起動をしてから、BLE HID の検証を実行します。 ```sh make e2e diff --git a/README.md b/README.md index a8f6049..60022d6 100644 --- a/README.md +++ b/README.md @@ -319,16 +319,15 @@ central VM: virtual HCI -> BlueZ HoG client -> hidraw -> evdev KEY_A ``` -Install Vagrant, UTM, and the UTM provider on the Mac: +Install Vagrant and UTM on the Mac: ```sh brew tap hashicorp/tap brew install hashicorp/tap/hashicorp-vagrant brew install --cask utm -vagrant plugin install vagrant_utm ``` -Run the check from the Mac. This command creates or starts the VMs before running the BLE HID check: +Run the check from the Mac. This command builds the UTM provider from `third_party/vagrant_utm`, installs it as a project-local Vagrant plugin, then creates or starts the VMs before running the BLE HID check: ```sh make e2e diff --git a/scripts/install-vagrant-utm-plugin.sh b/scripts/install-vagrant-utm-plugin.sh new file mode 100755 index 0000000..02df341 --- /dev/null +++ b/scripts/install-vagrant-utm-plugin.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +plugin_rel="third_party/vagrant_utm" +plugin_dir="${repo_root}/${plugin_rel}" +vagrant_cmd="${VAGRANT:-vagrant}" + +log() { + printf 'vagrant-utm-plugin: %s\n' "$*" +} + +fail() { + printf 'vagrant-utm-plugin: %s\n' "$*" >&2 + exit 1 +} + +need_command() { + command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" +} + +need_command "${vagrant_cmd}" +need_command git + +if [ ! -f "${plugin_dir}/vagrant_utm.gemspec" ]; then + log "initializing ${plugin_rel}" + git -C "${repo_root}" submodule update --init --recursive "${plugin_rel}" +fi + +[ -f "${plugin_dir}/vagrant_utm.gemspec" ] || + fail "missing ${plugin_rel}; run git submodule update --init --recursive ${plugin_rel}" + +if [ -x /opt/vagrant/embedded/bin/gem ]; then + gem_cmd="/opt/vagrant/embedded/bin/gem" +else + need_command gem + gem_cmd="gem" +fi + +log "building vagrant_utm gem" +build_output="$(cd "${plugin_dir}" && "${gem_cmd}" build vagrant_utm.gemspec)" +printf '%s\n' "${build_output}" + +gem_file="$(printf '%s\n' "${build_output}" | awk '/File:/ { print $2; exit }')" +[ -n "${gem_file}" ] || fail "could not find built gem path" + +gem_path="${plugin_dir}/${gem_file}" +[ -f "${gem_path}" ] || fail "built gem was not found: ${gem_path}" + +log "installing project-local Vagrant plugin from ${plugin_rel}/${gem_file}" +"${vagrant_cmd}" plugin install --local "${gem_path}" diff --git a/third_party/vagrant_utm b/third_party/vagrant_utm new file mode 160000 index 0000000..e29fc5e --- /dev/null +++ b/third_party/vagrant_utm @@ -0,0 +1 @@ +Subproject commit e29fc5e5e47fdce490eba97688abb35e83cdd40c From c53b3be76d02a546092a10066378e7846dd9ad25 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 20:41:14 +0900 Subject: [PATCH 29/37] =?UTF-8?q?fix:=20E2E=E3=81=AEBlueZ=E8=B5=B7?= =?UTF-8?q?=E5=8B=95=E5=BE=85=E3=81=A1=E3=82=92=E5=AE=89=E5=AE=9A=E3=81=95?= =?UTF-8?q?=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/hid-e2e.sh | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/hid-e2e.sh b/scripts/hid-e2e.sh index ebfa023..c743ee4 100755 --- a/scripts/hid-e2e.sh +++ b/scripts/hid-e2e.sh @@ -12,7 +12,6 @@ log() { fail() { printf 'hid-e2e: %s\n' "$*" >&2 - print_logs >&2 || true exit 1 } @@ -97,12 +96,19 @@ if command -v bluetoothd >/dev/null 2>&1; then else bluetoothd_path="/usr/libexec/bluetooth/bluetoothd" fi -"$bluetoothd_path" -n -d >/tmp/bluetoothd.log 2>&1 & -for _ in $(seq 1 100); do +start_bluetoothd() { + pkill -x bluetoothd >/dev/null 2>&1 || true + "$bluetoothd_path" -n -d >/tmp/bluetoothd.log 2>&1 & +} + +start_bluetoothd + +for _ in $(seq 1 300); do busctl --system get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Address >/tmp/bluez-adapter.log 2>&1 && break - sleep 0.1 + pgrep -x bluetoothd >/dev/null 2>&1 || start_bluetoothd + sleep 0.2 done busctl --system get-property org.bluez /org/bluez/hci0 org.bluez.Adapter1 Address >/tmp/bluez-adapter.log @@ -371,7 +377,10 @@ main() { start_central start_peripheral mac="$(peripheral_address)" - [ -n "$mac" ] || fail "peripheral address was empty" + if [ -z "$mac" ]; then + print_logs >&2 || true + fail "peripheral address was empty" + fi pair_central "$mac" wait_for_central_input "$mac" capture_input From 287983427322580ec8cfb008f1f94d35d718ce79 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Sun, 17 May 2026 21:35:45 +0900 Subject: [PATCH 30/37] =?UTF-8?q?fix:=20E2E=E5=88=9D=E5=9B=9E=E8=B5=B7?= =?UTF-8?q?=E5=8B=95=E3=81=AEHCI=E5=BE=85=E3=81=A1=E3=82=92=E5=AE=89?= =?UTF-8?q?=E5=AE=9A=E3=81=95=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vagrantfile | 3 ++- scripts/hid-e2e.sh | 14 +++++++------- third_party/vagrant_utm | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Vagrantfile b/Vagrantfile index d9ef6b8..78715f3 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -47,7 +47,8 @@ def provision_e2e_vm(config) install -m 0755 "${tmp_dir}/uv-aarch64-unknown-linux-gnu/uv" /usr/local/bin/uv install -m 0755 "${tmp_dir}/uv-aarch64-unknown-linux-gnu/uvx" /usr/local/bin/uvx - sudo -u vagrant env UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + install -d -m 0755 /opt/rpi-keyboard-switcher-tools + UV_PROJECT_ENVIRONMENT=/opt/rpi-keyboard-switcher-tools/.venv \ /usr/local/bin/uv --project /vagrant/tools --directory /vagrant/tools sync \ --locked --managed-python --python 3.12 --extra runtime --no-dev diff --git a/scripts/hid-e2e.sh b/scripts/hid-e2e.sh index c743ee4..4d140d2 100755 --- a/scripts/hid-e2e.sh +++ b/scripts/hid-e2e.sh @@ -85,9 +85,9 @@ start_bluez_adapter() { vm_sudo "$vm" <<'REMOTE' set -euo pipefail -for _ in $(seq 1 100); do +for _ in $(seq 1 300); do [ -d /sys/class/bluetooth/hci0 ] && break - sleep 0.1 + sleep 0.2 done [ -d /sys/class/bluetooth/hci0 ] @@ -141,7 +141,7 @@ rm -f /tmp/hid-e2e-events.log /tmp/hid-e2e-reader.log /tmp/bluetoothctl-pair.log rm -f /tmp/bt-server-le btvirt -s >/tmp/btvirt.log 2>&1 & tools_uv() { - UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + UV_PROJECT_ENVIRONMENT=/opt/rpi-keyboard-switcher-tools/.venv \ uv --project /vagrant/tools --directory /vagrant/tools run \ --locked --managed-python --python 3.12 --extra runtime --no-dev "$@" } @@ -164,7 +164,7 @@ REMOTE vm_sudo central <<'REMOTE' set -euo pipefail tools_uv() { - UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + UV_PROJECT_ENVIRONMENT=/opt/rpi-keyboard-switcher-tools/.venv \ uv --project /vagrant/tools --directory /vagrant/tools run \ --locked --managed-python --python 3.12 --extra runtime --no-dev "$@" } @@ -187,7 +187,7 @@ rm -f /tmp/hidraw.path /tmp/send-report /tmp/kbd-e2e.yaml /tmp/kbd-hid.log \ /tmp/hidraw-cuse.log /tmp/bluetoothd.log /tmp/hci-client.log /tmp/btmgmt.log modprobe cuse -UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ +UV_PROJECT_ENVIRONMENT=/opt/rpi-keyboard-switcher-tools/.venv \ uv --project /vagrant/tools --directory /vagrant/tools run \ --locked --managed-python --python 3.12 --extra runtime --no-dev \ python hci-proxy.py client "${central_host}" --port "${central_port}" >/tmp/hci-client.log 2>&1 & @@ -252,7 +252,7 @@ pair_central() { set -euo pipefail { - UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ + UV_PROJECT_ENVIRONMENT=/opt/rpi-keyboard-switcher-tools/.venv \ uv --project /vagrant/tools --directory /vagrant/tools run \ --locked --managed-python --python 3.12 --extra runtime --no-dev \ python bluez-pair.py --adapter hci0 "${mac}" @@ -306,7 +306,7 @@ hidraw_path="$(find /sys/devices/virtual/misc/uhid -maxdepth 3 -type d -name 'hi hidraw_path="/dev/$(basename "$hidraw_path")" (timeout 25s btmon >/tmp/btmon-report.log 2>&1) & -UV_PROJECT_ENVIRONMENT=/home/vagrant/.cache/rpi-keyboard-switcher-tools/.venv \ +UV_PROJECT_ENVIRONMENT=/opt/rpi-keyboard-switcher-tools/.venv \ timeout 22s uv --project /vagrant/tools --directory /vagrant/tools run \ --locked --managed-python --python 3.12 --no-dev \ python - "$event_path" "$hidraw_path" >/tmp/hid-e2e-events.log 2>/tmp/hid-e2e-reader.log <<'PY' & diff --git a/third_party/vagrant_utm b/third_party/vagrant_utm index e29fc5e..f70cc80 160000 --- a/third_party/vagrant_utm +++ b/third_party/vagrant_utm @@ -1 +1 @@ -Subproject commit e29fc5e5e47fdce490eba97688abb35e83cdd40c +Subproject commit f70cc807ea833ef0b067ca32e474d896d4591114 From 3762e69a267adcdea7a5df1329eb52b034653a60 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Mon, 18 May 2026 00:31:16 +0900 Subject: [PATCH 31/37] =?UTF-8?q?build:=20Packer=E3=81=A7E2E=E7=94=A8box?= =?UTF-8?q?=E3=82=92=E5=AE=9A=E7=BE=A9=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + packer/cloud-init/meta-data | 2 + packer/cloud-init/network-config | 7 +++ packer/cloud-init/user-data | 27 +++++++++++ packer/e2e-utm.pkr.hcl | 82 ++++++++++++++++++++++++++++++++ scripts/provision-e2e-vm.sh | 78 ++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+) create mode 100644 packer/cloud-init/meta-data create mode 100644 packer/cloud-init/network-config create mode 100644 packer/cloud-init/user-data create mode 100644 packer/e2e-utm.pkr.hcl create mode 100755 scripts/provision-e2e-vm.sh diff --git a/.gitignore b/.gitignore index bbc4756..9053d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /bin/ /dist/ /tmp/ +/packer_cache/ *.out *.test diff --git a/packer/cloud-init/meta-data b/packer/cloud-init/meta-data new file mode 100644 index 0000000..b5c695c --- /dev/null +++ b/packer/cloud-init/meta-data @@ -0,0 +1,2 @@ +instance-id: rpi-keyboard-switcher-e2e +local-hostname: rpi-keyboard-switcher-e2e diff --git a/packer/cloud-init/network-config b/packer/cloud-init/network-config new file mode 100644 index 0000000..7fcb346 --- /dev/null +++ b/packer/cloud-init/network-config @@ -0,0 +1,7 @@ +version: 2 +ethernets: + e2e: + match: + name: "en*" + dhcp4: true + dhcp6: true diff --git a/packer/cloud-init/user-data b/packer/cloud-init/user-data new file mode 100644 index 0000000..9b998ce --- /dev/null +++ b/packer/cloud-init/user-data @@ -0,0 +1,27 @@ +#cloud-config +hostname: rpi-keyboard-switcher-e2e +manage_etc_hosts: true +ssh_pwauth: true + +users: + - default + - name: vagrant + gecos: Vagrant + groups: + - adm + - cdrom + - dip + - plugdev + - sudo + shell: /bin/bash + sudo: + - ALL=(ALL) NOPASSWD:ALL + lock_passwd: false + passwd: $6$vagrant$aYdZwu4306HGdE39rROOrbSnB8G1Jser5zc9VMESSr8PouIZdgoO.OYQsFTOHXRXSYzB1oCD7571llAG6WR15. + ssh_authorized_keys: + - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC5kyIByRqaV9Yj+G8QBxYxUSbVmBoVoCiLKt1JgYfcrJWG4UXFhd1nbu6hFefRTTJbfS5n/iHpBL6hxF2Hpkm4PWJBgK4R40n0RtVGfG7D4qcCoaMYldK6efNQh8M1XPpFbUEpfPyeMhRZLYAYf6NSZ3MEz3AL2cYq5hf3a7e82QUKHQ2rHruXyFKy3n7paNLk5PmJMA2md6h+ZLHDdgsTwpn/1Wm8ww3qfRVQ5pS+X97oKFktW+0l2ikUBK55RymI5m9n8GbDV3RUvyswp+Tjs2B9G7E2Vmj0hUUNS+M+wv+FaC7gRCHtGKm6fI8UNEd3mQJA3Lw3CCZhjUowJhTxBa4pw1B7hrE/7oMHPtiAxQ90ZOPsMj2eVfPw5TIO7YM1nqH3ydJIOurMxhwrsEJbn2PNwB8iJHZ4TfdfhQWlF9wsnXTOBkLLFQsbAaQAmx4A1ICDII1bVD3bOfElJqDJJ22+Sg0DlPfkZJseHkLH//5dGQEdfG8= vagrant insecure public key + +chpasswd: + expire: false + +ssh_deletekeys: false diff --git a/packer/e2e-utm.pkr.hcl b/packer/e2e-utm.pkr.hcl new file mode 100644 index 0000000..b971247 --- /dev/null +++ b/packer/e2e-utm.pkr.hcl @@ -0,0 +1,82 @@ +packer { + required_plugins { + utm = { + version = "= 4.0.0" + source = "github.com/naveenrajm7/utm" + } + } +} + +locals { + box_output = "${path.root}/../dist/boxes/rpi-keyboard-switcher-e2e-utm.box" + build_output = "${path.root}/../dist/packer/rpi-keyboard-switcher-e2e-utm" + cloud_image_url = "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img" + cloud_image_sha = "1ea801e659d2f5035ac294e0faab0aac9b6ba66753df933ba5c7beab0c689bd0" + cloud_init_source = "${path.root}/cloud-init" + tools_stage = "/tmp/rpi-keyboard-switcher-tools" +} + +source "utm-cloud" "e2e" { + iso_url = local.cloud_image_url + iso_checksum = "sha256:${local.cloud_image_sha}" + + vm_name = "RpiKeyboardSwitcher-E2E-Base" + vm_arch = "aarch64" + cpus = 2 + memory = 4096 + output_directory = local.build_output + hypervisor = true + resize_cloud_image = true + uefi_boot = true + display_nopause = true + boot_nopause = true + export_nopause = true + + use_cd = true + cd_label = "cidata" + cd_files = [ + "${local.cloud_init_source}/meta-data", + "${local.cloud_init_source}/network-config", + "${local.cloud_init_source}/user-data", + ] + + ssh_username = "vagrant" + ssh_password = "vagrant" + ssh_timeout = "10m" + + shutdown_command = "echo 'vagrant' | sudo -S /sbin/halt -h -p" +} + +build { + name = "rpi-keyboard-switcher-e2e-utm" + + sources = [ + "source.utm-cloud.e2e", + ] + + provisioner "shell" { + inline = [ + "mkdir -p ${local.tools_stage}", + ] + } + + provisioner "file" { + source = "${path.root}/../tools/pyproject.toml" + destination = "${local.tools_stage}/pyproject.toml" + } + + provisioner "file" { + source = "${path.root}/../tools/uv.lock" + destination = "${local.tools_stage}/uv.lock" + } + + provisioner "shell" { + execute_command = "echo 'vagrant' | {{ .Vars }} sudo -S -E bash '{{ .Path }}'" + script = "${path.root}/../scripts/provision-e2e-vm.sh" + } + + post-processor "utm-vagrant" { + compression_level = 9 + output = local.box_output + } +} diff --git a/scripts/provision-e2e-vm.sh b/scripts/provision-e2e-vm.sh new file mode 100755 index 0000000..438c5b0 --- /dev/null +++ b/scripts/provision-e2e-vm.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +go_version="${GO_VERSION:-1.26.3}" +go_linux_arm64_sha256="${GO_LINUX_ARM64_SHA256:-9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565}" +uv_version="${UV_VERSION:-0.9.22}" +uv_linux_arm64_sha256="${UV_LINUX_ARM64_SHA256:-2f8716c407d5da21b8a3e8609ed358147216aaab28b96b1d6d7f48e9bcc6254e}" +tools_source="${RPI_KEYBOARD_SWITCHER_TOOLS_SOURCE:-/tmp/rpi-keyboard-switcher-tools}" +cache_root="/var/cache/rpi-keyboard-switcher" +tools_env="/opt/rpi-keyboard-switcher-tools/.venv" + +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y --no-install-recommends \ + bluez \ + bluez-test-tools \ + build-essential \ + ca-certificates \ + curl \ + dbus \ + git \ + gobject-introspection \ + kmod \ + libcairo2-dev \ + libdbus-1-dev \ + libfuse3-dev \ + libgirepository1.0-dev \ + libgirepository-2.0-dev \ + pkg-config \ + procps \ + "linux-modules-extra-$(uname -r)" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +go_archive="go${go_version}.linux-arm64.tar.gz" +curl -fsSL "https://go.dev/dl/${go_archive}" -o "${tmp_dir}/${go_archive}" +printf '%s %s\n' "${go_linux_arm64_sha256}" "${tmp_dir}/${go_archive}" | sha256sum -c - +rm -rf /usr/local/go +tar -C /usr/local -xzf "${tmp_dir}/${go_archive}" +ln -sf /usr/local/go/bin/go /usr/local/bin/go +ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt + +uv_archive="uv-aarch64-unknown-linux-gnu.tar.gz" +curl -fsSL "https://github.com/astral-sh/uv/releases/download/${uv_version}/${uv_archive}" -o "${tmp_dir}/${uv_archive}" +printf '%s %s\n' "${uv_linux_arm64_sha256}" "${tmp_dir}/${uv_archive}" | sha256sum -c - +tar -C "${tmp_dir}" -xzf "${tmp_dir}/${uv_archive}" +install -m 0755 "${tmp_dir}/uv-aarch64-unknown-linux-gnu/uv" /usr/local/bin/uv +install -m 0755 "${tmp_dir}/uv-aarch64-unknown-linux-gnu/uvx" /usr/local/bin/uvx + +install -d -m 0755 "${cache_root}" "${cache_root}/go-build" "${cache_root}/go-mod" "${cache_root}/uv" +install -d -m 0755 /opt/rpi-keyboard-switcher-tools + +UV_CACHE_DIR="${cache_root}/uv" \ + UV_PROJECT_ENVIRONMENT="${tools_env}" \ + /usr/local/bin/uv --project "${tools_source}" --directory "${tools_source}" sync \ + --locked --managed-python --python 3.12 --extra runtime --no-dev + +cat >/etc/profile.d/go.sh <<'PROFILE' +export PATH=/usr/local/go/bin:$PATH +PROFILE +chmod 0644 /etc/profile.d/go.sh + +git config --global --add safe.directory /vagrant +sudo -u vagrant git config --global --add safe.directory /vagrant + +cat >/etc/modules-load.d/rpi-keyboard-switcher-e2e.conf <<'MODULES' +hci_vhci +cuse +MODULES +modprobe hci_vhci +modprobe cuse +test -e /dev/vhci +test -e /dev/cuse + +apt-get clean +rm -rf /var/lib/apt/lists/* "${tools_source}" From 4d3e3b4292b77f275690163a7b270c079ad5b2e9 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Mon, 18 May 2026 00:32:02 +0900 Subject: [PATCH 32/37] =?UTF-8?q?build:=20Packer=20UTM=20plugin=E3=81=AE?= =?UTF-8?q?=E5=B0=8E=E5=85=A5=E6=89=8B=E9=A0=86=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 13 +++- scripts/install-packer-utm-plugin.sh | 103 +++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100755 scripts/install-packer-utm-plugin.sh diff --git a/Makefile b/Makefile index 261bc90..b72dca1 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check vagrant-check vagrant-utm-plugin cuse-check ci e2e +.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check vagrant-check packer-utm-plugin vagrant-utm-plugin cuse-check ci e2e GOLANGCI_LINT := go tool golangci-lint +PACKER ?= packer VAGRANT ?= vagrant LOCAL_GOOS ?= $(shell go env GOOS) LOCAL_GOARCH ?= $(shell go env GOARCH) @@ -11,10 +12,11 @@ UV ?= uv TOOLS_PYTHON ?= 3.12 TOOLS_DIR := tools TOOLS_UV := $(UV) --project $(TOOLS_DIR) --directory $(TOOLS_DIR) -SHELL_SCRIPTS := scripts/hid-e2e.sh scripts/install-vagrant-utm-plugin.sh +SHELL_SCRIPTS := scripts/hid-e2e.sh scripts/install-packer-utm-plugin.sh scripts/install-vagrant-utm-plugin.sh scripts/provision-e2e-vm.sh PYTHON_TOOLS := hci-proxy.py bluez-agent.py bluez-pair.py PYTHON_SOURCES := $(PYTHON_TOOLS) lib stubs CUSE_TOOL := tools/hidraw-cuse.c +PACKER_UTM_PLUGIN_STAMP ?= dist/packer/.packer-utm-plugin-v4.0.0.installed all: build @@ -76,6 +78,13 @@ python-runtime-check: vagrant-check: ruby -c Vagrantfile +$(PACKER_UTM_PLUGIN_STAMP): scripts/install-packer-utm-plugin.sh + PACKER=$(PACKER) scripts/install-packer-utm-plugin.sh + mkdir -p $(dir $@) + touch $@ + +packer-utm-plugin: $(PACKER_UTM_PLUGIN_STAMP) + vagrant-utm-plugin: VAGRANT=$(VAGRANT) scripts/install-vagrant-utm-plugin.sh diff --git a/scripts/install-packer-utm-plugin.sh b/scripts/install-packer-utm-plugin.sh new file mode 100755 index 0000000..4b516aa --- /dev/null +++ b/scripts/install-packer-utm-plugin.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +packer_cmd="${PACKER:-packer}" +git_cmd="${GIT:-git}" +go_cmd="${GO:-go}" +plugin_source="${PACKER_UTM_PLUGIN_SOURCE:-github.com/naveenrajm7/utm}" +plugin_repo="${PACKER_UTM_PLUGIN_REPO:-https://github.com/naveenrajm7/packer-plugin-utm.git}" +plugin_version="${PACKER_UTM_PLUGIN_VERSION:-4.0.0}" +plugin_tag="${PACKER_UTM_PLUGIN_TAG:-v${plugin_version}}" + +log() { + printf 'packer-utm-plugin: %s\n' "$*" +} + +fail() { + printf 'packer-utm-plugin: %s\n' "$*" >&2 + exit 1 +} + +need_command() { + command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" +} + +need_command "${packer_cmd}" +need_command "${git_cmd}" +need_command "${go_cmd}" +need_command osacompile +need_command patch +need_command perl + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/rpi-keyboard-switcher-packer-utm.XXXXXX")" +trap 'rm -rf "${work_dir}"' EXIT + +src_dir="${work_dir}/packer-plugin-utm" +bin_path="${work_dir}/packer-plugin-utm-bin" + +log "cloning ${plugin_repo} ${plugin_tag}" +"${git_cmd}" clone --depth 1 --branch "${plugin_tag}" "${plugin_repo}" "${src_dir}" + +log "fixing malformed AppleScript continuation bytes" +perl -0pi -e 's/\xC2(?!\xAC)/\xC2\xAC/g' "${src_dir}"/builder/utm/common/scripts/*.applescript +osacompile -o "${work_dir}/create_vm.scpt" "${src_dir}/builder/utm/common/scripts/create_vm.applescript" +osacompile -o "${work_dir}/add_port_forwards.scpt" "${src_dir}/builder/utm/common/scripts/add_port_forwards.applescript" + +log "patching Packer SSH network setup" +( + cd "${src_dir}" + patch -p1 <<'PATCH' +diff --git a/builder/utm/common/step_port_forwarding.go b/builder/utm/common/step_port_forwarding.go +index a9e68a2..b4791da 100644 +--- a/builder/utm/common/step_port_forwarding.go ++++ b/builder/utm/common/step_port_forwarding.go +@@ -75,24 +75,9 @@ func (s *StepPortForwarding) Run(ctx context.Context, state multistep.StateBag) + return multistep.ActionHalt + } + +- // We now hard code interfaces as needed by Vagrant and Packer. +- // 0 index - 'Shared Network' interface +- // 1 index - 'Emulated VLAN' interface +- // but this should be configurable +- +- // Add access to localhost => UTM 'Shared Network' interface +- if _, err := driver.ExecuteOsaScript("add_network_interface.applescript", vmId, "ShRd"); err != nil { +- err := fmt.Errorf("error adding network interface: %s", err) +- state.Put("error", err) +- ui.Error(err.Error()) +- return multistep.ActionHalt +- } +- +- // TODO: check if we need to add the 'Shared Network' interface +- // TODO: check if we need to add the 'Emulated VLAN' interface +- // and then add if needed +- // Make sure to configure the network interface to 'Emulated VLAN' mode +- // required for port forwarding now in packer , later in vagrant ++ // Use one user-mode network interface while building the box. ++ // The Ubuntu cloud image brings its first NIC up with DHCP, and ++ // UTM host port forwarding is attached to this interface. + if _, err := driver.ExecuteOsaScript("add_network_interface.applescript", vmId, "EmUd"); err != nil { + err := fmt.Errorf("error adding network interface: %s", err) + state.Put("error", err) +@@ -106,7 +91,7 @@ func (s *StepPortForwarding) Run(ctx context.Context, state multistep.StateBag) + ui.Say(fmt.Sprintf("Creating forwarded port mapping for communicator (SSH, WinRM, etc) (host port %d)", commHostPort)) + command := []string{ + "add_port_forwards.applescript", vmId, +- "--index", "1", ++ "--index", "0", + fmt.Sprintf("TcPp,,%d,127.0.0.1,%d", guestPort, commHostPort), + } + if _, err := driver.ExecuteOsaScript(command...); err != nil { +PATCH +) + +log "building ${plugin_source} ${plugin_version}" +( + cd "${src_dir}" + "${go_cmd}" build \ + -ldflags "-s -w -X github.com/naveenrajm7/packer-plugin-utm/version.Version=${plugin_version} -X github.com/naveenrajm7/packer-plugin-utm/version.VersionPrerelease=" \ + -o "${bin_path}" +) + +log "installing patched plugin" +"${packer_cmd}" plugins install --force --path "${bin_path}" "${plugin_source}" From bf65caf770376ef0e48192610001d13f4c0e8a52 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Mon, 18 May 2026 00:33:02 +0900 Subject: [PATCH 33/37] =?UTF-8?q?ci:=20Packer=E8=A8=AD=E5=AE=9A=E3=82=92?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 5 +++++ Makefile | 9 +++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbc477d..15e060d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,11 @@ jobs: version: "0.9.22" enable-cache: true + - name: Setup Packer + uses: hashicorp/setup-packer@v3.1.0 + with: + version: "1.15.3" + - name: Install check dependencies run: | sudo apt-get update diff --git a/Makefile b/Makefile index b72dca1..cef68f1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check vagrant-check packer-utm-plugin vagrant-utm-plugin cuse-check ci e2e +.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check packer-check vagrant-check packer-utm-plugin vagrant-utm-plugin cuse-check ci e2e GOLANGCI_LINT := go tool golangci-lint PACKER ?= packer @@ -75,6 +75,11 @@ python-check: python-runtime-check: $(TOOLS_UV) run --locked --managed-python --python $(TOOLS_PYTHON) --extra runtime --no-dev python -c 'import dbus; import gi; from gi.repository import GLib; print(GLib.MainLoop)' +packer-check: + $(PACKER) fmt -check packer + $(PACKER) init packer + $(PACKER) validate packer + vagrant-check: ruby -c Vagrantfile @@ -92,7 +97,7 @@ cuse-check: pkg-config --exists fuse3 cc -Wall -Wextra -fsyntax-only $$(pkg-config --cflags fuse3) $(CUSE_TOOL) -ci: check race-test build mod-check script-check python-runtime-check vagrant-check cuse-check +ci: check race-test build mod-check script-check python-runtime-check packer-check vagrant-check cuse-check e2e: vagrant-utm-plugin VAGRANT=$(VAGRANT) scripts/hid-e2e.sh From 98d310398b8f4b40b7f7edc29f4d768163917f83 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Mon, 18 May 2026 00:33:37 +0900 Subject: [PATCH 34/37] =?UTF-8?q?fix:=20macOS=E3=81=A7CUSE=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E3=82=92=E5=AE=9F=E8=A1=8C=E3=81=97=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Makefile b/Makefile index cef68f1..f9d9f36 100644 --- a/Makefile +++ b/Makefile @@ -93,9 +93,14 @@ packer-utm-plugin: $(PACKER_UTM_PLUGIN_STAMP) vagrant-utm-plugin: VAGRANT=$(VAGRANT) scripts/install-vagrant-utm-plugin.sh +ifeq ($(LOCAL_GOOS),linux) cuse-check: pkg-config --exists fuse3 cc -Wall -Wextra -fsyntax-only $$(pkg-config --cflags fuse3) $(CUSE_TOOL) +else +cuse-check: + @echo "skip cuse-check: fuse3 CUSE check requires Linux ($(LOCAL_GOOS))" +endif ci: check race-test build mod-check script-check python-runtime-check packer-check vagrant-check cuse-check From 0c049756b27ef289305a905d13ca69bded79b7eb Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Mon, 18 May 2026 00:33:50 +0900 Subject: [PATCH 35/37] =?UTF-8?q?build:=20E2E=E7=94=A8box=E3=82=92Vagrant?= =?UTF-8?q?=E3=81=B8=E7=99=BB=E9=8C=B2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index f9d9f36..b6068ab 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ -.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check packer-check vagrant-check packer-utm-plugin vagrant-utm-plugin cuse-check ci e2e +.PHONY: all clean build fmt fmt-check lint lint-config vet test race-test check mod-check script-check python-fmt python-check python-runtime-check packer-check vagrant-check packer-utm-plugin vagrant-utm-plugin e2e-box cuse-check ci e2e GOLANGCI_LINT := go tool golangci-lint PACKER ?= packer +UTM_APP ?= /Applications/UTM.app +QEMU_IMG ?= $(shell find -L "$(UTM_APP)" -path '*/qemu-img.framework/Versions/*/qemu-img' -type f 2>/dev/null | head -n 1) VAGRANT ?= vagrant LOCAL_GOOS ?= $(shell go env GOOS) LOCAL_GOARCH ?= $(shell go env GOARCH) @@ -16,6 +18,10 @@ SHELL_SCRIPTS := scripts/hid-e2e.sh scripts/install-packer-utm-plugin.sh scripts PYTHON_TOOLS := hci-proxy.py bluez-agent.py bluez-pair.py PYTHON_SOURCES := $(PYTHON_TOOLS) lib stubs CUSE_TOOL := tools/hidraw-cuse.c +E2E_BOX_NAME ?= rpi-keyboard-switcher/e2e-ubuntu-24.04-arm64 +E2E_BOX_FILE ?= dist/boxes/rpi-keyboard-switcher-e2e-utm.box +E2E_BOX_STAMP ?= dist/boxes/.rpi-keyboard-switcher-e2e-utm.added +E2E_BOX_INPUTS := packer/e2e-utm.pkr.hcl packer/cloud-init/meta-data packer/cloud-init/network-config packer/cloud-init/user-data scripts/provision-e2e-vm.sh tools/pyproject.toml tools/uv.lock PACKER_UTM_PLUGIN_STAMP ?= dist/packer/.packer-utm-plugin-v4.0.0.installed all: build @@ -93,6 +99,19 @@ packer-utm-plugin: $(PACKER_UTM_PLUGIN_STAMP) vagrant-utm-plugin: VAGRANT=$(VAGRANT) scripts/install-vagrant-utm-plugin.sh +$(E2E_BOX_FILE): $(E2E_BOX_INPUTS) + mkdir -p $(dir $@) + $(PACKER) init packer + PACKER=$(PACKER) scripts/install-packer-utm-plugin.sh + PATH="$(dir $(QEMU_IMG)):$$PATH" $(PACKER) build -force packer/e2e-utm.pkr.hcl + +$(E2E_BOX_STAMP): $(E2E_BOX_FILE) + $(VAGRANT) box add --force --name $(E2E_BOX_NAME) $(E2E_BOX_FILE) + mkdir -p $(dir $@) + touch $@ + +e2e-box: $(E2E_BOX_STAMP) + ifeq ($(LOCAL_GOOS),linux) cuse-check: pkg-config --exists fuse3 @@ -104,5 +123,5 @@ endif ci: check race-test build mod-check script-check python-runtime-check packer-check vagrant-check cuse-check -e2e: vagrant-utm-plugin +e2e: vagrant-utm-plugin e2e-box VAGRANT=$(VAGRANT) scripts/hid-e2e.sh From 743d2d548d2322875e4d55915ddea19d706f7442 Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Mon, 18 May 2026 00:33:58 +0900 Subject: [PATCH 36/37] =?UTF-8?q?refactor:=20Vagrantfile=E3=82=92E2E?= =?UTF-8?q?=E7=94=A8box=E5=89=8D=E6=8F=90=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vagrantfile | 85 +++++------------------------------------------------ 1 file changed, 7 insertions(+), 78 deletions(-) diff --git a/Vagrantfile b/Vagrantfile index 78715f3..c15c078 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -1,76 +1,7 @@ -GO_VERSION = "1.26.3" -GO_LINUX_ARM64_SHA256 = "9d89a3ea57d141c2b22d70083f2c8459ba3890f2d9e818e7e933b75614936565" -UV_VERSION = "0.9.22" -UV_LINUX_ARM64_SHA256 = "2f8716c407d5da21b8a3e8609ed358147216aaab28b96b1d6d7f48e9bcc6254e" +E2E_BOX_NAME = ENV.fetch("KBD_E2E_BOX_NAME", "rpi-keyboard-switcher/e2e-ubuntu-24.04-arm64") -def provision_e2e_vm(config) - config.vm.synced_folder ".", "/vagrant" - config.vm.provision "shell", privileged: true, inline: <<-SHELL - set -eu - - export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get install -y --no-install-recommends \ - bluez \ - bluez-test-tools \ - build-essential \ - ca-certificates \ - curl \ - dbus \ - git \ - gobject-introspection \ - kmod \ - libcairo2-dev \ - libdbus-1-dev \ - libfuse3-dev \ - libgirepository1.0-dev \ - libgirepository-2.0-dev \ - pkg-config \ - procps \ - "linux-modules-extra-$(uname -r)" - - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT - - go_archive="go#{GO_VERSION}.linux-arm64.tar.gz" - curl -fsSL "https://go.dev/dl/${go_archive}" -o "${tmp_dir}/${go_archive}" - printf '%s %s\n' '#{GO_LINUX_ARM64_SHA256}' "${tmp_dir}/${go_archive}" | sha256sum -c - - rm -rf /usr/local/go - tar -C /usr/local -xzf "${tmp_dir}/${go_archive}" - ln -sf /usr/local/go/bin/go /usr/local/bin/go - ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt - - uv_archive="uv-aarch64-unknown-linux-gnu.tar.gz" - curl -fsSL "https://github.com/astral-sh/uv/releases/download/#{UV_VERSION}/${uv_archive}" -o "${tmp_dir}/${uv_archive}" - printf '%s %s\n' '#{UV_LINUX_ARM64_SHA256}' "${tmp_dir}/${uv_archive}" | sha256sum -c - - tar -C "$tmp_dir" -xzf "${tmp_dir}/${uv_archive}" - install -m 0755 "${tmp_dir}/uv-aarch64-unknown-linux-gnu/uv" /usr/local/bin/uv - install -m 0755 "${tmp_dir}/uv-aarch64-unknown-linux-gnu/uvx" /usr/local/bin/uvx - - install -d -m 0755 /opt/rpi-keyboard-switcher-tools - UV_PROJECT_ENVIRONMENT=/opt/rpi-keyboard-switcher-tools/.venv \ - /usr/local/bin/uv --project /vagrant/tools --directory /vagrant/tools sync \ - --locked --managed-python --python 3.12 --extra runtime --no-dev - - cat >/etc/profile.d/go.sh <<'PROFILE' -export PATH=/usr/local/go/bin:$PATH -PROFILE - chmod 0644 /etc/profile.d/go.sh - git config --global --add safe.directory /vagrant - sudo -u vagrant git config --global --add safe.directory /vagrant - - cat >/etc/modules-load.d/rpi-keyboard-switcher-e2e.conf <<'MODULES' -hci_vhci -cuse -MODULES - modprobe hci_vhci - modprobe cuse - test -e /dev/vhci - test -e /dev/cuse - SHELL -end - -def configure_utm(vm, name) +def configure_e2e_vm(vm, name) + vm.vm.synced_folder ".", "/vagrant" vm.vm.provider "utm" do |utm| utm.name = name utm.cpus = 2 @@ -80,19 +11,17 @@ def configure_utm(vm, name) end Vagrant.configure("2") do |config| - config.vm.box = "bento/ubuntu-24.04" - config.vm.box_architecture = "arm64" + config.vm.box = E2E_BOX_NAME + config.vm.box_check_update = false config.vm.define "central" do |central| central.vm.hostname = "rpi-keyboard-switcher-central" central.vm.network "forwarded_port", guest: 45550, host: 45560, auto_correct: false - configure_utm(central, "RpiKeyboardSwitcher E2E Central") - provision_e2e_vm(central) + configure_e2e_vm(central, "RpiKeyboardSwitcher E2E Central") end config.vm.define "peripheral" do |peripheral| peripheral.vm.hostname = "rpi-keyboard-switcher-peripheral" - configure_utm(peripheral, "RpiKeyboardSwitcher E2E Peripheral") - provision_e2e_vm(peripheral) + configure_e2e_vm(peripheral, "RpiKeyboardSwitcher E2E Peripheral") end end From edcea816ec9795489a626da3d49f44791c2c48da Mon Sep 17 00:00:00 2001 From: RarkHopper Date: Mon, 18 May 2026 00:34:06 +0900 Subject: [PATCH 37/37] =?UTF-8?q?refactor:=20HID=20E2E=E3=81=AE=E8=B5=B7?= =?UTF-8?q?=E5=8B=95=E6=89=8B=E9=A0=86=E3=82=92=E5=88=86=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/hid-e2e.sh | 73 +++++++++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/scripts/hid-e2e.sh b/scripts/hid-e2e.sh index 4d140d2..b645f35 100755 --- a/scripts/hid-e2e.sh +++ b/scripts/hid-e2e.sh @@ -50,12 +50,15 @@ REMOTE done } +# Ensure both VMs are running before test services are started. start_vms() { need_command "${vagrant_cmd}" log "starting Vagrant VMs" "${vagrant_cmd}" up --provider="${vagrant_provider}" central peripheral } +# Reset one VM to a clean Bluetooth state so previous test runs cannot pair or +# report input through stale processes. reset_bluetooth_host() { local vm="$1" vm_sudo "$vm" <<'REMOTE' @@ -80,6 +83,8 @@ rm -rf /var/lib/bluetooth/* REMOTE } +# Start BlueZ on one VM and verify that hci0 is present, powered, LE-only, and +# connectable. start_bluez_adapter() { local vm="$1" vm_sudo "$vm" <<'REMOTE' @@ -128,9 +133,8 @@ btmgmt_cmd 'connectable on' REMOTE } -start_central() { +start_central_hci_bridge() { log "starting central Bluetooth host" - reset_bluetooth_host central vm_sudo central <<'REMOTE' set -euo pipefail @@ -158,9 +162,9 @@ tools_uv python hci-proxy.py bridge \ --unix-path /tmp/bt-server-le >/tmp/hci-bridge.log 2>&1 & tools_uv python hci-proxy.py client 127.0.0.1 --port 45550 >/tmp/hci-client.log 2>&1 & REMOTE +} - start_bluez_adapter central - +start_central_pairing_agent() { vm_sudo central <<'REMOTE' set -euo pipefail tools_uv() { @@ -177,9 +181,15 @@ grep -q '^agent registered ' /tmp/bluez-agent.log REMOTE } -start_peripheral() { +prepare_central() { + reset_bluetooth_host central + start_central_hci_bridge + start_bluez_adapter central + start_central_pairing_agent +} + +start_peripheral_hci_client() { log "starting peripheral BLE keyboard" - reset_bluetooth_host peripheral vm_sudo peripheral </tmp/hci-client.log 2>&1 & REMOTE +} - start_bluez_adapter peripheral - +start_peripheral_hid_keyboard() { vm_sudo peripheral <<'REMOTE' set -euo pipefail cd /vagrant -GOCACHE=/tmp/go-cache GOMODCACHE=/tmp/go-mod /usr/local/go/bin/go build -o /tmp/kbd-hid ./cmd/kbd-hid +GOCACHE=/var/cache/rpi-keyboard-switcher/go-build \ + GOMODCACHE=/var/cache/rpi-keyboard-switcher/go-mod \ + /usr/local/go/bin/go build -o /tmp/kbd-hid ./cmd/kbd-hid cflags="$(pkg-config fuse3 --cflags)" libs="$(pkg-config fuse3 --libs)" cc -Wall -Wextra -O2 -o /tmp/hidraw-cuse ./tools/hidraw-cuse.c $cflags $libs -pthread @@ -235,6 +247,16 @@ grep -q 'Advertisement registered' /tmp/bluetoothd.log REMOTE } +# Prepare the peripheral so it advertises a BLE HID keyboard backed by the fake +# hidraw device. +prepare_peripheral() { + reset_bluetooth_host peripheral + start_peripheral_hci_client + start_bluez_adapter peripheral + start_peripheral_hid_keyboard +} + +# Read the peripheral adapter address that the central VM must pair with. peripheral_address() { vm_sudo peripheral <<'REMOTE' | awk '/^addr / { print $2; exit }' set -euo pipefail @@ -245,7 +267,9 @@ btmgmt info | awk ' REMOTE } -pair_central() { +# Pair central with peripheral and verify BlueZ reports paired, connected, and +# trusted states. +pair_central_with_peripheral() { local mac="$1" log "pairing central with ${mac}" vm_sudo central <&2 || true fail "peripheral address was empty" fi - pair_central "$mac" - wait_for_central_input "$mac" - capture_input - trigger_input - verify_input + pair_central_with_peripheral "$mac" + wait_for_central_evdev_keyboard "$mac" + start_central_input_capture + trigger_peripheral_hidraw_report + verify_central_key_a_input log "passed: virtual HCI pair, BLE HID notification, hidraw report, and evdev KEY_A press/release" }