キーボードの古いkeybowをHAのスイッチ化したよ

Home Assistant・IoT

古いRaspberry Pi Zero搭載の古いPimoroniの10キーのキーボード Keybowがウチに転がっていたので、なんとか有効に使える方法はないだろうか?と考えた時にHomeAssistantでスイッチとして使えないかな?と思いやってみました。

結果は大成功!メモ的にHomeAssistantを電源やスイッチとして成功した方法を紹介します。

Pimoroni Keybowとは?

放置状態のKeybow

Keybowは今まで使い道がなかったので、適当に壁の磁石のボードに付けておきました。

右中央あたりに3キーのKeybowもあります

USB接続して電源は入っていますが、基本的に飾り状態のKeybow。

同じく磁石のボードにくっつけてあるRaspberry Pi 5は2TBのSSD3台を接続してファイルサーバーとして、あとHomeAssistantサーバーとして使っています。

キーキャップはメルカリでテンキー用のを買いました。このキーキャップはなかなか見た目が良かった。

Pimoroni Keybowの種類

まずはPimoroniの12キーや3キーのKeybowを紹介します。

一番新しいのはKeybow2040ですが、持っていないので詳しくはわかりません。より改造しやすくなっているようですね。

モデル名Keybow MINIKeybowKeybow 2040
キー配置3キー 1 × 3 列12キー 3 × 4 列16キー 4 × 4 列
コントローラーRaspberry Pi Zero系
(背面に別途装着)
RP2040チップ
(基板上に内蔵)
接続端子Micro USBUSB Type-C
キースイッチKailhメカニカル(ホットスワップ)
バックライトLEDAPA102 RGB LED
ファームウェアKeybow OS
(Luaベース)
Keybow OS
(Luaベース)
CircuitPython
Pimoroni直販価格約 £22.50
(※生産終了)
約 £50.00前後
(※生産終了)
約 £39.00
(現行販売中)
国内相場(参考)約 3,500円〜4,500円約 7,000円〜9,000円約 8,500円〜9,500円

※価格は1ポンド=190円で計算。価格にはRaspberry Pi Zero本体の代金は含まれていません。

Keybow(12キー)とKeybow MINI(3キー)を持っていますが、今回は12キーのKeybowをHomeAssistantのスイッチとして使います。(10キーとしては使い道がなかったので)

KeybowをHomeAssistantで使えるようにする

古いKeybowなのであまり役に立たない情報かもしれませんが、新しめのKeybow 2040の参考になるかもしれませんのでメモ程度に書いておきます。

必要なもの

HomeAssistantをすでに構築していることが前提になります。

Raspberry Pi 5にRaspberry OSを焼いてその上にHomeAssistant Superviser(アプリ・HACSが使えるようになる)をインストールしたらDockerにインストールされました。

  • HomeAssistantサーバー
  • Keybow 12キー
  • Raspberry Pi Zero(Raspberry Pi Zero V1.3だったら別途Bluetoothドングル必要)
  • Bluetoothドングル(Raspberry Pi Zero WHであれば不要)OTAハブで接続

困ったのはOTA使用可のUSBハブでないとBluetoothドングルが使えず、直接Raspberry Pi Zero V1.3の真ん中のMicroUSBポートにさしても使えず、OTAが使えるUSBケーブルでも自分の持っているBluetoothドングルが使えなかった。なので最初に使えていたUSBハブ(OTA)をそのまま使うことにした。

Raspberry Pi OS Liteで運用、SDカードにインストール

Keybowをキーボードとして使うには通常Keybow OSを使いますが、HomeAssistantで使うので、Raspberry Pi OS Lite(Legacy 32bit)を使います。

必要な設定

PythonスクリプトファイルをRaspberry Pi Zeroに1つ、HomeAssistantサーバー(Raspberry Pi 5)に1つと、HomeAssistantのyaml2箇所に追記します。

  • Raspberry Pi 5:HomeAssistant上ではなくRaspberry OSに自分のホームにPythonファイルを置きます。
  • HomeAssistant(YAMLファイル2つ):Raspberry Pi 5にインストールしたHomeAssistantのautomations.yaml、Configration.yamlの2つのファイルを追記、アプリのFile editorを使うとHomeAssistantのサイトから追記可能
  • Keybow(Raspberry Pi Zero):自分のホームにPythonファイルを置きます。

HomeAssistant(Raspberry Pi 5)側の設定

Pythonスクリプト

まずは自分のホーム(自分の場合は/home/go)にPythonファイルを置きます。

Path:/home/go/bt-keybow.py エンコードはUTF-8、改行コードはLinuxのLFで保存しておいてください。(/home/goは自分の場合はgoユーザーなので置き換えてください。)

import socket
import threading
import time
import requests
import json
import re

last_sent_cmds = {}

# --- 設定項目 ---
BT_PORT = 1 
LISTEN_PORT = 5000 
HA_URL = "http://127.0.0.1:8123/api/webhook/keybow_event"

