0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-15

はじめに

前回の記事 では、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に送信する、という仕組みです。

注意
JISキーボードには対応していますが、質問と回答の日本語対応は難しいので英語のみです。詳しくは Q7 を参照ください。
ハードウェア構成や g_hid の設定、などの詳細は 前回の記事 をご参照ください。


2. システム構成図

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

  • 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 APIopenai ライブラリ)呼び出しを行う
  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 関数に、コロン・セミコロン・丸括弧などを追加し、英語文章で一般的に使う記号を少し増やしています。

#!/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配列想定)

以上

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?