Raspberry Pi Zero 2 W でキーボード入力 ～質問に答えるキーボード～

---

# はじめに

[前回の記事](https://qiita.com/sukimaengineer/items/be988ac5778ab93db621) では、Raspberry Pi Zero 2 W を USB HID (g_hid) として動作させ、**クライアントからの入力**（gRPC）と **Bluetooth キーボードの入力**（evdev）を **同時に** USB キーボードとしてターゲットPCに送信する仕組みを構築しました。  

本記事では、その**同じハードウェア構成**を使って、**「質問に答えてくれるスマートキーボード」** を実装してみます。  

具体的には、

1. 通常は Bluetooth キーボードの入力をそのまま USB キーボード出力（パススルー）  
2. 入力テキストの中に**質問パターン**（例: Q[...英語のみ...]＋改行 ）を検出すると  
3. **ChatGPT API** を呼び出して回答を取得  
4. その回答(英語のみ)を自動的に USB HID 出力する。

という流れを Python スクリプト上で制御します。  

つまり、**入力テキスト中に特定のパターン（"Q[...]\n" など）を検出したら、ChatGPT API を呼び出して回答を取得し、** 取得後は**回答を USB キーボード入力**としてターゲットPCに送信する、という仕組みです。  

:::note info
注意
JISキーボードには対応していますが、質問と回答の日本語対応は難しいので英語のみです。詳しくは Q7 を参照ください。
ハードウェア構成や g_hid の設定、などの詳細は [前回の記事](https://qiita.com/sukimaengineer/items/be988ac5778ab93db621) をご参照ください。
:::
---

## 2. システム構成図

以下の Mermaid 図に、本記事で解説する「スマートキーボード」の要素を示します。

```mermaid
flowchart TD
    A[Bluetooth<br>キーボード 入力<br>evdev] --> B[入力バッファ<br>テキスト保持]
    subgraph Raspberry Pi Zero 2 W
    B -->|通常入力| D[USB HID出力<br>/dev/hidg0]
    B -->|"質問パターン検出 Q[...]\n"| E[ChatGPT API 呼び出し]
    F --> D
    end
    D --> G[ターゲットPC<br>USBキーボード認識]
    E -->|"質問(英語のみ)"| H[Open AI]
    H -->|"回答(英語のみ)"| F[回答テキスト取得]

```

- **Bluetoothキーボード入力 (evdev):** 物理キーボードからのキーイベントを受け取る  
- **ネットワーク入力（gRPC）:** 前回記事の構成を流用可能（任意）  
- **入力バッファ:** 受け取ったテキストを蓄積し、質問パターンを検出  
- **質問パターン検出:** ここではシンプルに `Q[...]` + 改行を正規表現でチェック  
- **ChatGPT API呼び出し:** 入力された質問文を ChatGPT に投げて回答を得る  
- **回答テキスト取得 → USB HID出力:** ChatGPT の回答をまとめてキーボード出力  
- **ターゲットPC:** 物理キーボードとして入力が届くので、メモ帳などに自動で文字が打ち込まれるイメージ  

---

## 3. システム全体の概要

### 3.1 全体のフロー

1. **Bluetoothキーボードからの入力**を随時、**入力バッファ**に蓄積しながら、同時に**USB HID**へパススルー（通常のキー入力として動作）します。  
2. **入力バッファ**内で、行末（改行）まで受け取ったテキストを正規表現でスキャンし、"Q[...]"\n という形式の質問文があれば検出します。  
3. 質問が見つかったら、**ChatGPT API** を別スレッドで呼び出し、回答を取得します。  
   - この時、**`dev.grab()`** を使っている間は、Raspberry Pi 側へのキーボード入力を遮断（USB HID 出力だけ有効）にするなどの工夫を行えます。  
   - API コールは数秒程度かかる場合があるため、**非同期スレッド**で処理し、メインループが止まらないようにするのがおすすめです。  
   - API 呼び出し時の**排他制御**として、Python の `threading.Lock` などを使って `/dev/hidg0` への書き込みを保護します。  
4. **ChatGPT の回答テキスト**を取得後、**USB HID**出力としてまとめて送信し、ターゲットPC上で自動的に文字が打ち込まれます。  
5. 回答送信が完了したら、通常状態（パススルー）に戻ります。  

### 3.2 ポイント

- **質問を入力するときも普通にキーボードで打ち込むだけ**（"Q[質問文]\n" の形式）。  
- 検出後は**Pi 側で ChatGPT に問い合わせ**、回答を**キー入力として転送**。  
- ユーザーはターゲットPCでメモ帳やターミナルなどを開いて待っているだけで、**自動で回答文が流れ込む**イメージです。

---

## 4. サンプルコード

ここでは、**前回のJIS配列対応コード**をベースに、**以下の機能**を追加したサンプルを示します。

1. **入力バッファ**で文字列を管理し、**正規表現で "Q[...]" を検出**する  
2. **ChatGPT API**（`openai` ライブラリ）呼び出しを行う  
3. **API 呼び出し中は `dev.grab()`** を使って Pi 側への入力を遮断する  
4. **回答が返ってきたら USB HID**出力に送る  
5. **排他制御（`threading.Lock`）**を使い、複数スレッドからの `/dev/hidg0` への書き込みが競合しないようにする  
6. **JIS配列の英語入力に合わせて `(`、`)`、`:`、`;` など一部記号を追加サポート**  

> **前提**:  
> - `pip3 install evdev openai` などでライブラリをインストールしておきましょう。  
> - **OpenAI API キー**が必要です（環境変数 `OPENAI_API_KEY` でセットするか、コード内で指定してください）。  
> - ハードウェア設定（USB HID + Bluetooth ペアリング）は**前回記事**と同様です。  
> - **実際の物理キーボードやOSのJIS認識状況によっては、記号がずれる場合があります。** 必要に応じて調整してください。

以下、コードがやや長くなりますが、要所にコメントを記載しています。  
**冒頭の `char_to_usage_id_with_shift` 関数に、コロン・セミコロン・丸括弧などを追加**し、英語文章で一般的に使う記号を少し増やしています。

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
自動質問応答付き「スマートキーボード」サンプルコード

- 前回の JIS配列対応コードをベースに、
  - 質問パターン検出 ("Q[...]\n")
  - ChatGPT への問い合わせ
  - 回答文の USB HID 出力
  - 排他制御 (threading.Lock)
  - dev.grab() で Pi 側入力遮断
  - さらに JISキーボードで英語の記号 (;, :, (, )) 等にも対応
"""

import time
import struct
import re
import threading
import os

import evdev
from evdev import ecodes
import openai

#=====================================================================
# ChatGPT API の設定
#=====================================================================
openai.api_key = os.getenv("OPENAI_API_KEY", "YOUR_API_KEY_HERE")

#=====================================================================
# 1. Modifierキー として扱うキーコード → ビットマスク (JIS配列例: 前回記事同様)
#=====================================================================
MODIFIER_MAP = {
    ecodes.KEY_LEFTCTRL:   0x01,
    ecodes.KEY_LEFTSHIFT:  0x02,
    ecodes.KEY_LEFTALT:    0x04,
    ecodes.KEY_LEFTMETA:   0x08,
    ecodes.KEY_RIGHTCTRL:  0x10,
    ecodes.KEY_RIGHTSHIFT: 0x20,
    ecodes.KEY_RIGHTALT:   0x40,  # AltGr
    ecodes.KEY_RIGHTMETA:  0x80,
}

#=====================================================================
# 2. 通常キー (JIS配列を想定) → Usage ID (前回記事と同様)
#=====================================================================
KEYCODE_MAP = {
    ecodes.KEY_A: 0x04, ecodes.KEY_B: 0x05, ecodes.KEY_C: 0x06,
    ecodes.KEY_D: 0x07, ecodes.KEY_E: 0x08, ecodes.KEY_F: 0x09,
    ecodes.KEY_G: 0x0A, ecodes.KEY_H: 0x0B, ecodes.KEY_I: 0x0C,
    ecodes.KEY_J: 0x0D, ecodes.KEY_K: 0x0E, ecodes.KEY_L: 0x0F,
    ecodes.KEY_M: 0x10, ecodes.KEY_N: 0x11, ecodes.KEY_O: 0x12,
    ecodes.KEY_P: 0x13, ecodes.KEY_Q: 0x14, ecodes.KEY_R: 0x15,
    ecodes.KEY_S: 0x16, ecodes.KEY_T: 0x17, ecodes.KEY_U: 0x18,
    ecodes.KEY_V: 0x19, ecodes.KEY_W: 0x1A, ecodes.KEY_X: 0x1B,
    ecodes.KEY_Y: 0x1C, ecodes.KEY_Z: 0x1D,

    ecodes.KEY_1: 0x1E, ecodes.KEY_2: 0x1F, ecodes.KEY_3: 0x20,
    ecodes.KEY_4: 0x21, ecodes.KEY_5: 0x22, ecodes.KEY_6: 0x23,
    ecodes.KEY_7: 0x24, ecodes.KEY_8: 0x25, ecodes.KEY_9: 0x26,
    ecodes.KEY_0: 0x27,

    ecodes.KEY_ENTER:      0x28,
    ecodes.KEY_ESC:        0x29,
    ecodes.KEY_BACKSPACE:  0x2A,
    ecodes.KEY_TAB:        0x2B,
    ecodes.KEY_SPACE:      0x2C,

    ecodes.KEY_MINUS:      0x2D,  # '-' 
    ecodes.KEY_CARET:      0x2E,  # '^'
    ecodes.KEY_AT:         0x2F,  # '@'
    ecodes.KEY_LEFTBRACE:  0x30,  # '['
    ecodes.KEY_YEN:        0x31,  # '¥'
    ecodes.KEY_SEMICOLON:  0x33,  # ';'
    ecodes.KEY_COLON:      0x34,  # ':'
    ecodes.KEY_GRAVE:      0x35,  # '`'
    ecodes.KEY_COMMA:      0x36,  # ','
    ecodes.KEY_DOT:        0x37,  # '.'
    ecodes.KEY_SLASH:      0x38,  # '/'

    ecodes.KEY_F1:  0x3A, ecodes.KEY_F2:  0x3B, ecodes.KEY_F3:  0x3C,
    ecodes.KEY_F4:  0x3D, ecodes.KEY_F5:  0x3E, ecodes.KEY_F6:  0x3F,
    ecodes.KEY_F7:  0x40, ecodes.KEY_F8:  0x41, ecodes.KEY_F9:  0x42,
    ecodes.KEY_F10: 0x43, ecodes.KEY_F11: 0x44, ecodes.KEY_F12: 0x45,

    ecodes.KEY_SYSRQ:      0x46,  # PrintScreen
    ecodes.KEY_SCROLLLOCK: 0x47,
    ecodes.KEY_PAUSE:      0x48,
}

#=====================================================================
# HID レポート作成関数
#=====================================================================
def build_hid_report(modifier_byte, pressed_keys):
    report = [0]*8
    report[0] = modifier_byte
    report[1] = 0x00
    idx = 2
    for usage_id in list(pressed_keys)[:6]:
        report[idx] = usage_id
        idx += 1
    return struct.pack('8B', *report)

#=====================================================================
# /dev/hidg0 への排他制御用 Lock
#=====================================================================
hid_write_lock = threading.Lock()

def write_hid_report(hid_file, modifier_byte, pressed_keys):
    report = build_hid_report(modifier_byte, pressed_keys)
    with hid_write_lock:
        hid_file.write(report)
        hid_file.flush()
    time.sleep(0.005)

#=====================================================================
# SHIFT を考慮した英語記号対応版
#=====================================================================
def char_to_usage_id_with_shift(ch):
    """
    JISキーボード配列で、英語文章に使われる代表的な文字を SHIFT 対応付きでマッピング。
    - 大文字A-Z, 小文字a-z, 数字0-9
    - 記号: ! ? . , ( ) : ; など
    """
    # A-Z (大文字)
    if 'A' <= ch <= 'Z':
        base = ord(ch) - ord('A')
        return (0x02, 0x04 + base)  # SHIFT + [a-z usage]
    # a-z (小文字)
    if 'a' <= ch <= 'z':
        base = ord(ch) - ord('a')
        return (0x00, 0x04 + base)
    # 0-9 (数字)
    if '0' <= ch <= '9':
        map_digits = {
            '0':0x27, '1':0x1e, '2':0x1f, '3':0x20, '4':0x21,
            '5':0x22, '6':0x23, '7':0x24, '8':0x25, '9':0x26
        }
        return (0x00, map_digits[ch])

    # 改行
    if ch == '\n':
        return (0x00, 0x28)
    # スペース
    if ch == ' ':
        return (0x00, 0x2C)
    # ピリオド
    if ch == '.':
        return (0x00, 0x37)
    # カンマ
    if ch == ',':
        return (0x00, 0x36)

    # 感嘆符 (!): SHIFT + 1
    if ch == '!':
        return (0x02, 0x1e)
    # クエスチョン (?): SHIFT + /
    if ch == '?':
        return (0x02, 0x38)

    # コロン (:): SHIFT + セミコロン (0x33)
    if ch == ':':
        return (0x02, 0x33)
    # セミコロン (;): そのまま 0x33
    if ch == ';':
        return (0x00, 0x33)

    # 丸括弧 ((), ) は JIS だと SHIFT+8=(, SHIFT+9=)
    # 例: '(' => SHIFT + '8' => SHIFT(0x02), usage(0x25)
    if ch == '(':
        return (0x02, 0x25)
    if ch == ')':
        return (0x02, 0x26)

    # ここに他の記号 (^, _, +, 等) を追加する場合あり

    # 未対応
    return None

def send_string_to_usb_hid_enhanced(hid_file, text):
    """
    SHIFT 対応版で1文字ずつ入力。
    未対応文字はスペースに置き換え。
    """
    for ch in text:
        info = char_to_usage_id_with_shift(ch)
        if info is None:
            # 未対応 → スペース
            info = (0x00, 0x2C)

        modifier, usage_id = info

        # KeyDown
        with hid_write_lock:
            hid_file.write(build_hid_report(modifier, {usage_id}))
            hid_file.flush()
        time.sleep(0.005)

        # KeyUp
        with hid_write_lock:
            hid_file.write(build_hid_report(0x00, set()))
            hid_file.flush()
        time.sleep(0.005)

#=====================================================================
# ChatGPT 呼び出し → HID 出力の流れ
#=====================================================================
def chatgpt_worker(question_text, dev, hidg_path):
    try:
        # Pi 側への入力を遮断
        dev.grab()
        answer_text = call_chatgpt_api(question_text)
        with open(hidg_path, "wb") as hid:
            # ChatGPT の回答を1文字ずつタイプ入力
            send_string_to_usb_hid_enhanced(hid, answer_text + "\n")
    finally:
        dev.ungrab()

def call_chatgpt_api(question):
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[{"role": "user", "content": question}],
            temperature=0.7,
        )
        return response["choices"][0]["message"]["content"]
    except Exception as e:
        return f"Error: {e}"

#=====================================================================
# メイン (Bluetooth キーボードのイベントを受け取り)
#=====================================================================
def main():
    device_path = "/dev/input/event3"  # 例: Bluetooth キーボード
    hidg_path = "/dev/hidg0"          # USB HID 出力先

    dev = evdev.InputDevice(device_path)
    print(f"Using input device: {device_path} ({dev.name})")
    print(f"Writing HID reports to: {hidg_path}")

    input_buffer = ""
    pressed_keys = set()
    modifier_state = 0x00

    with open(hidg_path, "wb") as hid:
        for event in dev.read_loop():
            if event.type == ecodes.EV_KEY:
                if event.value not in (0,1):
                    # 0=UP,1=DOWN,2=REPEAT
                    continue
                is_pressed = (event.value == 1)

                # (A) Modifierキー
                if event.code in MODIFIER_MAP:
                    bitmask = MODIFIER_MAP[event.code]
                    if is_pressed:
                        modifier_state |= bitmask
                    else:
                        modifier_state &= ~bitmask

                # (B) 通常キー
                elif event.code in KEYCODE_MAP:
                    usage_id = KEYCODE_MAP[event.code]
                    if is_pressed:
                        pressed_keys.add(usage_id)
                        # 入力バッファにも文字を追加してみる（簡易ASCII化）
                        pseudo_char = usage_id_to_char(usage_id)
                        if pseudo_char:
                            input_buffer += pseudo_char
                            if pseudo_char == '\n':
                                check_and_call_chatgpt(input_buffer, dev, hidg_path)
                                input_buffer = ""
                    else:
                        pressed_keys.discard(usage_id)

                # HID レポート送信
                write_hid_report(hid, modifier_state, pressed_keys)

def usage_id_to_char(usage_id):
    """
    ここは単に「バッファ用のASCII化」を雑にやっているだけ。
    大文字・記号の判定はしていない。
    """
    if usage_id == 0x28:
        return '\n'
    if usage_id == 0x2C:
        return ' '
    # a-z
    if 0x04 <= usage_id <= 0x1d:
        return chr((usage_id - 0x04) + ord('a'))
    # 0-9
    map_digits = {
        0x27:'0', 0x1e:'1', 0x1f:'2', 0x20:'3', 0x21:'4',
        0x22:'5', 0x23:'6', 0x24:'7', 0x25:'8', 0x26:'9'
    }
    if usage_id in map_digits:
        return map_digits[usage_id]

    return ""

def check_and_call_chatgpt(text_buffer, dev, hidg_path):
    pattern = r'Q\[(.*?)\]\n'
    match = re.search(pattern, text_buffer, re.DOTALL)
    if match:
        question_text = match.group(1).strip()
        print(f"Detected question: {question_text}")
        th = threading.Thread(target=chatgpt_worker, args=(question_text, dev, hidg_path))
        th.start()

if __name__ == "__main__":
    main()
```
> **注意:**  
> - 本サンプルはあくまで**代表的な記号**に対するキー操作を実装しているのみです。  
> - **JISキーボードでの記号割り当ては環境や機種によって微妙に異なる**場合があります。  
> - もし「`^` や `` ` `` が出ない」「`{` が必要」「`_` が必要」など追加要望がある場合は、同様のやり方でさらにマッピングを追加してください。  


### コードのポイント

1. **`char_to_usage_id_with_shift()`**:  
   - `(` → SHIFT + `8` (0x25)  
   - `)` → SHIFT + `9` (0x26)  
   - `:` → SHIFT + セミコロン (0x33)  
   - `;` → そのまま 0x33  
   - `?` → SHIFT + `/` (0x38)  
   - `!` → SHIFT + `1` (0x1e)  
   - など、JIS配列の英語入力で一般的な割り当てを定義  
2. **`send_string_to_usb_hid_enhanced()`**:  
   - 上記のマッピングで `modifier=0x02`（左Shift）を使うかどうかを決め、KeyDown → KeyUp の順で送信  
3. **`usage_id_to_char()`** (メインループ内のバッファ用):  
   - Shift などは一切考慮せず、**“a～z” など最小限の文字判定**だけを行っている  
   - **質問文入力**を判定するための簡易処理なので、細かい記号変換は行わない  

---

## 5. よくある質問

### Q1. 質問形式の入力方法は？

**`"Q[...]\n"` と入力するだけ**でOKです。  
例えば：  

```
Q[What is the difference between (A) and (B)?]
```

と入力して Enter キーを押すと、正規表現で検出し、ChatGPT に問い合わせます。  
（今回のサンプルでは $Q[...]$ の末尾に改行が必須です。）  

### Q2. Bluetoothキーボードの通常入力と自動応答は同時に動きますか？

はい。通常入力は**随時パススルー**されますし、質問検出時には**別スレッド**で ChatGPT を呼び出します。  
ただし、ChatGPT API 呼び出し中に `dev.grab()` を行うと、**Pi 側（ローカル）のキー入力は遮断**されるので注意してください。  
（USB HID には引き続き出力されます。）

### Q3. 文字変換の精度や、Shift・記号への対応を拡張したい

現状でも **`(`、`)`、`;`、`:`、`?`、`!`** などを JIS配列想定でサポートしましたが、**まだ網羅的ではありません**。  
必要に応じて、**他の記号**（`[ ] { } ^ - _ + " ' < >` など）を追加してください。  
- 「JIS配列でどのキーと SHIFT を組み合わせると何になるか？」はキーボードや環境によって微妙に違う場合があります。  
- `evtest` や実機で確認しながらマッピングを調整すると良いでしょう。  

### Q4. ChatGPT API 呼び出しが遅い場合はどうすれば？

ChatGPT API はインターネット経由のため、数秒遅延する場合があります。  
その間にキー入力を続けるとローカル操作が阻害される恐れがあるため、  
- **`dev.grab()` の呼び出しをしない**（遮断しない）  
- **回答取得後にだけ一括で文字を送る**  
- **別プロセスで処理し、完了を通知する**  

などの設計が考えられます。  

### Q5. 他にも複数の入力ソース（例：gRPC）を扱いたい場合は？

前回の記事のように**gRPC サーバを同時に動かす**ことも可能です。  
- **gRPC 入力**と**Bluetooth キーボード入力**の両方をバッファに流し込み、同一ロジックで**質問検出**しても良い  
- あるいは、**複数のバッファ**を用意して入力ソースごとに別々に管理しても良い  
- いずれにしても `/dev/hidg0` への書き込みタイミングが競合しないように、**排他やイベントキュー**を整備しましょう。

### Q6. Q[...] 以外にもっといい方法はありますか？

必ずしも `"Q[...]"` の形式に固定する必要はなく、**どのような形式・トリガーで「質問入力」を判定するか**は自由に設計できます。  
たとえば、次のような例があります。  

- **特殊文字やキーワードを使う** (`"Q:"` で始まる行など)  
- **特定のキー入力をトリガーにする** (F1 キーや `Ctrl+Shift+Q` で確定)  
- **自然言語的に `?` を検出する** (英語なら簡易実装可)  
- **タグや JSON 形式で明示する**  
- **CLI 的に「コマンド + 引数」で指定** (`:ask "Hello?"` など)

---

### Q7. このコードで日本語の質問と回答も行えますか？

結論としては、**ChatGPT への問い合わせそのものは日本語でも行えます**し、ChatGPT からも日本語で回答を得られます。ただし、**「ターゲットPCに日本語変換されて入力された質問をChatGPTに送る」「回答をそのまま日本語文字としてUSB HID出力する」** ためには追加の実装が必要です。

1. **ChatGPT は多言語対応**  
   - `"Q[東京の人口は何人ですか？]"` のように日本語を送れば、API は日本語で回答できます。  

2. **USB HID キーボードでの日本語入力は複雑**  
   - 単にキーコードを送るだけでは「あ」「い」「う」などの直接入力はできず、**ローマ字入力＋IME変換**が必要になるケースが多いです。  
   - IME を切り替えたり変換確定したりするために、**無変換/変換キーの押下やスペース連打**などが必要で、ロジックが複雑化します。  

3. **現行サンプルコードはASCII中心**  
   - `char_to_usage_id_with_shift` は英数と記号にしか対応していないため、日本語には未対応。  

もし**日本語の文字をそのまま物理キーボード経由で入力したい**なら、**IME制御**などさらに大掛かりな実装が必要です。  
本記事では英語入力への対応が主眼なので、日本語は**ChatGPTへの問い合わせ文・回答文**としては扱えても、**キー入力としては未対応**と割り切る形にしています。

---

### Q8. 大文字 (Uppercase) や ? ! (などの英語記号) を打ち込みたいけど、まだ記号が足りない場合は？

上記サンプルで `(`、`)`、`:`、`;` などにも対応しましたが、まだすべての記号は網羅していません。必要に応じて `char_to_usage_id_with_shift()` の中に **「Shift + ??? = 記号」** を追加してください。  

- **JIS配列のキー配置**は US配列より複雑で、`@` や `^`、 `[` `]` などの位置が違います。  
- 実際にキーを押して `evtest` などでどのキーコード (ecodes.KEY_???) を得るか確認し、それを SHIFT と組み合わせた時にどの記号が出るかを検証すると確実です。  

---

## 6. まとめ

以上のように、**前回の記事で構築したハードウェア構成**（Raspberry Pi Zero 2 W + USB HID + Bluetooth キーボード）を流用して、**質問に答えてくれるスマートキーボード**を実装できます。  

- **入力バッファ**にテキストをためて **"Q[...]"** を検知  
- **ChatGPT API**で回答を生成し  
- **USB HID**として**回答文を自動入力**  
- **英語の記号**や**大文字**もある程度は対応（JIS配列想定）  

---

以上