server_sock = None
client_sock = None

def send_to_home_assistant(payload_dict):
    """HAのWebhookへ辞書データを投げる"""
    try:
        requests.post(HA_URL, json=payload_dict, timeout=5)
        print(f"Sent to HA: {payload_dict}")
    except Exception as e:
        print(f"HA send error: {e}")

def handle_network_commands():
    """HAからの指示(curl)をZeroへ流す"""
    global client_sock
    net_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    net_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    net_sock.bind(('0.0.0.0', LISTEN_PORT))
    net_sock.listen(5)
    print(f"Network listener started on port {LISTEN_PORT}")

    while True:
        try:
            conn, addr = net_sock.accept()
            raw_data = conn.recv(1024).decode('utf-8')
            if raw_data:
                # 正規表現で LED:数字:状態 または LED_数字_状態 を抽出
                # 例: GET /LED:11:ON HTTP/1.1 -> LED:11:ON を見つける
                #match = re.search(r'LED[:_]\d+[:_](?:ON|OFF)', raw_data)
                # 数字、カンマ、ON/OFF すべてを通すように変更
                match = re.search(r'LED[:_]\d+[:_](?:ON|OFF|[\d,]+)', raw_data)
                if match:
                    cmd = match.group().replace('_', ':')
                    btn_num = cmd.split(':')[1]

                    # デバッグ用のプリント
                    print(f"Accepted command: {cmd}")
                    
                    # 前回の命令と同じなら、2秒以内は無視する
                    if last_sent_cmds.get(btn_num) == cmd:
                        continue 
                    
                    if client_sock:
                        try:
                            client_sock.sendall((cmd + "\n").encode('utf-8'))
                            print(f"Final Command to Zero: {cmd}")
                            last_sent_cmds[btn_num] = cmd
                        except Exception as e:
                            client_sock = None

                else:
                    print(f"Debug: No pattern match in: {raw_data.strip().splitlines()[0]}")
            conn.close()
        except Exception as e:
            print(f"Network error: {e}")

def main():
    global server_sock, client_sock
    
    # ネットワークスレッド開始
    threading.Thread(target=handle_network_commands, daemon=True).start()

    server_sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
    
    try:
        server_sock.bind(("00:00:00:00:00:00", BT_PORT))
        server_sock.listen(1)
    except OSError as e:
        print(f"Bind error: {e}")
        return

    while True:
        print("Waiting for Zero to connect via Bluetooth...")
        try:
            client_sock, client_info = server_sock.accept()
            print(f"Zero connected: {client_info}")

            # 受信バッファの残りを保持する変数
            buffer = ""
            while True:
                data_raw = client_sock.recv(1024).decode('utf-8')
                if not data_raw:
                    break
                
                buffer += data_raw
                # 改行区切りで1メッセージずつ処理(重要!)
                while "\n" in buffer:
                    line, buffer = buffer.split("\n", 1)
                    line = line.strip()
                    if not line: continue
                    
                    print(f"From Zero: {line}")
                    
                    try:
                        if line.startswith('{'):
                            payload = json.loads(line)
                        else:
                            payload = {"event": line, "click_type": "single"}
                        
                        threading.Thread(target=send_to_home_assistant, args=(payload,), daemon=True).start()
                    except json.JSONDecodeError:
                        print(f"Invalid JSON received: {line}")

        except Exception as e:
            print(f"Bluetooth connection lost: {e}")
        finally:
            if client_sock:
                client_sock.close()
                client_sock = None
            time.sleep(1)

if __name__ == "__main__":
    main()

HomeAssistant(Raspberry Pi 5)でservice登録

上記Pythonスクリプトをsystemdサービスに登録してラズパイが再起動しても自動で起動するようにします。

sudo vim /etc/systemd/system/bt_keybow.service
[Unit]
Description=Bluetooth Receiver for Keybow
After=bluetooth.target

[Service]
ExecStart=/usr/bin/python3 /home/go/bt-keybow.py
WorkingDirectory=/home/go
StandardOutput=inherit
StandardError=inherit
Restart=always
User=go

[Install]
WantedBy=multi-user.target
sudo systemctl start bt_keybow.service

Keybow側の設定

こちらはRaspberry OSの自分のホーム(例:/home/go)にPythonスクリプトを置きます。

KeybowのPythonスクリプト

Path:/home/go/keybow-ha.py

スクリプトが長いのはバックライトのグラデーションをレインボーなどといろいろ変更できるようにしているからです。

import keybow
import socket
import time
import colorsys
import math
import random
import threading
import json

# --- 設定項目 ---
TARGET_ADDRESS = "D8:3A:DD:D5:54:6A" 
PORT = 1

led_mode = "animation" 
anim_type = 0         
brightness = 0.4      
sock = None
button_states = [None] * 12
pressed_keys = [False] * 12
connecting = False

