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/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdcfd3a..15e060d 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,31 @@ 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: Setup Packer + uses: hashicorp/setup-packer@v3.1.0 + with: + version: "1.15.3" + + - 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 \ + libgirepository1.0-dev \ + libgirepository-2.0-dev \ + pkg-config \ + ruby \ + shellcheck + - name: Check - run: make check + run: make ci diff --git a/.gitignore b/.gitignore index 71bbc64..9053d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /bin/ /dist/ /tmp/ +/packer_cache/ *.out *.test @@ -9,3 +10,4 @@ coverage.out .DS_Store .idea/ .vscode/ +.vagrant/ 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/.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/Makefile b/Makefile index fe07470..b6068ab 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,33 @@ -.PHONY: build fmt lint lint-config test check +.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) RPI_GOOS ?= linux RPI_GOARCH ?= arm64 +SHELLCHECK ?= shellcheck +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-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 +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 + +clean: + rm -rf dist tools/.mypy_cache tools/.ruff_cache tools/.venv tools/__pycache__ tools/lib/__pycache__ build: mkdir -p dist @@ -14,6 +37,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 ./... @@ -21,7 +49,79 @@ lint: 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 python-check + +mod-check: + go mod tidy + git diff --exit-code -- go.mod go.sum + +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: + $(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: + $(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 + +$(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 + +$(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 + 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 + +e2e: vagrant-utm-plugin e2e-box + VAGRANT=$(VAGRANT) scripts/hid-e2e.sh diff --git a/README.ja.md b/README.ja.md index 275d03e..615e37f 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 の hidraw デバイスから読んだ USB HID report を送ります。 ## コマンド @@ -19,18 +19,19 @@ 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` が `/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 を読む -> /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` は `/dev/hidraw*` を読みます。手動確認では `sudo kbd-hid ...` で実行し、systemd unit も root で起動します。 バイナリを `/usr/local/bin` に置きます。 @@ -123,6 +134,7 @@ hid: appearance: keyboard pairable: true discoverable: true + hidraw_device: /dev/hidraw0 ``` 接続先が保存されると、次のような項目が追加されます。 @@ -146,6 +158,7 @@ targets: - `hid.appearance`: HID の appearance。現在は `keyboard` のみ対応しています。 - `hid.pairable`: true または未指定なら、ペアリング要求を受け付けます。 - `hid.discoverable`: true または未指定なら、アダプタを discoverable にします。 +- `hid.hidraw_device`: 読み取る hidraw デバイス。USB キーボードに対応する `/dev/hidrawN` を指定します。 接続先名に使える文字は英数字、`_`、`-`、`.` だけです。未知の YAML フィールドはエラーにします。 @@ -157,18 +170,32 @@ Bluetooth に触る前に、`kbd-hid` が読む設定を確認します。 kbd-hid inspect --config /etc/kbd-switch/config.yaml ``` +USB キーボードを Raspberry Pi に挿し、hidraw デバイス名を確認します。 + +```sh +ls -l /dev/hidraw* +udevadm info --query=all --name=/dev/hidraw0 +``` + +USB キーボードに対応する `hidraw` を `hid.hidraw_device` に書きます。`udevadm info` の `ID_INPUT_KEYBOARD=1` や `HID_NAME` を確認して選びます。 + +```yaml +hid: + 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でテキストエディタなどの入力欄を開いてから、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 アドレスは通常そのままにします。 +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,13 +305,80 @@ eval "$(kbd-rpi completion bash)" ## セキュリティ -Raspberry Pi はキー入力の経路上に置かれます。信頼できない Raspberry Pi を使うと、キー入力の読み取り、保存、変更、注入が可能になります。業務PCや管理対象PCでは、所有者または管理者の許可なしに使わないでください。 +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 を入れます。 + +```sh +brew tap hashicorp/tap +brew install hashicorp/tap/hashicorp-vagrant +brew install --cask utm +``` + +Mac 側から検証を実行します。このコマンドは `third_party/vagrant_utm` から UTM provider をビルドして Vagrant の project-local plugin として入れ、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 +``` ## 開発 +`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 \ + libgirepository1.0-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 環境で実行する場合は、次を使います。 + +```sh +make ci +``` diff --git a/README.md b/README.md index ea78659..60022d6 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 USB HID reports read from a Raspberry Pi hidraw device after the host subscribes to HID notifications. ## Commands @@ -20,18 +20,19 @@ 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 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 -> 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 `/dev/hidraw*`. 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 + hidraw_device: /dev/hidraw0 ``` 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.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. @@ -157,18 +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 hidraw device name: + +```sh +ls -l /dev/hidraw* +udevadm info --query=all --name=/dev/hidraw0 +``` + +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: + 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 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 `/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 @@ -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,13 +305,80 @@ 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. +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 and UTM on the Mac: + +```sh +brew tap hashicorp/tap +brew install hashicorp/tap/hashicorp-vagrant +brew install --cask utm +``` + +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: -Input logging is not part of the initial implementation and should stay off by default. +```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 +`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 \ + libgirepository1.0-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: + +```sh +make ci +``` diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..c15c078 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,27 @@ +E2E_BOX_NAME = ENV.fetch("KBD_E2E_BOX_NAME", "rpi-keyboard-switcher/e2e-ubuntu-24.04-arm64") + +def configure_e2e_vm(vm, name) + vm.vm.synced_folder ".", "/vagrant" + 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 = 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_e2e_vm(central, "RpiKeyboardSwitcher E2E Central") + end + + config.vm.define "peripheral" do |peripheral| + peripheral.vm.hostname = "rpi-keyboard-switcher-peripheral" + configure_e2e_vm(peripheral, "RpiKeyboardSwitcher E2E Peripheral") + end +end 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/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 < 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, @@ -125,25 +173,38 @@ 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 } -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, } } @@ -185,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()) } } @@ -194,10 +255,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 } } @@ -205,6 +263,17 @@ func (app *HIDApplication) SendReports(reports [][]byte) error { return nil } +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 @@ -220,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 @@ -231,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 @@ -239,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 @@ -255,17 +324,18 @@ 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 } -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) { @@ -282,20 +352,24 @@ 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() - advertisement := NewHIDAdvertisement(options.Name, options.Appearance) + app := NewHIDApplication(HIDApplicationOptions{ + ReportMap: options.ReportMap, + InputReportIDs: options.InputReportIDs, + OutputReportIDs: options.OutputReportIDs, + }) + advertisement := NewHIDAdvertisement(options.Name, options.Appearance, options.Discoverable) agent := NewAgent(options.Log) if err := app.Export(conn); err != nil { @@ -311,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") @@ -345,7 +419,7 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { }() readyErrors := make(chan error, 1) - if len(options.TestReports) > 0 || options.OnPeerReady != nil { + if options.OnPeerReady != nil || options.InputReports != nil { go func() { if err := app.WaitForSubscription(ctx); err != nil { if !errors.Is(err, context.Canceled) { @@ -364,8 +438,10 @@ func (DBusDaemon) Run(ctx context.Context, options DaemonOptions) error { return } } - if err := app.SendReports(options.TestReports); err != nil { - readyErrors <- err + if options.InputReports != nil { + if err := options.InputReports(ctx, app.SendInputReport); err != nil && !errors.Is(err, context.Canceled) { + readyErrors <- err + } } }() } @@ -382,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_" @@ -425,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 @@ -472,23 +548,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 fmt.Errorf("%w: 0x%02X", errUnknownInputReportID, report.ID) + } + fallbackPath := dbus.ObjectPath("") + if 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] @@ -498,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...) @@ -506,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 { @@ -539,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...) @@ -557,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() { @@ -576,7 +694,7 @@ func (characteristic *Characteristic) StopNotify() *dbus.Error { return nil } -func (characteristic *Characteristic) Confirm() *dbus.Error { +func (*Characteristic) Confirm() *dbus.Error { return nil } @@ -614,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) { @@ -643,13 +761,13 @@ 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 } -func reportMap() []byte { +func defaultReportMap() []byte { return []byte{ 0x05, 0x01, 0x09, 0x06, @@ -694,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) @@ -707,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 4cbb4a7..b63b3ef 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}) } @@ -112,6 +114,76 @@ 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 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{} @@ -139,3 +211,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) + } +} 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 ad60c25..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"` } @@ -58,6 +76,7 @@ type HIDConfig struct { Appearance string `yaml:"appearance"` Pairable *bool `yaml:"pairable,omitempty"` Discoverable *bool `yaml:"discoverable,omitempty"` + HIDRawDevice string `yaml:"hidraw_device"` } func DefaultLocalConfigPath() (string, error) { @@ -108,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) } @@ -134,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 { @@ -146,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 @@ -187,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 @@ -213,13 +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 errHIDRawDeviceRequired + } + if hasControl(hid.HIDRawDevice) { + return errHIDRawDeviceControlChar } return nil @@ -257,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/config/config_test.go b/internal/config/config_test.go index b07e262..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,33 @@ targets: bluetooth_mac: AA:BB:CC:DD:EE:02 hid: appearance: mouse + hidraw_device: /dev/hidraw0 +`) + + if _, err := config.LoadRPI(path); err == nil { + t.Fatal("err = nil, want error") + } +} + +func TestRaspberryPi側設定はHIDrawデバイスを読める(t *testing.T) { + path := writeConfig(t, ` +hid: + hidraw_device: /dev/hidraw0 +`) + + cfg, err := config.LoadRPI(path) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if cfg.HID.HIDRawDevice != "/dev/hidraw0" { + t.Fatalf("hidraw_device = %q, want /dev/hidraw0", cfg.HID.HIDRawDevice) + } +} + +func TestRaspberryPi側設定は空のHIDrawデバイスを拒否する(t *testing.T) { + path := writeConfig(t, ` +hid: + hidraw_device: "" `) if _, err := config.LoadRPI(path); err == 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 580219a..8da05eb 100644 --- a/internal/hidapp/app.go +++ b/internal/hidapp/app.go @@ -2,6 +2,7 @@ package hidapp import ( "context" + "errors" "fmt" "io" "os" @@ -9,15 +10,26 @@ 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" +) + +var ( + errUnknownFlag = errors.New("unknown flag") + errFlagRequiresValue = errors.New("flag requires a value") ) type HIDDaemon interface { Run(ctx context.Context, options bluez.DaemonOptions) error } +type InputForwarder interface { + Descriptor() (input.Descriptor, error) + Run(ctx context.Context, send func(input.Report) error) error +} + type App struct { Daemon HIDDaemon + Input InputForwarder Context context.Context Stdout io.Writer Stderr io.Writer @@ -39,11 +51,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") @@ -59,7 +71,6 @@ func (app App) Run(args []string) int { type cliOptions struct { configPath string - testText string command string operands []string } @@ -78,17 +89,8 @@ 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) + return cliOptions{}, fmt.Errorf("%w: %s", errUnknownFlag, arg) case options.command == "": options.command = arg default: @@ -102,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 @@ -119,19 +121,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 @@ -152,6 +155,13 @@ 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()) + _, _ = 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(), "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) _, _ = fmt.Fprintf(app.stdout(), "service_uuid: %s\n", bluez.HIDServiceUUID) @@ -159,14 +169,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, + 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) }, @@ -174,10 +194,10 @@ func (app App) daemonOptions(configPath string, cfg config.RPIConfig, reports [] } } -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{} @@ -194,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 { @@ -238,17 +262,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(), + } +} + +func reportIDsString(ids []byte) string { + if len(ids) == 0 { + return "" + } + + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, fmt.Sprintf("0x%02X", id)) } - return hidreport.Bytes(reports), nil + return strings.Join(parts, ", ") } func (app App) daemonRunner() HIDDaemon { diff --git a/internal/hidapp/app_test.go b/internal/hidapp/app_test.go index ab44c9f..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 { @@ -25,14 +26,34 @@ func (daemon *fakeDaemon) Run(_ context.Context, options bluez.DaemonOptions) er return daemon.err } +type fakeInput struct { + descriptor input.Descriptor + reports []input.Report +} + +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 + } + } + + return nil +} + func TestHIDCLIはdaemonで設定からBLEkeyboardを起動する(t *testing.T) { configPath := writeConfig(t) daemon := &fakeDaemon{} 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) @@ -52,16 +73,51 @@ 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.InputReportIDs, []byte{0x02}) { + t.Fatalf("InputReportIDs = %#v, want [2]", daemon.options.InputReportIDs) } - if !reflect.DeepEqual(daemon.options.TestReports, wantReports) { - t.Fatalf("reports = %#v, want %#v", daemon.options.TestReports, wantReports) + 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") } + if daemon.options.InputReports == nil { + t.Fatal("InputReports is nil") + } +} + +func TestHIDCLIはUSBキーボード入力をBLEreportへ渡す(t *testing.T) { + configPath := writeConfig(t) + daemon := &fakeDaemon{} + wantReport := bluez.InputReport{ID: 0x02, Data: []byte{0x00, 0x00, 0x04}} + + code := hidapp.App{ + Daemon: daemon, + 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 []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, []bluez.InputReport{wantReport}) { + t.Fatalf("reports = %#v, want %#v", gotReports, []bluez.InputReport{wantReport}) + } } func TestHIDCLIはinspectで実際に使うBLE設定を出す(t *testing.T) { @@ -69,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"}) @@ -80,6 +137,9 @@ func TestHIDCLIはinspectで実際に使うBLE設定を出す(t *testing.T) { "adapter: hci1\n", "name: Desk Bridge\n", "appearance: keyboard (0x03C1)\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)) { @@ -94,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"}) @@ -132,6 +193,7 @@ hid: appearance: keyboard pairable: true discoverable: true + hidraw_device: /dev/hidraw0 `) if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatal(err) @@ -139,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..5e867dd --- /dev/null +++ b/internal/input/descriptor.go @@ -0,0 +1,126 @@ +package input + +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 + 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{}, errEmptyReportDescriptor + } + + 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{}, errTruncatedLongItem + } + size := int(reportMap[index]) + index += 2 + if index+size > len(reportMap) { + return Descriptor{}, errTruncatedLongItemPayload + } + 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{}, errTruncatedShortItemPayload + } + value := reportMap[index : index+size] + index += size + + if itemType == 1 && tag == 8 { + if len(value) != 1 { + return Descriptor{}, errInvalidReportIDSize + } + if value[0] == 0x00 { + return Descriptor{}, errZeroReportID + } + 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{}, errNoInputReports + } + + 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..3332d60 --- /dev/null +++ b/internal/input/descriptor_test.go @@ -0,0 +1,97 @@ +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") + } +} + +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_linux.go b/internal/input/forwarder_linux.go new file mode 100644 index 0000000..67a1f4a --- /dev/null +++ b/internal/input/forwarder_linux.go @@ -0,0 +1,135 @@ +//go:build linux + +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 +} + +func (forwarder Forwarder) Descriptor() (Descriptor, error) { + fd, err := unix.Open(forwarder.Device, unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + return Descriptor{}, fmt.Errorf("open hidraw device %s: %w", forwarder.Device, err) + } + defer func() { + _ = unix.Close(fd) + }() + + return readDescriptor(fd, forwarder.Device) +} + +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 hidraw device %s: %w", forwarder.Device, err) + } + defer func() { + _ = unix.Close(fd) + }() + + 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)) + + pollFD, err := pollFileDescriptor(fd) + if err != nil { + return err + } + buffer := make([]byte, 4096) + for { + select { + case <-ctx.Done(): + return nil + default: + } + + ready, err := unix.Poll([]unix.PollFd{{Fd: pollFD, Events: unix.POLLIN}}, 250) + if errors.Is(err, unix.EINTR) { + continue + } + if err != nil { + return fmt.Errorf("poll hidraw device %s: %w", forwarder.Device, err) + } + if ready == 0 { + continue + } + + n, err := unix.Read(fd, buffer) + 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("%w: %s", errHIDRawClosed, forwarder.Device) + } + + 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.IoctlGetInt(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("%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 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 + } + + _, _ = 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..8c06f67 --- /dev/null +++ b/internal/input/forwarder_other.go @@ -0,0 +1,24 @@ +//go:build !linux + +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) Descriptor() (Descriptor, error) { + return Descriptor{}, ErrUnsupportedOS +} + +func (Forwarder) Run(_ context.Context, _ func(Report) error) error { + return ErrUnsupportedOS +} 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/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) 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) 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/hid-e2e.sh b/scripts/hid-e2e.sh new file mode 100755 index 0000000..b645f35 --- /dev/null +++ b/scripts/hid-e2e.sh @@ -0,0 +1,423 @@ +#!/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 + 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 +} + +# 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' +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 -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 +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' +set -euo pipefail + +for _ in $(seq 1 300); do + [ -d /sys/class/bluetooth/hci0 ] && break + sleep 0.2 +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 + +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 + 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 + +btmgmt_cmd() { + { + printf 'select 0\n' + printf '%s\n' "$1" + printf 'quit\n' + } | script -qfec btmgmt /dev/null >>/tmp/btmgmt.log 2>&1 +} + +btmgmt_cmd 'power off' +btmgmt_cmd 'le on' +btmgmt_cmd 'bredr off' +btmgmt_cmd 'power on' +btmgmt_cmd 'connectable on' +REMOTE +} + +start_central_hci_bridge() { + log "starting central Bluetooth host" + 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 & +tools_uv() { + 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 "$@" +} + +for _ in $(seq 1 100); do + [ -S /tmp/bt-server-le ] && break + sleep 0.1 +done +[ -S /tmp/bt-server-le ] + +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 & +tools_uv python hci-proxy.py client 127.0.0.1 --port 45550 >/tmp/hci-client.log 2>&1 & +REMOTE +} + +start_central_pairing_agent() { + vm_sudo central <<'REMOTE' +set -euo pipefail +tools_uv() { + 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 "$@" +} +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 +done +grep -q '^agent registered ' /tmp/bluez-agent.log +REMOTE +} + +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" + vm_sudo peripheral </tmp/hci-client.log 2>&1 & +REMOTE +} + +start_peripheral_hid_keyboard() { + vm_sudo peripheral <<'REMOTE' +set -euo pipefail +cd /vagrant +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 + +/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 +} + +# 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 +btmgmt info | awk ' + $1 == "hci0:" { found = 1; next } + found && $1 == "addr" { print "addr " $2; exit } +' +REMOTE +} + +# 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 </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 the paired BLE HID keyboard to appear as an evdev input device on +# central. +wait_for_central_evdev_keyboard() { + 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 +} + +# Start readers for central evdev and hidraw output, then verify the readers are +# ready before injecting input. +start_central_input_capture() { + 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) & +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' & +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 the fake peripheral hidraw device to send one keyboard report. +trigger_peripheral_hidraw_report() { + log "triggering fake hidraw keyboard" + vm_sudo peripheral <<'REMOTE' +set -euo pipefail +rm -f /tmp/send-report +touch /tmp/send-report +REMOTE +} + +# Verify central received both hidraw reports and the KEY_A press/release evdev +# events. +verify_central_key_a_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 + prepare_central + prepare_peripheral + mac="$(peripheral_address)" + if [ -z "$mac" ]; then + print_logs >&2 || true + fail "peripheral address was empty" + fi + 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" +} + +main "$@" 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}" 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/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}" diff --git a/third_party/vagrant_utm b/third_party/vagrant_utm new file mode 160000 index 0000000..f70cc80 --- /dev/null +++ b/third_party/vagrant_utm @@ -0,0 +1 @@ +Subproject commit f70cc807ea833ef0b067ca32e474d896d4591114 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/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. diff --git a/tools/bluez-agent.py b/tools/bluez-agent.py new file mode 100644 index 0000000..16a00fe --- /dev/null +++ b/tools/bluez-agent.py @@ -0,0 +1,121 @@ +#!/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, + GMainLoopProxy, + bluez_object, + call_dbus, + call_loop, + dbus_interface, + system_bus, +) + +BLUEZ = "org.bluez" +AGENT_MANAGER = "org.bluez.AgentManager1" +AGENT = "org.bluez.Agent1" +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) -> None: + print("agent released", flush=True) + call_loop(loop, "quit") + + @dbus.service.method(AGENT, in_signature="o", out_signature="s") + 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: 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: 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: str) -> int: + print(f"request passkey device={device}", flush=True) + return cast(int, dbus.UInt32(0)) + + @dbus.service.method(AGENT, in_signature="ou", out_signature="") + 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: str) -> None: + print(f"authorize pairing device={device}", flush=True) + + @dbus.service.method(AGENT, in_signature="os", out_signature="") + 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) -> None: + print("request canceled", flush=True) + + +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 = system_bus() + Agent(bus.raw, AGENT_PATH) + + 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: int, _frame: FrameType | None) -> None: + manager.unregister_agent(AGENT_PATH) + call_loop(loop, "quit") + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + call_loop(loop, "run") + + +loop: GMainLoop = GMainLoopProxy(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..f60f22d --- /dev/null +++ b/tools/bluez-pair.py @@ -0,0 +1,198 @@ +#!/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, +) + +OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager" +PROPERTIES = "org.freedesktop.DBus.Properties" +ADAPTER = "org.bluez.Adapter1" +DEVICE = "org.bluez.Device1" + + +@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 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(client: BlueZClient, address: str) -> DeviceSnapshot | None: + want = address.upper() + for path, interfaces in client.managed_objects().items(): + props = interfaces.get(DEVICE) + if props and str(props.get("Address", "")).upper() == want: + return DeviceSnapshot(path, props) + return None + + +def wait_for_device(client: BlueZClient, address: str, timeout: float) -> DeviceSnapshot: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + 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 bool_text(value: DBusValue) -> str: + 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() -> None: + 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() + + 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_device = find_device(client, address) + if existing_device is not None: + with contextlib.suppress(dbus.DBusException): + adapter_proxy.remove_device(existing_device.path) + + client.set_prop(adapter, ADAPTER, "Powered", dbus_true()) + + adapter_proxy.start_discovery() + try: + device = wait_for_device(client, address, discover_timeout) + finally: + with contextlib.suppress(dbus.DBusException): + adapter_proxy.stop_discovery() + + device_proxy = client.device(device.path) + + props = client.get_props(device.path, DEVICE) + if not bool(props.get("Paired", False)): + device_proxy.pair(timeout=connect_timeout) + + client.set_prop(device.path, DEVICE, "Trusted", dbus_true()) + + props = client.get_props(device.path, DEVICE) + if not bool(props.get("Connected", False)): + device_proxy.connect(timeout=connect_timeout) + + deadline = time.monotonic() + connect_timeout + while time.monotonic() < deadline: + 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 = 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) + 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) diff --git a/tools/hci-proxy.py b/tools/hci-proxy.py new file mode 100755 index 0000000..d90434d --- /dev/null +++ b/tools/hci-proxy.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import select +import selectors +import signal +import socket +import time +from types import FrameType +from typing import cast + +HCI_PRIMARY = 0x00 + + +def h4_packet_length(buf: bytes) -> int | None: + 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: bytes) -> tuple[list[bytes], bytes]: + packets: list[bytes] = [] + 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: int, data: bytes) -> None: + view = memoryview(data) + while 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: socket.socket, data: bytes) -> None: + 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() -> 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: 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)) + except OSError as error: + last_error = error + connection.close() + 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: socket.socket, right: socket.socket) -> None: + 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(): + source = cast(socket.socket, key.fileobj) + destination = cast(socket.socket, key.data) + try: + data = source.recv(4096) + except BlockingIOError: + continue + if not data: + return + send_all_socket(destination, data) + + +def reap_children(_signum: int, _frame: FrameType | None) -> None: + while True: + try: + pid, _ = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + return + except InterruptedError: + continue + if pid == 0: + return + + +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) + 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) + 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() + raw_proxy(client, upstream) + os._exit(0) + client.close() + upstream.close() + + +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") + 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": + send_all_socket(connection, 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() -> None: + 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() 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); +} diff --git a/tools/lib/bluez_dbus.py b/tools/lib/bluez_dbus.py new file mode 100644 index 0000000..45dba7b --- /dev/null +++ b/tools/lib/bluez_dbus.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +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() + + def quit(self) -> None: + self.raw.quit() + + +def system_bus() -> DBusConnection: + return SystemBusConnection(dbus.SystemBus()) + + +def bluez_object(bus: DBusConnection, path: str) -> DBusRemoteObject: + return bus.get_object(path) + + +def dbus_interface(obj: DBusRemoteObject, interface: str) -> DBusProxy: + return DBusInterface(dbus.Interface(obj.raw, interface)) + + +def call_dbus(proxy: DBusProxy, method_name: str, *args: DBusValue) -> DBusValue: + return proxy.call(method_name, *args) + + +def call_dbus_with_timeout(proxy: DBusProxy, method_name: str, timeout: float) -> None: + proxy.call_with_timeout(method_name, timeout) + + +def call_loop(loop: GMainLoop, method_name: str) -> None: + 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: + return cast(DBusValue, dbus.Boolean(True)) diff --git a/tools/pyproject.toml b/tools/pyproject.toml new file mode 100644 index 0000000..55a425d --- /dev/null +++ b/tools/pyproject.toml @@ -0,0 +1,69 @@ +[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", + "pyright>=1.1.407,<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 = "stubs:." +disallow_subclassing_any = false +disallow_untyped_decorators = false 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 new file mode 100644 index 0000000..473741d --- /dev/null +++ b/tools/uv.lock @@ -0,0 +1,178 @@ +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 = "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" +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 = "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" +source = { virtual = "." } + +[package.optional-dependencies] +runtime = [ + { name = "dbus-python" }, + { name = "pygobject" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pyright" }, + { 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 = "pyright", specifier = ">=1.1.407,<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" }, +]