# クリック判定用
click_counts = [0] * 12
last_press_time = [0] * 12
timers = [None] * 12

def connect_to_pi5():
    global sock, connecting
    if connecting: return
    connecting = True
    try:
        new_sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
        new_sock.settimeout(5)
        new_sock.connect((TARGET_ADDRESS, PORT))
        new_sock.settimeout(None)
        sock = new_sock
        print("Bluetooth Connected to Pi 5!")
        threading.Thread(target=receive_loop, daemon=True).start()
    except:
        sock = None
    finally:
        connecting = False

def receive_loop():
    global sock, led_mode, button_states, anim_type
    while True:
        try:
            if sock is None: break
            data_raw = sock.recv(1024).decode('utf-8', errors='ignore')
            if not data_raw: break
            for line in data_raw.strip().split('\n'):
                if line.startswith("LED:"):
                    parts = line.split(":")
                    if len(parts) >= 3:
                        idx = int(parts[1])
                        val = parts[2]
                        
                        if val == "OFF":
                            button_states[idx] = None
                        elif val == "ON":
                            button_states[idx] = (255, 255, 255)
                        else:
                            # "R,G,B" の形式で送られてきた色を解析
                            try:
                                r, g, b = map(int, val.split(','))
                                button_states[idx] = (r, g, b)
                            except:
                                # 解析に失敗したら白を点灯させる
                                button_states[idx] = (255, 255, 255)
        except: break
    sock = None


#星空で使う pixel_colors# 各LEDの状態を保存するリスト [色相(h), 明るさ(v)]
pixel_colors = [[0.0, 0.0] for _ in range(12)]

#バックライト キー1を押すごとに変更
def get_anim_rgb(i, step, anim_idx):
    x, y = i % 3, i // 3
    t = step / 20.0
    
    # 明るさを一括で30%程度に設定(255 * 0.3 ≒ 80)
    local_brightness = brightness if 'brightness' in globals() else 80

    # すべて anim_idx で統一
    if anim_idx == 0: # レインボー
        h = (step / 100.0 + i / 12.0) % 1.0
        return colorsys.hsv_to_rgb(h, 0.6, local_brightness)
    
    elif anim_idx == 1: # 暖色フェード
        h = (math.sin(t * 0.2) + 1) / 2 * 0.1
        return colorsys.hsv_to_rgb(h, 0.8, local_brightness)

    elif anim_idx == 2: # パルス(青)
        val = (math.sin(t) + 1) / 2
        return (0, 0, local_brightness * val)

    elif anim_idx == 3: # 走る光(白)
        val = 1.0 if (i == int(step/500) % 12) else 0.05
        return (local_brightness * val, local_brightness * val, local_brightness * val)

    elif anim_idx == 4: # スキャン(縦)
        val = 1.0 if (x == int(step/500) % 3) else 0.1
        return (local_brightness * val, 0, local_brightness * val)

    elif anim_idx == 5: # 森林の呼吸(緑)
        val = (math.sin(t * 0.5 + i) + 1) / 2
        return (0, local_brightness * val, 0.1 * local_brightness)

    elif anim_idx == 6: # サイバーパンク(ピンク/青)
        # step/30 に変更してスピードを少しゆっくりに調整
        if (i + int(step/30)) % 2 == 0:
            return (local_brightness, 0, local_brightness)
        return (0, local_brightness, local_brightness)

    elif anim_idx == 7: # スキャン(横)
        val = 1.0 if (y == int(step/500) % 4) else 0.1
        return (0, local_brightness * val, local_brightness * val)

    elif anim_idx == 8: # 水面
        val = (math.sin(t + x) + math.cos(t + y) + 2) / 4
        return (0, local_brightness * val, local_brightness)

    elif anim_idx == 9: # 琥珀 (修正: anim_type -> anim_idx, 誤ったvalを削除)
        h, s = 0.08, 0.9
        v = local_brightness * (0.3 + 0.2 * math.sin(step / 20 + i))
        return colorsys.hsv_to_rgb(h, s, v)

    elif anim_idx == 10: # パステル (修正: colorsysを使用するように変更)
        h = (step / 500) % 1.0
        s, v = 0.5, local_brightness * 0.4
        return colorsys.hsv_to_rgb(h, s, v)

    elif anim_idx == 11: # 原色
        h = (step / 100 + i / 12) % 1.0
        s, v = 1.0, local_brightness * 0.7
        return colorsys.hsv_to_rgb(h, s, v)

    elif anim_idx == 12: # 桜 (修正: randomを使用)
        h, s = 0.95, 0.3
        v = local_brightness * (0.3 + 0.2 * random.random())
        return colorsys.hsv_to_rgb(h, s, v)

    elif anim_idx == 13: # 蛍
        h, s = 0.15, 0.8
        v = local_brightness * (0.5 + 0.5 * math.sin(step / 40 + i)) * (0.3 if i % 2 == 0 else 0.1)
        return colorsys.hsv_to_rgb(h, s, v)

    elif anim_idx == 14: # 星空 (注意: pixel_colors配列が事前に必要です)
        if random.random() < 0.03: 
            pixel_colors[i] = [random.random(), local_brightness]
        pixel_colors[i][1] *= 0.96
        h, s, v = pixel_colors[i][0], 0.5, pixel_colors[i][1]
        return colorsys.hsv_to_rgb(h, s, v)

    else:
        return (0.05, 0.05, 0.1)

def update_leds(step):
    global led_mode, button_states, pressed_keys, anim_type
    for i in range(12):
        if pressed_keys[i]:
            #keybow.set_led(i, 255, 255, 255)
            keybow.set_led(i, 80, 80, 80)
            continue
        if button_states[i] is not None:
            r, g, b = button_states[i]
            keybow.set_led(i, r, g, b)
            continue
        if led_mode == "off":
            keybow.set_led(i, 0, 0, 0)
            continue
        rgb = get_anim_rgb(i, step, anim_idx=anim_type)
        keybow.set_led(i, int(rgb[0]*255), int(rgb[1]*255), int(rgb[2]*255))

def send_to_ha_via_bluetooth(payload):
    """Bluetooth経由でPi 5にJSONを送信"""
    global sock
    if sock:
        try:
            msg = json.dumps(payload) + "\n"
            sock.send(msg.encode('utf-8'))
        except:
            pass

def finalize_click(index):
    global click_counts, anim_type, led_mode
    count = click_counts[index]
    click_counts[index] = 0
    timers[index] = None
    
    # 内部処理
    if index == 1 and count == 1:
        anim_type = (anim_type + 1) % 15
        led_mode = "animation"
    elif index == 2 and count == 1:
        led_mode = "off"

    # BluetoothでPi5へ送信
    payload = {
        "event": f"Button_{index}_Pressed",
        "click_type": "single" if count == 1 else "double" if count == 2 else "triple"
    }
    send_to_ha_via_bluetooth(payload)
    print(f"Key {index}: {payload['click_type']}")

def handle_key(index, state):
    global pressed_keys, last_press_time, click_counts, timers
    pressed_keys[index] = state
    
    if state:
        last_press_time[index] = time.time()
        if timers[index]:
            timers[index].cancel()
    else:
        duration = time.time() - last_press_time[index]
        if duration > 0.6:
            payload = {"event": f"Button_{index}_Pressed", "click_type": "hold"}
            send_to_ha_via_bluetooth(payload)
            click_counts[index] = 0
            print(f"Key {index}: hold")
        else:
            click_counts[index] += 1
            timers[index] = threading.Timer(0.3, finalize_click, [index])
            timers[index].start()

for i in range(12): keybow.on(i, handle_key)
step = 0
try:
    while True:
        step += 1
        if sock is None and not connecting: connect_to_pi5()
        update_leds(step)
        keybow.show()
        time.sleep(0.03)
except KeyboardInterrupt:
    keybow.set_all(0, 0, 0)
    keybow.show()

Raspberry Pi Zero(Keybow)のサービス登録

Keybow側も再起動時に自動でサービスが起動するようにします。

sudo vim /etc/systemd/system/keybow.service
[Unit]
Description=Keybow to Home Assistant Bridge
After=bluetooth.target

[Service]
ExecStart=/usr/bin/python3 /home/go/keybow_ha.py
WorkingDirectory=/home/go
StandardOutput=inherit
StandardError=inherit
Restart=always
User=go

[Install]
WantedBy=multi-user.target
sudo systemctl start keybow.service

WindowsからPythonスクリプトコピー

もし、シェルでRaspberry Pi OS Liteで直接Pythonスクリプトを書くのではなく、WindowsやMacで書いてコピーしたい場合は以下のようにしました。(例としてWindows11でやりました)

WindowsではRaspberry OS用のSDカードはbootfsフォルダがあるので、bootfsに作ったPythonスクリプト「keybow-ha.py」をコピーしておきます。

KeybowのRaspberry Pi OS Liteを起動してシェルに以下のようにPythonスクリプトを置きます。

cd ~
mv keybow-ha.py keybow-ha2.py #古いkeybow-ha.pyがあればバックアップしておく
cp /boot/firmware/keybow-ha.py ./
sudo systemctl restart keybow.service
sudo systemctl status keybow

WindowsのSDカード内ではbootfsフォルダでしたが、Raspberry OSになると/boot/firmwareで同じファイルを見れます。

Windows SDカード内:/bootfs/keybow-ha.py
         = 
Raspberry OS:/boot/firmware/keybow-ha.py

HomeAssistantのYAMLファイル2箇所に追記

Raspberry Pi 5にインストールしたHomeAssistantに戻って以下の2つのファイルに追記していきます。

HomeAssistantのautomations.yaml

HomeAssistantのautomations.yamlに以下を追記します。

Keybowの各キーに個別の機能を追加しています。そして夜になったらバックライトオフ、朝になったらバックライトをオンにするようにしました。

- id: keybow_8_ultimate_v3
  alias: Keybow 8 Ultimate (Dimming & Color Loops)
  trigger:
  - trigger: webhook
    webhook_id: keybow_event
  condition:
  - condition: template
    value_template: '{{ trigger.json.event == ''Button_8_Pressed'' }}'
  action:
  - choose:
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''single'' }}'
      sequence:
      - action: switch.toggle
        target:
          entity_id: switch.chuangmi_212a01_4915_switch
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''double'' }}'
      sequence:
      - action: light.turn_on
        target:
          entity_id: light.floor_lamp_0a2a
        data:
          brightness_pct: '{% set b = state_attr(''light.floor_lamp_0a2a'', ''brightness'')
            | default(0) | int %} {% set p = (b / 255 * 100) | round %} {% if p <
            25 %} 40 {% elif p < 55 %} 70 {% elif p < 85 %} 100 {% else %} 10 {% endif
            %}'
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''triple'' }}'
      sequence:
      - choose:
        - conditions:
          - condition: template
            value_template: '{{ state_attr(''light.floor_lamp_0a2a'', ''color_temp_kelvin'')
              | default(0) | int <= 3000 }}'
          sequence:
          - action: light.turn_on
            target:
              entity_id: light.floor_lamp_0a2a
            data:
              color_temp_kelvin: 4500
        - conditions:
          - condition: template
            value_template: '{{ state_attr(''light.floor_lamp_0a2a'', ''color_temp_kelvin'')
              | default(0) | int <= 5000 }}'
          sequence:
          - action: light.turn_on
            target:
              entity_id: light.floor_lamp_0a2a
            data:
              color_temp_kelvin: 6500
        - conditions:
          - condition: template
            value_template: '{{ state_attr(''light.floor_lamp_0a2a'', ''color_temp_kelvin'')
              | default(0) | int > 5000 }}'
          sequence:
          - action: light.turn_on
            target:
              entity_id: light.floor_lamp_0a2a
            data:
              rgb_color:
              - 0
              - 255
              - 255
        - conditions:
          - condition: template
            value_template: '{% set rgb = state_attr(''light.floor_lamp_0a2a'', ''rgb_color'')
              | default([0,0,0]) %} {{ rgb[1] > 200 and rgb[2] > 200 and rgb[0] <
              100 }}'
          sequence:
          - action: light.turn_on
            target:
              entity_id: light.floor_lamp_0a2a
            data:
              rgb_color:
              - 191
              - 0
              - 255
        - conditions:
          - condition: template
            value_template: 'true'
          sequence:
          - action: light.turn_on
            target:
              entity_id: light.floor_lamp_0a2a
            data:
              color_temp_kelvin: 2000
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''hold'' }}'
      sequence:
      - action: light.toggle
        target:
          entity_id: light.floor_lamp_0a2a
  mode: restart
 
- id: keybow_7_new_toggle
  alias: Keybow 7 Light Toggle Control
  trigger:
  - trigger: webhook
    webhook_id: keybow_event
  condition:
  - condition: template
    value_template: '{{ trigger.json.event == ''Button_7_Pressed'' }}'
  action:
  - choose:
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''single'' }}'
      sequence:
      - action: light.toggle
        target:
          entity_id: light.e27_led_lamp2
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''double'' }}'
      sequence:
      - action: light.toggle
        target:
          entity_id: light.e27_led_lamp1

- id: keybow_6_control
  alias: Keybow 6 Toggle
  trigger:
  - trigger: webhook
    webhook_id: keybow_event
  condition:
  - condition: template
    value_template: '{{ trigger.json.event == ''Button_6_Pressed'' }}'
  action:
  - action: switch.toggle
    target:
      entity_id: switch.chuangmi_m3_28ac_switch

- id: keybow_11_integrated
  alias: Keybow 11 Integrated (Single & Double)
  trigger:
  - trigger: webhook
    webhook_id: keybow_event
  condition:
  - condition: template
    value_template: '{{ trigger.json.event == ''Button_11_Pressed'' }}'
  action:
  - choose:
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''single'' }}'
      sequence:
      - action: switch.toggle
        target:
          entity_id: switch.chuangmi_212a01_b9a1_switch
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''double'' }}'
      sequence:
      - action: switch.toggle
        target:
          entity_id: switch.smart_plug_switch_1
  mode: restart
- id: keybow_10_control
  alias: Keybow 10 Toggle
  trigger:
  - trigger: webhook
    webhook_id: keybow_event
  condition:
  - condition: template
    value_template: '{{ trigger.json.event == ''Button_10_Pressed'' }}'
  action:
  - action: switch.toggle
    target:
      entity_id: switch.plug_lumi_158d000128a61d_ch0_lumi_158d000128a61d
- id: keybow_9_control
  alias: Keybow 9 Gateway Light (Robust RGB Loop)
  trigger:
  - trigger: webhook
    webhook_id: keybow_event
  condition:
  - condition: template
    value_template: '{{ trigger.json.event == ''Button_9_Pressed'' }}'
  action:
  - choose:
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''single'' }}'
      sequence:
      - action: light.toggle
        target:
          entity_id: light.lumi_gateway_v3_light
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''double'' }}'
      sequence:
      - action: light.turn_on
        target:
          entity_id: light.lumi_gateway_v3_light
        data:
          rgb_color: "{% set rgb = state_attr('light.lumi_gateway_v3_light', 'rgb_color')
            | default([255, 255, 255]) %} {% set r = rgb[0] %} {% set g = rgb[1] %}
            {% set b = rgb[2] %}\n{# 暖色判定:赤が強く、青が弱い場合 -> 白へ #} {% if r > 240 and b
            < 100 %}\n  [255, 255, 255]\n{# 白判定:赤と青が共に高い場合 -> 寒色へ #} {% elif r > 240
            and b > 200 %}\n  [150, 200, 255]\n{# それ以外(寒色など) -> 暖色へ #} {% else %}\n
            \ [255, 150, 50]\n{% endif %}\n"
          brightness: 255

- id: keybow_0_control
  alias: Keybow 0 Fan Control (Restore Points)
  mode: restart
  trigger:
    - trigger: webhook
      webhook_id: keybow_event
  condition:
    - condition: template
      value_template: "{{ trigger.json.event == 'Button_0_Pressed' }}"
  action:
    - choose:
        # --- シングルクリック:初期の正常に動作していた設定 ---
        - conditions:
            - condition: template
              value_template: "{{ trigger.json.click_type == 'single' }}"
          sequence:
            - action: scene.turn_on
              target:
                entity_id: scene.sirinkuhuan
            - action: input_boolean.toggle
              target:
                entity_id: input_boolean.fan_state_helper

        # --- ダブルクリック:安定していた風量循環設定 ---
        - conditions:
            - condition: template
              value_template: "{{ trigger.json.click_type == 'double' }}"
          sequence:
            - action: input_select.select_next
              target:
                entity_id: input_select.fan_scene_helper
            - delay: "00:00:01"
            - action: scene.turn_on
              target:
                entity_id: >
                  {% set current = states('input_select.fan_scene_helper') %}
                  {% if current == 'Strong' %} scene.sirinkuhuan_qiang
                  {% elif current == 'Medium' %} scene.sirinkuhuan_zhong
                  {% else %} scene.sirinkuhuan_ruo
                  {% endif %}
            - action: input_boolean.turn_on
              target:
                entity_id: input_boolean.fan_state_helper

- id: keybow_5_control
  alias: Keybow 5 Fan Light Control (With LED Sync)
  trigger:
  - trigger: webhook
    webhook_id: keybow_event
  condition:
  - condition: template
    value_template: '{{ trigger.json.event == ''Button_5_Pressed'' }}'
  action:
  - choose:
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''single'' }}'
      sequence:
      - action: scene.turn_on
        target:
          entity_id: scene.sirinkuhuanraito_tokuru
      - action: input_boolean.toggle
        target:
          entity_id: input_boolean.fan_light_mode
    - conditions:
      - condition: template
        value_template: '{{ trigger.json.click_type == ''double'' }}'
      sequence:
      - choose:
        - conditions:
          - condition: template
            value_template: '{{ is_state(''input_boolean.fan_light_mode'', ''off'')
              }}'
          sequence:
          - action: scene.turn_on
            target:
              entity_id: scene.sirinkuhuanraito_taku
          - action: input_boolean.turn_on
            target:
              entity_id: input_boolean.fan_light_mode
        - conditions:
          - condition: template
            value_template: 'true'
          sequence:
          - action: scene.turn_on
            target:
              entity_id: scene.sirinkuhuanraito_tong_chang
          - action: input_boolean.turn_on
            target:
              entity_id: input_boolean.fan_light_mode


- id: keybow_3_sesame_control
  alias: Keybow 3 Sesame Control (Single & Double)
  trigger:
    - trigger: webhook
      webhook_id: keybow_event
  condition:
    - condition: template
      value_template: '{{ trigger.json.event == "Button_3_Pressed" }}'
  action:
    - choose:
        # シングルクリック:エントランス解錠
        - conditions:
            - condition: template
              value_template: '{{ trigger.json.click_type == "single" }}'
          sequence:
            - action: light.toggle
              target:
                entity_id: light.sesami_hotuto_2

        # ダブルクリック:玄関ドア (5 Pro)
        - conditions:
            - condition: template
              value_template: '{{ trigger.json.click_type == "double" }}'
          sequence:
            - if:
                - condition: state
                  entity_id: lock.sesami_5_pro
                  state: "locked"
              then:
                - action: lock.unlock
                  target:
                    entity_id: lock.sesami_5_pro
              else:
                - action: lock.lock
                  target:
                    entity_id: lock.sesami_5_pro
  mode: restart
 

- id: keybow_led_sync_final_v3
  alias: Keybow LED Sync (Specific Fix for 3 and 11)
  trigger:
    - trigger: state
      entity_id:
        - light.e27_led_lamp2
        - switch.chuangmi_m3_28ac_switch
        - switch.chuangmi_212a01_b9a1_switch # 11キー用
        - switch.smart_plug_switch_1          # 11キー用
        - switch.plug_lumi_158d000128a61d_ch0_lumi_158d000128a61d
        - light.lumi_gateway_v3_light
        - switch.chuangmi_212a01_4915_switch
        - input_boolean.fan_state_helper
        - input_boolean.fan_light_mode
        - lock.sesami_5_pro                  # 3キー用
  condition:
    - condition: time
      after: "07:00:00"
      before: "21:00:00"
  action:
    - choose:
        # --- 【3キー専用】施錠:緑 / 解錠:赤 ---
        - conditions:
            - condition: template
              value_template: "{{ trigger.entity_id == 'lock.sesami_5_pro' }}"
          sequence:
            - action: shell_command.keybow_led_on
              data:
                btn: 3
                color: "{% if is_state('lock.sesami_5_pro', 'locked') %}0,255,0{% else %}255,0,0{% endif %}"

        # --- 【11キー専用】両方ONの時のみ赤 / それ以外は消灯 ---
        - conditions:
            - condition: template
              value_template: "{{ trigger.entity_id in ['switch.chuangmi_212a01_b9a1_switch', 'switch.smart_plug_switch_1'] }}"
          sequence:
            - if:
                - condition: state
                  entity_id: switch.chuangmi_212a01_b9a1_switch
                  state: "on"
                - condition: state
                  entity_id: switch.smart_plug_switch_1
                  state: "on"
              then:
                - action: shell_command.keybow_led_on
                  data: { btn: 11, color: "255,0,0" }
              else:
                - action: shell_command.keybow_led_off
                  data: { btn: 11 }

        # --- 【その他のキー】3と11を完全に除外 ---
        - conditions:
            - condition: template
              value_template: >
                {{ trigger.entity_id not in [
                  'lock.sesami_5_pro', 
                  'switch.chuangmi_212a01_b9a1_switch', 
                  'switch.smart_plug_switch_1'
                ] }}
          sequence:
            - action: "{% if trigger.to_state.state == 'on' %} shell_command.keybow_led_on {% else %} shell_command.keybow_led_off {% endif %}"
              data:
                btn: >
                  {% set mapper = {
                    'light.e27_led_lamp2': 7,
                    'switch.chuangmi_m3_28ac_switch': 6,
                    'switch.plug_lumi_158d000128a61d_ch0_lumi_158d000128a61d': 10,
                    'light.lumi_gateway_v3_light': 9,
                    'switch.chuangmi_212a01_4915_switch': 8,
                    'input_boolean.fan_state_helper': 0,
                    'input_boolean.fan_light_mode': 5
                  } %} {{ mapper[trigger.entity_id] }}
  mode: restart

- id: keybow_morning_resync
  alias: Keybow Morning State Resync (08:00)
  trigger:
    - platform: time
      at: '08:00:00'
  action:
    # 3キーの朝の復元
    - action: shell_command.keybow_led_on
      data:
        btn: 3
        color: "{% if is_state('lock.sesami_5_pro', 'locked') %}0,255,0{% else %}255,0,0{% endif %}"
    
    # 11キーの朝の復元
    - choose:
        - conditions:
            - condition: state
              entity_id: switch.chuangmi_212a01_b9a1_switch
              state: 'on'
            - condition: state
              entity_id: switch.smart_plug_switch_1
              state: 'on'
          sequence:
            - action: shell_command.keybow_led_on
              data: { btn: 11, color: "255,0,0" }
        - conditions:
            - condition: state
              entity_id: switch.chuangmi_212a01_b9a1_switch
              state: 'on'
          sequence:
            - action: shell_command.keybow_led_on
              data: { btn: 11, color: "255,255,255" }

    # その他の標準キーの復元
    - repeat:
        for_each:
          - { entity: 'light.e27_led_lamp2', btn: 7 }
          - { entity: 'switch.chuangmi_m3_28ac_switch', btn: 6 }
          - { entity: 'switch.plug_lumi_158d000128a61d_ch0_lumi_158d000128a61d', btn: 10 }
          - { entity: 'light.lumi_gateway_v3_light', btn: 9 }
          - { entity: 'switch.chuangmi_212a01_4915_switch', btn: 8 }
          - { entity: 'input_boolean.fan_state_helper', btn: 0 }
          - { entity: 'input_boolean.fan_light_mode', btn: 5 }
        sequence:
          - if:
              - condition: template
                value_template: "{{ is_state(repeat.item.entity, 'on') }}"
            then:
              - action: shell_command.keybow_led_on
                data: { btn: "{{ repeat.item.btn }}" }


- id: keybow_night_dimmer
  alias: Keybow Night Mode (Off at 21:00)
  trigger:
  - platform: time
    at: '21:00:00'
  action:
  - repeat:
      for_each: [0, 3, 5, 6, 7, 8, 9, 10, 11] # 3を追加
      sequence:
      - action: shell_command.keybow_led_off
        data:
          btn: '{{ repeat.item }}'
  - action: input_boolean.turn_off
    target:
      entity_id:
      - input_boolean.fan_state_helper
      - input_boolean.fan_light_mode

keybow_night_dimmerで21時になったらスイッチがオンになっているバックライトをオフにします。(バックライトのグラデーションは手動でキー2を押してオフにしています。)

HomeAssistantのconfiguration.yaml

shell_commandに以下を追加します。

shell_command:
  keybow_led_on: 'curl -m 1 "http://127.0.0.1:5000/LED:{{ btn }}:{{ color | default("ON") }}"'
  keybow_led_off: 'curl -m 1 "http://127.0.0.1:5000/LED:{{ btn }}:OFF"'

Keybowのキーキャップと完成の図

Keybowのキーキャップは透明なのでバックライトがちょっと眩しく、何も書いてないのでどれがどのキーか判断しずらいのでキーキャップを3Dプリンターで作ることにしました。

3Dモデル

thingiverse.comにCherry MXロープロファイルがあったのでこちらをお借りしてキーキャップを作る。

Wakizashi 40 Slim by kabdelgaber
Slimmed out as much of the keyboard as possible including the plate, bezel (since I was using slightly lower profile mx ...

3Dプリンターと安いPLAフィラメント

フィラメントはアマゾンで最安値のPLAフィラメント eSUN 基本的な PLAフィラメント 1.75mm、ePLA 3Dプリンティングフィラメントを使っています。

3Dプリンターは去年買ったCreality K1Cで0.08mmでプリントしました。このプリンターはもう面倒をあまり見ることなく放置&手間なしで使えるようになりました。

仕上がりは良でキーキャップとして使うには悪くないと思います。

キー4はブランクで、キー1(Vimのキー)はバックライトのグラデーションを変更、キー2(Logのキー)はグラデーションオフにした。

バックライトで半透明にしたかったけど無理だった・・・

やはり3Dプリンターで作ったキーキャップは半透明にならずに暗い。

なので文字の部分を白で塗ってみます。

キーキャップのキーを判断できるように白く塗る

ちょっと近くで見るとだいぶ荒い塗りだけどこれで暗くてもある程度はどれがどのスイッチか判別できる。

漢字部分の白い色はぺんてる 油性ペン ホワイト 中字 X100W-MDで塗りました。何回も重ね塗りしたのでなかなか白色が乾かなくて指で押したら白が付いてしまうのがネック、そのうち乾くでしょう。

Kwybow 各キーの役割

一応どのキーがどの動作をするかまとめてみました。

  • 0 風:ダクトレール 3つのシーリングファンの扇風機 Singleオン・オフ
  • 3 鍵:Single玄関セサミ鍵、Double1階エントランス鍵 セサミボット2
  • 4:未使用
  • 5 天:ダクトレール 3つのシーリングファンの照明 Singleオン・オフ
  • 6 机: デスクのLED間接照明 Singleオン・オフ
  • 7 球: ダクトレール Single電球No1、 Double電球No2
  • 8 床:Switchbotのフロアライト Single LEDオン・オフ、Double色変更(3種)
  • 9 門:XiaomiのゲートウェイLED Singleオン・オフ、Double色変更(3種)
  • 10 音:ダクトレールに設置したUSBアダプターの電源 Singleオン・オフ
  • 11 植:植物温室 SingleLED照明 オン・オフ、Double ホットカーペット

現状これくらいです。もう一般家庭ではあまりオートメーション化する物はない?かもしれません。

リモコンで操作するタイプのシーリングファンライトとXiaomi電球・XiaomiスイッチにUSBアダプタを装着↓

シーリングファンライトはリモコン式でTuya学習リモコンと連携してシーンで操作するが、3つのうち2個が動作したりしなかったりと使い勝手が悪い。アリエク10ドルくらいのシーリングファンライトなので仕方がないけど・・・

一軒家・ガレージ付きだったら色々と自動化するガジェットはあるのかもしれませんが賃貸のマンション暮らしだとあまり自動化するものはなさそう。

KeybowをHAスイッチ化まとめ

ちょっと大変でしたけど、古いKeybowを有効活用することができました。

自作キーボードもHomeAssistantと連携できればもっと欲しくなる(使い道ないと思うけど)なぁ。

コメント