Skip to content
 
 

Repository files navigation

ESP32 BLE HID Keyboard for ESPHome

HomeOps fork. Forked from markusg1234/ESPHome-espidf_ble_keyboard (GPL-3.0) to add a quality layer the upstream lacks: a CI compile gate (every PR builds for ESP32 + M5 Atom Lite on current ESPHome, so an upstream ESPHome release breaking the component is caught here, not in production) and release-please tags you can pin via external_components: ref:. Thanks to the upstream author. Use a tagged release for stability:

external_components:
  - source:
      type: git
      url: https://github.com/HomeOps/esphome-blekeyboard
      ref: v0.1.0          # pin a release
      path: components
    components: [ble_keyboard]

This is a custom ESPHome component that transforms an ESP32 into a Bluetooth Low Energy (BLE) HID Keyboard. This component currently targets ESP-IDF Bluedroid GATTS (rather than NimBLE), chosen for the HID behavior and host compatibility validated in this project.

Features

  • Standard HID Keyboard: Recognized as a native keyboard by Windows, Android, and iOS. Full HOGP-compliant BLE HID with Device Information and Battery services. Use passkey_mode: legacy for Windows (Just Works for Android), passkey_mode: secure_connections for iOS.
  • Secure Pairing: Supports a configurable 6-digit static passkey (PIN) for secure bonding on Windows and iOS. Android uses Just Works pairing (no PIN) due to HID compatibility limitations.
  • Efficient Memory Usage: Direct API implementation ensures stability even with complex ESPHome configurations.
  • Key Combos: Send any modifier + key combination using hex keycodes (e.g. Win+R, Ctrl+C).
  • String Typing: Type any string directly. The active keyboard layout (us, uk, de, be) controls how each character is mapped to HID keycodes. UK adds £, ¬, ; DE adds ä, ö, ü, ß, , §, °; BE adds é, è, à, ç, ù, , £, ², §, µ plus dead-key sequences (â ê î ô û ä ë ï ö ü + uppercase) via UTF-8.
  • Keyboard Layouts: Choose us (default), uk, de, or be in YAML, or switch live from the web UI (persisted to NVS). Layout is fully extensible — see Keyboard layouts.
  • Pre-defined Actions: Built-in helpers for ctrl_alt_del, sleep, hibernate and shutdown.
  • Media Keys: Control volume, playback, mute and more via HID consumer control.
  • Power Button: Native HID power/sleep signals — no Run dialog, clean OS-level control.
  • Consumer Control: Send any HID consumer code directly from YAML using consumer:0xXXXX syntax.
  • Mouse Control: Left, right, and middle click, cursor movement, and scroll wheel via HID mouse reports.
  • Custom Text Input: Send any text typed in Home Assistant directly to the paired host device.
  • RSSI Sensor: Read the signal strength (dBm) of the connected host on a configurable interval. Supports proximity-based automations via on_rssi_above / on_rssi_below.
  • Keyboard LED Feedback: Expose host-side Num Lock, Caps Lock, and Scroll Lock LED state as ESPHome binary sensors. Updated whenever the host writes a HID output report.

📖 Keycode Reference · 🌐 View Web Page

Showcase: a BLE media remote in one file

Two importable packages turn any ESP32 into a Home-Assistant-controlled BLE media remote — nothing to copy, nothing to hand-wire:

  • packages/keyboard.yaml — enables BLE (the ESP-IDF Bluetooth sdkconfig that's easy to get wrong), instantiates the ble_keyboard (id kb), a BLE-link sensor, the send_keys HA service, and the host-management buttons (Forget All Hosts, Switch to Host 1–4, Forget Host 1–4) — these act on the keyboard itself, so they live here (imported once), not per-remote.
  • packages/media_keys.yaml — one button per canonical control (the homeops-ir-canonical vocabulary that has a BLE HID equivalent), grouped under a Home Assistant sub-device. Button ids are <ble_device_id>_<canonical> — the same id a control gets on the IR side (esphome-ir-codegen) — so Concerto drives one canonical control over IR or BLE alike. Covers media/navigation plus the app_* video-app controls (YouTube, Netflix, …); app-launch has no HID usage of its own, so each app emits a distinct standard gamepad button (continuing past gamepad.yaml's 1–15) and the host's key layout decides which app it opens — host-agnostic, same model as gamepad.
  • packages/gamepad.yaml — same idea for the pad_* canonical gamepad controls (face / shoulders / triggers / stick-clicks / system buttons). Each maps to its standard HID Button-page index (BTN_A=1 … BTN_SELECT=11, BTN_START=12 …) — host-agnostic: any compliant host decodes the standard gamepad identically, so no per-host tailoring. Import it with its own ble_device_id for a dedicated controller device. (D-pad is omitted — no hat in the descriptor; use the navigation buttons in media_keys.yaml.)
esphome:
  name: media-remote
esp32:
  board: m5stack-atom          # your ESP32 board

substitutions:
  ble_device_id: living_tv          # the HA sub-device id + button-id prefix
  ble_device_name: "Living TV"      # the pretty HA display name

.repo: &repo                   # repo + ref written once, reused below
  url: https://github.com/HomeOps/esphome-blekeyboard
  ref: v0.1.0                  # pin a release

external_components:
  - source: { type: git, <<: *repo, path: components }
    components: [ble_keyboard]

packages:
  keyboard:   { <<: *repo, files: [packages/keyboard.yaml] }
  media_keys: { <<: *repo, files: [packages/media_keys.yaml] }

Two substitutions, the repo pinned once. Flash it, pair with the TV, and Home Assistant gets a Living TV device with Power, Volume, Mute, Play/Pause, channel and navigation buttons — all canonically named. (ble_keyboard id defaults to kb. One remote per node via substitutions:; for several remotes on one node, !include packages/media_keys.yaml with per-remote vars: { ble_device_id: …, ble_device_name: … } instead.) Validity ≠ correctness: a green compile proves the YAML; verify the keys on the real host.

It maps power, volume/mute, channel, transport (play/pause/stop/record/rewind/ fast-forward/next/previous/eject) and navigation (menu/select/arrows/back/home) — the component's named actions where they exist, standard HID consumer/keyboard usages otherwise.

Sending arbitrary keys — the send_keys service

The fixed buttons cover the common controls. For anything else, keyboard.yaml also exposes a Home Assistant service, esphome.<node>_send_keys, that takes a single action string. The grammar (handled by the component) is:

Form Example Sends
named volume_up, mute, power, play_pause, ctrl_alt_del a built-in action
consumer: consumer:0x40 a raw HID Consumer-page usage (hex/decimal)
combo: combo:0x01:0x4F modifier:keycode on the Keyboard page
button: button:11 gamepad button 1–32 → an Android gamepad keycode
text string:hello or just hello typed as keystrokes
macro menu|delay:200|down|down|select |-separated steps, optional delay:ms

Android TV menu: Google/Android TV decodes its remote's Menu button as KEYCODE_BUTTON_11 (Android keycode 198 — the numbered button keycodes are sequential from BUTTON_1 = 188, so BUTTON_N = 187 + N). That's a gamepad button, not a keyboard/consumer usage, so send button:11. Note the HID button index isn't 1:1 with the keycode — Linux remaps the named A/B/X/Y/… range first, so the keycode you want may sit at a higher index. If button:11 doesn't open the menu, sweep button:1button:32 and use whichever fires it. (Adding the gamepad collection changes the HID descriptor, so already-paired hosts must re-pair once to see it.)

# Home Assistant automation / Developer Tools → Actions
action: esphome.media_remote_send_keys
data:
  action: "menu|delay:300|down|select"

Because it's the same execute_action engine the buttons use, anything a button can do, the service can do — plus macros and typed text. (It needs the native API, so a network component is required.)

Device identity (PnP ID) — Android TV by default

A TV decides which key layout to apply to a remote by its vendor ID. So by default this component advertises Google's Reference Remote identity (vendor 0x0957), which makes an Android/Google TV treat it like a known remote — that's what lets app-launch usages work:

App action: via Google's layout
YouTube consumer:0x77 launches YouTube
Netflix consumer:0x78 launches Netflix
Featured app consumer:0x187 FEATURED_APP_1

It's fully overridable — for a plain keyboard on a PC/phone, advertise a generic identity instead:

ble_keyboard:
  id: kb
  vendor_id_source: 1      # Bluetooth SIG
  vendor_id: 0x02E5        # Espressif (generic keyboard identity)
  product_id: 0xA1B2

Two things in the open: the PnP ID is read at bond time, so changing it means re-pairing once; and VIDEO_APP_18 / FEATURED_APP_24 have no published HID usage — each TV vendor wires those in a private layout, so they can't be reproduced without capturing your own remote's raw usages (adb shell getevent -lt, read the MSC_SCANCODE line).

Sony Bravia (Android TV) — captured remote map

Google's identity covers the standard keys plus YouTube/Netflix, but a Sony Bravia's Menu and most app-launch buttons are vendor-private — they only resolve under the TV's own SONY_TV_VRC_001.kl layout. To reach them, advertise the Sony remote's PnP identity so the Bravia applies that layout.

Ready-to-use package: packages/sony_bravia.yaml composes media_keys.yaml (all the shared controls, defined once) and only overrides the Sony-specific keys (Menu + the app codes) and adds the Sony function buttons + identity. Import it instead of media_keys.yaml — it pulls media_keys.yaml in itself — with the same ble_device_id / ble_device_name substitutions. Switching a node to it changes the PnP identity, so it needs a one-time re-pair. The rest of this section documents what that package sets, if you'd rather wire it by hand:

ble_keyboard:
  id: kb
  device_name: "SONY TV VRC 001"       # GATT/HID name → TV keys the layout off this
  advertised_name: "Master BR Remote"  # unique name the pairing scan shows
  vendor_id_source: 2                  # USB-IF
  vendor_id: 0x054C                    # Sony Corporation
  product_id: 0x0F22
  product_version: 0x0011

Why the two names. device_name is the GATT (0x2A00) / HID name Android reads after connecting, and it keys SONY_TV_VRC_001.kl off that — so it must be exactly SONY TV VRC 001. But a Bravia's pairing scan won't surface a second device whose name matches your already-paired physical remote. advertised_name (new) is broadcast in the advertising scan-response — the name the pairing scan shows — so the ESP appears under a unique name you can pick, then presents SONY TV VRC 001 over GATT once connected. You get the Sony layout and clean pairing alongside the real remote. If advertised_name is omitted it falls back to device_name. packages/sony_bravia.yaml sets advertised_name to the HA display name (${ble_device_name}) automatically.

These usages were captured from a physical Bravia remote (SONY TV VRC 001, on a BRAVIA 4K AE2) with adb shell getevent -lt — the MSC_SCAN line is the raw HID Consumer-page usage. Every one sits on the Consumer page, so each is an action: { type: consumer, code: … }.

Navigation & function:

Control code: Control code:
Up / Down / Left / Right 0x420x45 Menu 0x51F
Select / OK 0x41 Settings (gear) 0x586
Back 0x224 Guide / List 0x08D
Home 0x223 TV 0x089
Input / Source 0x533 Tools (wrench) 0x3C3

The voice/mic button has no entry — it triggers Assistant over a separate voice channel, not a HID key, so it can't be reproduced as a consumer: code.

Media & volume — these are standard HID usages that work on any host, no Sony layout needed (the component already sends them via its named actions):

Control code: Control code:
Play / Pause 0x0CD Mute 0x0E2
Volume + / − 0x0E9 / 0x0EA Channel + / − 0x09C / 0x09D

App-launch buttons:

App code: App code:
Sony Pictures 0x4F0 Prime Video 0x4EA
Netflix 0x547 Crunchyroll 0x4FB
Disney+ 0x4EB YouTube 0x4E5

The Menu, Settings, Input, and app usages sit above the old 0x3FF consumer ceiling; the report descriptor now advertises usages up to 0x7FFF, so they transmit. All usages above are verified captures from the physical remote, and the whole set was confirmed on-device: with the dual-name trick the Bravia applied SONY_TV_VRC_001.kl to the ESP (matched by the GATT device name), and Menu / YouTube / Netflix fire correctly alongside the untouched physical remote.

Power is IR on a Bravia, not BLE

The Bravia remote's Power button is sent over IR — it arrives on the TV's IR receiver, not the BLE link — and BLE can't wake a TV whose Bluetooth is off in standby. So sony_bravia.yaml removes the BLE power button; drive Power from the same ESP's IR blaster instead. Capture the code once by pointing the remote at the ESP's remote_receiver: (dump: all) and reading the decoded sony: code from the logs, then add an IR button on the node (which owns the transmitter):

button:
  - platform: template
    name: "Power"
    id: ${ble_device_id}_power_toggle   # same canonical id, now on the IR side
    on_press:
      - remote_transmitter.transmit_sony:
          data: 0xA90    # <- your captured Sony power code (verify with dump)
          nbits: 12

Usage Example

Add the following to your ESPHome YAML configuration:

substitutions:
  device_name: bluetooth-keyboard
  friendly_name: "Bluetooth keyboard"
  wifi_ssid: "***"
  wifi_password: "***"
  api_encryption_key: "***"
  ota_password: "***"

esphome:
  name: ${device_name}
  friendly_name: ${friendly_name}

esp32:
  board: esp32dev   # Tested with esp32dev and esp32-c6-devkitm-1
  framework:
    type: esp-idf
    sdkconfig_options:
      CONFIG_BT_ENABLED: y
      CONFIG_BT_CONTROLLER_ENABLED: y
      CONFIG_BT_BLUEDROID_ENABLED: y
      CONFIG_BT_NIMBLE_ENABLED: n
      CONFIG_BT_BLE_ENABLED: y
      CONFIG_BT_GATTS_ENABLE: y
      CONFIG_BT_BLE_42_FEATURES_SUPPORTED: y
      CONFIG_BT_BLE_50_FEATURES_SUPPORTED: n
      CONFIG_BT_BLE_42_ADV_EN: y
      CONFIG_BT_BLE_42_SCAN_EN: y
      CONFIG_BT_BLE_SMP_ENABLE: y
      CONFIG_BT_ACL_CONNECTIONS: "4"

logger:

api:
  encryption:
    key: ${api_encryption_key}

ota:
  - platform: esphome
    password: ${ota_password}

wifi:
  ssid: ${wifi_ssid}
  password: ${wifi_password}
  power_save_mode: light
  fast_connect: true

external_components:
  - source:
      type: git
      url: https://github.com/markusg1234/ESPHome-espidf_ble_keyboard
      ref: main
      path: components
    components: [ ble_keyboard ]

ble_keyboard:
  id: my_keyboard
  # Optional: BLE device name shown during pairing (max 29 chars, default: "ESP32 BLE KB")
  device_name: "ESP32 BLE KB"
  # Optional: per-character delay when typing strings in ms (default: 80)
  key_delay_ms: 80
  # Optional: Set a 6-digit pairing code.
  # If omitted, the device will use "Just Works" (no PIN) pairing.
  # Note: Android does not support passkey pairing for BLE HID devices.
  passkey: 123456
  # Optional pairing mode when passkey is set:
  # legacy (default, Windows-friendly) or secure_connections (iOS-required)
  passkey_mode: legacy
  # Optional: enable built-in web control page at http://<device-ip>/ble_keyboard
  # Requires web_server component. No HA cards or services needed.
  web_control: true
  # Optional: number of host slots for multi-host switching (1–10, default: 4)
  host_slots: 4
  # Optional: web mouse sensitivity settings
  mouse_sensitivity: 1.0       # base movement speed (default: 1.0)
  mouse_acceleration: 0.15     # speed-based acceleration factor (default: 0.15)
  mouse_max_speed: 4.0         # max sensitivity cap (default: 4.0)
  scroll_sensitivity: 2.0      # scroll speed multiplier (default: 2.0)
  # Optional: link text entities for custom text input (shows Send button in web UI)
  custom_text_id:
    - custom_text
  # Optional: per-slot passkey, pairing mode, and keyboard layout
  hosts:
    - slot: 0
      passkey: 111111
      passkey_mode: legacy
      layout: us           # auto-apply this layout whenever slot 0 becomes active
    - slot: 1
      passkey: 222222
      passkey_mode: legacy
      layout: uk           # ...and this one for slot 1
    - slot: 2
      passkey_mode: legacy
    - slot: 3
      passkey_mode: legacy

button:

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Ctrl + F1"
    action: "combo:0x01:0x3A"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Win + R (Run Dialog)"
    # 0x08 = Windows Key, 0x15 = 'r'
    action: "combo:0x08:0x15"

  - platform: template
    name: "Template Hello"
    on_press:
      - lambda: |-
          id(my_keyboard).send_string("Hello\n");

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Type Hello"
    action: "Hello\n"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Ctrl Alt Del"
    action: "ctrl_alt_del"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Sleep PC"
    action: "sleep"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Hibernate PC"
    action: "hibernate"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Shutdown PC"
    action: "shutdown"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Mute"
    action: "mute"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Volume Up"
    action: "volume_up"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Volume Down"
    action: "volume_down"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Play / Pause"
    action: "play_pause"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Open Calculator"
    action: "consumer:0x0192"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Left Click"
    action: "left_click"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Move Mouse Right"
    action: "mouse_move:50:0"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Scroll Down"
    action: "mouse_scroll:-3"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Send Custom Text"
    action: "send_custom_text"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Host 0"
    action:
      type: switch_host
      slot: 0

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Host 1"
    action:
      type: switch_host
      slot: 1

  - platform: restart
    name: ${friendly_name}

text:
  - platform: template
    name: "Custom Text"
    id: custom_text
    mode: text
    optimistic: true

binary_sensor:
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "BLE Keyboard Paired"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    type: caps_lock
    name: "BLE Keyboard Caps Lock"

  - platform: status
    name: ${friendly_name}

Configuration Variables

ble_keyboard

  • id (Required, ID): The ID used to link buttons or automations to this keyboard.
  • device_name (Optional, string): The BLE device name advertised during pairing. Defaults to ESP32 BLE KB. Maximum 29 characters.
  • key_delay_ms (Optional, int): Total delay per character when typing strings, in milliseconds. Split evenly between key-down and key-up. Defaults to 80. Increase if characters are being dropped on slow BLE connections.
  • passkey (Optional, int): A 6-digit static PIN (000000–999999). If set, the device uses static passkey pairing (legacy MITM bond) and requires this PIN during initial pairing.
  • passkey_mode (Optional, string): Passkey security mode. legacy (default) uses legacy MITM bonding — tested and recommended for Windows. secure_connections uses LE Secure Connections MITM bonding — required for iOS passkey pairing (legacy mode does not work on iOS). Android does not support passkey pairing with BLE HID keyboards.
  • web_control (Optional, bool): Enable a built-in web control page with keyboard and mouse UI at http://<device-ip>/ble_keyboard. Requires the web_server component. Defaults to false.
  • host_slots (Optional, int): Number of host slots for multi-host switching (1–10). Each slot can store a bonded host. Switch between hosts using buttons, HA services, or the web control page. Defaults to 4.
  • mouse_sensitivity (Optional, float): Web mouse base movement multiplier. Defaults to 1.0. Range: 0.1–10.0.
  • mouse_acceleration (Optional, float): Web mouse speed-based acceleration factor. Defaults to 0.15. Range: 0.0–2.0.
  • mouse_max_speed (Optional, float): Web mouse maximum sensitivity cap. Defaults to 4.0. Range: 0.5–20.0.
  • scroll_sensitivity (Optional, float): Web mouse scroll speed multiplier. Defaults to 2.0. Range: 0.1–10.0.
  • custom_text_id (Optional, ID or list of IDs): Link one or more ESPHome text entities for custom text input. Automatically registers a "Send" button in the web UI for each. Use send_custom_text or send_custom_text:N action to trigger.
  • keyboard_layout (Optional, string): Default keyboard layout. One of us (default), uk, de, be. Controls how send_string maps each character to USB HID keycodes — must match the host's keyboard layout. Can be overridden at runtime from the web UI (persisted to NVS, survives reboot). See Keyboard layouts below.
  • hosts (Optional, list): Per-slot passkey and pairing mode overrides. Each entry has:
    • slot (Required, int): Host slot number (0–9).
    • passkey (Optional, int): 6-digit PIN for this slot (000000–999999). If omitted, the slot uses the global passkey setting (or Just Works if no global passkey).
    • passkey_mode (Optional, string): legacy (default) or secure_connections. Overrides the global passkey_mode for this slot.

button (Platform: ble_keyboard)

  • keyboard_id (Required, ID): The ID of the ble_keyboard component.
  • action (Required, string or mapping): The action to perform when the button is pressed. Accepts either a string or a dict with type key (see below).

binary_sensor (Platform: ble_keyboard)

The binary_sensor platform supports four types via the type key:

Paired Sensor (default)

Reports whether the keyboard has completed BLE pairing with a host on the current connection.

  • keyboard_id (Required, ID): The ID of the ble_keyboard component.
  • type (Optional, string): paired (default).
  • name (Optional, string): Friendly entity name shown in Home Assistant.

State behavior:

  • ON = a GAP: Pairing Successful event occurred on the current connection.
  • OFF = keyboard is disconnected (including host-side unpair) or not yet paired in this session.

LED State Sensors (Num Lock / Caps Lock / Scroll Lock)

Expose the host-side keyboard LED state, as reported by the connected host via the HID output report. Updates within one loop cycle of the host changing the lock state.

  • keyboard_id (Required, ID): The ID of the ble_keyboard component.
  • type (Required, string): One of num_lock, caps_lock, scroll_lock.
  • name (Optional, string): Friendly entity name shown in Home Assistant.

State behavior:

  • ON = the corresponding lock LED is currently lit on the host.
  • OFF = the lock is off, or no host has sent an LED report yet.
binary_sensor:
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    type: num_lock
    name: "BLE Keyboard Num Lock"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    type: caps_lock
    name: "BLE Keyboard Caps Lock"

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    type: scroll_lock
    name: "BLE Keyboard Scroll Lock"

Note: LED state reflects what the host thinks the lock state is. After re-pairing or host switching, sensors may briefly show stale values until the host sends a fresh LED report.

sensor (Platform: ble_keyboard)

The sensor platform supports two types via the type key:

RSSI Sensor (default)

Exposes the RSSI (signal strength) of the currently connected host as an ESPHome sensor entity.

  • keyboard_id (Required, ID): The ID of the ble_keyboard component.
  • type (Optional, string): rssi (default).
  • name (Optional, string): Friendly entity name shown in Home Assistant.
  • update_interval (Optional, duration): How often to read RSSI from the connected host. Default: 10s.

State behavior:

  • Publishes the RSSI value in dBm (e.g. -65) while a host is connected.
  • Publishes unavailable when the host disconnects.
sensor:
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "BLE Host RSSI"
    update_interval: 15s

Active Host Sensor

Publishes the currently active host slot number (0-based). Updates instantly when the host is switched from the webserver, HA card, or YAML automation. Required for the keyboard card's host display to stay in sync.

  • keyboard_id (Required, ID): The ID of the ble_keyboard component.
  • type (Required, string): active_host.
  • name (Optional, string): Friendly entity name shown in Home Assistant.
sensor:
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    type: active_host
    name: "BLE Keyboard Active Host"

The keyboard card auto-detects this entity by name pattern (sensor.*_active_host). If auto-detection fails, set active_host_entity in the card config:

type: custom:ble-keyboard-card
device: bluetooth_keyboard
host_slots: 4
active_host_entity: sensor.bluetooth_keyboard_active_host

Proximity Automations

Use on_rssi_above and on_rssi_below on the main ble_keyboard component to trigger actions based on signal strength. Both fire on every RSSI sample that crosses the threshold — add your own debounce logic (e.g. a script or globals flag) if needed.

Key Description
threshold RSSI value in dBm (−127 to 0). on_rssi_above fires when RSSI > threshold. on_rssi_below fires when RSSI < threshold.

The automation receives a single rssi variable (int, dBm) you can use in lambdas.

ble_keyboard:
  id: my_keyboard
  on_rssi_above:
    threshold: -65      # fires when host is close (strong signal)
    then:
      - logger.log:
          format: "Host nearby (RSSI %d dBm)"
          args: [rssi]
  on_rssi_below:
    threshold: -90      # fires when host moves far away (weak signal)
    then:
      - logger.log:
          format: "Host far away (RSSI %d dBm)"
          args: [rssi]

Tip: Typical indoor RSSI values range from around −40 dBm (very close) to −90 dBm (far/weak). A threshold of −70 to −75 is a reasonable starting point for proximity detection.

Action Types

Action Description
"Hello\n" Type a string. Use \n for Enter. Printable ASCII is supported on all layouts; non-ASCII (e.g. £ ¬ € on UK) is supported via UTF-8 when a layout exposes it. Characters with no layout mapping are silently skipped.
"combo:0x08:0x15" Send a key combination. Format: combo:<modifier_hex>:<keycode_hex>. Use 0x00 as modifier for no modifier key. See Keycode Reference.
"combo:0x00:0x04" Send a plain keypress with no modifier. 0x04 = A, 0x05 = B ... 0x1D = Z.
"consumer:0x0192" Send any HID consumer control code. Format: consumer:<usage_hex>. See Keycode Reference for full list.
"ctrl_alt_del" Send the Ctrl+Alt+Del secure login sequence.
"sleep" HID System Sleep signal — clean OS-level sleep.
"hibernate" Hibernate the PC — saves to disk, full power off. Requires powercfg /hibernate on.
"shutdown" HID System Power Down signal — clean OS-level shutdown.
"power" HID power button — triggers Windows power button action.
"mute" Toggle mute.
"volume_up" Volume up.
"volume_down" Volume down.
"play_pause" Play / pause media.
"next_track" Skip to next track.
"prev_track" Previous track.
"stop" Stop media playback.
"left_click" Mouse left click.
"right_click" Mouse right click.
"middle_click" Mouse middle click.
"mouse_click:0x01" Mouse click with button mask. 0x01 = left, 0x02 = right, 0x04 = middle. Combine for simultaneous buttons.
"mouse_move:<x>:<y>" Move mouse cursor. Values -127 to 127 (relative, pixels).
"mouse_scroll:<wheel>" Scroll mouse wheel. Positive = up, negative = down (-127 to 127).
"switch_host:N" Switch to host slot N (0–9). Reconnects to stored host or advertises for new pairing.
"forget_host:N" Remove BLE bond for host slot N (0–9) and clear the slot.
"string:hello" Explicit text typing — useful in multi-step macros to distinguish text from action names.
"delay:N" Pause for N milliseconds (max 10000). Used between steps in multi-step macros.
"send_custom_text" Send the first linked text entity's content. Requires custom_text_id in config.
"send_custom_text:N" Send the Nth linked text entity (0-based). E.g. send_custom_text:1 for the second.

Lambda helpers (for use in YAML automations):

Method Description
execute_action("action_string") Run any action string from a lambda. Works with all action types above. Supports multi-step with |.
execute_macro(index) Run a web-defined macro by index (0-based, shown as [0], [1] in web UI). Returns false if index is out of range.

Dict Action Format

Instead of a string, action also accepts a mapping with a type key. This can be more readable for complex actions:

# Combo — modifier + key
action:
  type: combo
  modifier: 0x01   # 0x00 = none, 0x01 = Ctrl, 0x02 = Shift, 0x04 = Alt, 0x08 = Win
  key: 0x04        # 0x04 = A ... 0x1D = Z, see Keycode Reference

# Plain keypress — no modifier
action:
  type: combo
  modifier: 0x00
  key: 0x04        # Just 'A'

# Consumer control
action:
  type: consumer
  code: 0x0192     # Open Calculator

# Mouse click
action:
  type: mouse_click
  buttons: 0x01    # 0x01 = left, 0x02 = right, 0x04 = middle

# Mouse move
action:
  type: mouse_move
  x: 50            # move 50px right
  y: -20           # move 20px up

# Mouse scroll
action:
  type: mouse_scroll
  wheel: 3         # scroll up 3 notches (negative = down)

# Switch host
action:
  type: switch_host
  slot: 1             # switch to host slot 1

# Forget host
action:
  type: forget_host
  slot: 2             # remove bond for host slot 2

Both formats are equivalent — the dict format is converted to the string format at compile time so there is no runtime difference.


Multi-Host Switching

The keyboard supports up to 10 bonded hosts and can switch between them on the fly — like commercial keyboards with a host-switch button. Each host slot stores the bonded device address in NVS (persistent across reboots).

How It Works

  1. Pair your first host — it is automatically saved to slot 0.
  2. Switch to an empty slot (e.g. slot 1) — the keyboard disconnects and starts advertising. Pair a new host; it is saved to that slot.
  3. Switch back — the keyboard disconnects from the current host and uses directed advertising to reconnect to the stored host. The target host reconnects automatically (no re-pairing needed).

Each host slot uses a unique BLE address, so other bonded hosts won't interfere during pairing.

Switching takes 1–3 seconds depending on the host OS.

YAML Configuration

ble_keyboard:
  id: my_keyboard
  host_slots: 4          # 1–10, default: 4

button:
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Host 0"
    action:
      type: switch_host
      slot: 0

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Host 1"
    action:
      type: switch_host
      slot: 1

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Host 2"
    action:
      type: switch_host
      slot: 2

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Host 3"
    action:
      type: switch_host
      slot: 3

  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Forget Host 0"
    action:
      type: forget_host
      slot: 0
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Forget Host 1"
    action:
      type: forget_host
      slot: 1
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Forget Host 2"
    action:
      type: forget_host
      slot: 2
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Forget Host 3"
    action:
      type: forget_host
      slot: 3            

String action format is also supported: "switch_host:0", "forget_host:2".

Host Switching from Home Assistant

Add an ESPHome service to trigger host switching from HA automations:

api:
  services:
    - service: switch_host
      variables:
        slot: int
      then:
        - lambda: |-
            id(my_keyboard).switch_host(slot);
    - service: forget_host
      variables:
        slot: int
      then:
        - lambda: |-
            id(my_keyboard).forget_host(slot);

Web Control

When web_control: true is enabled, a full control page is available at http://<device-ip>/ble_keyboard with keyboard, mouse, and remote sections. Section toggle buttons in the toolbar let you show/hide each section. When host_slots > 1, a host bar appears below the toolbar showing all slots. Click a slot to switch. The active slot is highlighted. Occupied slots show the stored Bluetooth address.

Web Control Page

Action Reference

Action Description
"switch_host:N" Switch to host slot N (0–9). If the slot has a stored host, uses directed advertising to reconnect. If empty, starts normal advertising for new pairing.
"forget_host:N" Remove the bond for host slot N (0–9). Clears the stored address and removes the BLE bond from the ESP32. If the forgotten host is currently connected, it is disconnected.

Mouse Control Card for Home Assistant

A custom Lovelace card is included that provides a touchpad, 3 mouse buttons, and scroll controls. It requires ESPHome services to be defined so Home Assistant can call the mouse functions with parameters.

1. Add ESPHome services

Add the following to your ESPHome device YAML (alongside the existing api: section):

api:
  encryption:
    key: ${api_encryption_key}
  services:
    - service: mouse_move
      variables:
        x: int
        y: int
      then:
        - lambda: |-
            id(my_keyboard).send_mouse_move(x, y);
    - service: mouse_scroll
      variables:
        amount: int
      then:
        - lambda: |-
            id(my_keyboard).send_mouse_scroll(amount);
    - service: mouse_click
      variables:
        btn: int
      then:
        - lambda: |-
            id(my_keyboard).send_mouse_click(btn);

2. Install the card

  1. Copy docs/mouse-card.js to your Home Assistant config/www/ folder.
  2. In Home Assistant: Settings -> Dashboards -> Resources -> Add Resource
    • URL: /local/mouse-card.js
    • Type: JavaScript Module

3. Add to a dashboard

type: custom:ble-mouse-card
device: bluetooth_keyboard    # your ESPHome device name (underscored)

Example with all optional overrides:

type: custom:ble-mouse-card
device: bluetooth_keyboard
name: Living Room Mouse       # card title (auto-detected from HA if omitted)
sensitivity: 2.0              # base cursor speed (default: 1.5)
mouse_acceleration: 0.2       # speed-based acceleration factor (default: 0.15)
mouse_max_speed: 6.0          # max sensitivity cap (default: 4.5)
scroll_sensitivity: 3         # faster scroll (default: 2)
tap_to_click: false           # disable tap-to-click (default: true)

Optional configuration:

Option Default Description
name Auto from HA Card title. Auto-detected from HA device registry if omitted.
sensitivity 1.5 Base cursor speed multiplier.
mouse_acceleration 0.15 Speed-based acceleration factor. Higher = more acceleration on fast swipes.
mouse_max_speed 4.5 Maximum sensitivity cap. Limits how fast the cursor can move.
scroll_sensitivity 2 Scroll speed multiplier.
tap_to_click true Tap the touchpad for a left click (5px dead zone prevents accidental clicks).

Features:

  • Touchpad — 16:9 aspect ratio, drag to move cursor, tap for left click, mouse wheel/trackpad scroll.
  • Mouse acceleration — slow movements are precise, fast swipes cover more ground.
  • Buttons — Left, Middle, Right click.
  • Scroll — Scroll Up / Scroll Down buttons (hold to repeat).
  • Auto device name — card title is auto-detected from Home Assistant's device registry.

Mouse HA Card


Web Control (Standalone — No Home Assistant)

A built-in web page with full keyboard and mouse control, served directly from the ESP32. Access it from any browser on the same network — no Home Assistant required.

Setup

  1. Add web_server and enable web_control in your YAML:
web_server:
  port: 80

ble_keyboard:
  id: my_keyboard
  web_control: true
  1. Flash and open http://<device-ip>/ble_keyboard in any browser or phone.

Web Control Link in Home Assistant

Add this sensor to your YAML to get a clickable link in HA that opens the web control page:

text_sensor:
  - platform: wifi_info
    ip_address:
      id: wifi_ip
      internal: true
  - platform: template
    name: "Web Control"
    icon: "mdi:keyboard"
    lambda: |-
      return {"http://" + id(wifi_ip).state + "/ble_keyboard"};
    update_interval: 60s

In Home Assistant, the sensor value will be a URL like http://192.168.1.100/ble_keyboard. Click it to open the web control page directly.

Features

  • Full QWERTY keyboard — letters, numbers, symbols, F-keys, modifiers, arrows
  • Mouse touchpad — 16:9 aspect ratio, drag to move cursor, tap for left click (5px dead zone prevents accidental clicks)
  • Mouse acceleration — slow movements are precise, fast swipes cover more ground (up to 4x)
  • Mouse buttons — Left, Middle, Right click
  • Scroll controls — buttons + mouse wheel on the touchpad
  • Remote control — D-pad navigation (Up/Down/Left/Right/Enter), Power, Home, Back, Search, Volume +/-, Mute with hold-to-repeat
  • Section toggles — show/hide Keyboard, Mouse, Remote, and Buttons sections individually (state saved in browser)
  • Zoom controls — resize keyboard and mouse with +/- buttons (50%–150%)
  • Light/dark theme — toggle between dark and light mode, preference saved in browser
  • BLE connection status — live indicator shows Connected, Paired, or Disconnected (polls every 3s)
  • Device name display — shows the configured device_name in the toolbar and browser tab title
  • Programmed buttons — any buttons defined in YAML appear as clickable buttons on the web page
  • Zero dependencies — no HA, no custom cards, no JS files to install
  • Works from any phone — just open the URL in a mobile browser

REST API

The web control page uses these local HTTP endpoints (useful for custom integrations):

Endpoint Method Parameters Description
/api/ble_keyboard/string POST keys (string) Type text
/api/ble_keyboard/key POST modifier (int), keycode (int) Send key combo
/api/ble_keyboard/mouse_move POST x (int), y (int) Move cursor
/api/ble_keyboard/mouse_click POST btn (int) Click button
/api/ble_keyboard/mouse_scroll POST amount (int) Scroll wheel
/api/ble_keyboard/status GET Returns {"connected":bool,"paired":bool,"device_name":"..."}
/api/ble_keyboard/buttons GET Returns JSON array of programmed buttons
/api/ble_keyboard/press POST action (string) Trigger a programmed button action
/api/ble_keyboard/hosts GET Returns {"active":N,"slots":[{"slot":N,"occupied":bool,"addr":"XX:XX:..."},...]}
/api/ble_keyboard/switch_host POST slot (int) Switch to host slot 0–9
/api/ble_keyboard/forget_host POST slot (int) Remove bond for host slot 0–9
/api/ble_keyboard/macro_add POST name, action Add a new macro (max 16)
/api/ble_keyboard/macro_update POST index, name, action Update an existing macro
/api/ble_keyboard/macro_delete POST index (int) Delete a macro by index

Example: curl -X POST "http://<device-ip>/api/ble_keyboard/string?keys=Hello"


Keyboard Control Card for Home Assistant

A custom Lovelace card that provides a full on-screen QWERTY keyboard. It requires ESPHome services to be defined so Home Assistant can send keystrokes and text.

1. Add ESPHome services

Add the following services to your ESPHome device YAML (alongside any existing mouse services):

api:
  encryption:
    key: ${api_encryption_key}
  services:
    - service: send_string
      variables:
        keys: string
      then:
        - lambda: |-
            id(my_keyboard).send_string(keys);
    - service: send_key
      variables:
        modifier: int
        keycode: int
      then:
        - lambda: |-
            id(my_keyboard).send_key_combo(modifier, keycode);

2. Install the card

  1. Copy docs/keyboard-card.js to your Home Assistant config/www/ folder.
  2. In Home Assistant: Settings -> Dashboards -> Resources -> Add Resource
    • URL: /local/keyboard-card.js
    • Type: JavaScript Module

3. Add to a dashboard

type: custom:ble-keyboard-card
device: bluetooth_keyboard    # your ESPHome device name (underscored)

Example with all optional overrides:

type: custom:ble-keyboard-card
device: bluetooth_keyboard
name: Living Room Keyboard    # card title (auto-detected from HA if omitted)
show_fkeys: true             # hide F1-F12 row (default: true)
layout: us                    # us (default), uk, de, or be — match the ESP's keyboard_layout
host_slots: 4                 # show host switcher bar (default: 0 = hidden)
host_names:                   # custom names for each slot (optional)
  - TV
  - Phone
  - Laptop
  - Tablet
active_host_entity: sensor.bluetooth_keyboard_active_host  # (auto-detected)

Minimal UK layout example:

type: custom:ble-keyboard-card
device: bluetooth_keyboard
layout: uk

Optional configuration:

Option Default Description
name Auto from HA Card title. Auto-detected from HA device registry if omitted.
show_fkeys true Show the F1–F12 function key row.
layout us Keyboard layout for the on-screen card: us, uk, de, or be. UK draws the ISO shape (extra | key, £ on Shift+3); DE draws QWERTZ (Y/Z swapped, ü/ö/ä/ß keys, German modifier labels); BE draws AZERTY (A↔Q and Z↔W swapped, M on home row, é è à ç ù on the digit row). Set this to match the ESP's keyboard_layout option so the visual matches what gets typed.
host_slots 0 Number of host slots. Set to match your host_slots config to show a host switcher bar with prev/next buttons, host name, and MAC address. 0 hides the bar.
host_names [] List of custom names for each host slot (e.g., ["TV", "Phone"]). Index 0 = slot 0, etc. Falls back to switch_host button names from the ESP32, then "Host N".
active_host_entity Auto Entity ID of the active host sensor. Auto-detected by name pattern (sensor.*_active_host). Set explicitly if auto-detection fails.

Features:

  • Full QWERTY layout — letters, numbers, punctuation, all standard keys.
  • Modifier keys — Ctrl, Alt, Win, Shift are sticky (toggle on, auto-release after next key).
  • Caps Lock — persistent toggle with visual indicator.
  • Function keys — F1–F12 (can be hidden with show_fkeys: false).
  • Arrow keys — Up, Down, Left, Right + Delete.
  • Shift labels — key labels update to show shifted characters when Shift is active.
  • Host switcher — prev/next buttons to switch hosts, shows current host name and MAC address (requires host_slots and switch_host ESPHome service).
  • Auto device name — card title is auto-detected from Home Assistant's device registry.
  • Keyboard layoutslayout: us (default), layout: uk, layout: de, or layout: be renders the matching ANSI/ISO/QWERTZ/AZERTY shape with the correct shifted labels.

Note: Caps Lock state is tracked locally in the card. If Caps Lock is toggled from another keyboard, the card indicator may be out of sync.

Keyboard HA Card


Media Remote Card for Home Assistant

A custom Lovelace card that provides a modern media remote control with power, navigation D-pad, volume, media playback, and app launch buttons.

1. Add ESPHome services

Add the following services to your ESPHome device YAML (alongside any existing keyboard/mouse services):

api:
  encryption:
    key: ${api_encryption_key}
  services:
    - service: send_string
      variables:
        keys: string
      then:
        - lambda: |-
            id(my_keyboard).send_string(keys);
    - service: send_key
      variables:
        modifier: int
        keycode: int
      then:
        - lambda: |-
            id(my_keyboard).send_key_combo(modifier, keycode);
    - service: send_consumer
      variables:
        code: int
      then:
        - lambda: |-
            id(my_keyboard).send_consumer(code);

2. Install the card

  1. Copy docs/remote-card.js to your Home Assistant config/www/ folder.
  2. In Home Assistant: Settings -> Dashboards -> Resources -> Add Resource
    • URL: /local/remote-card.js
    • Type: JavaScript Module

3. Add to a dashboard

type: custom:ble-remote-card
device: bluetooth_keyboard    # your ESPHome device name (underscored)

Example with all optional overrides:

type: custom:ble-remote-card
device: bluetooth_keyboard
name: Living Room Remote      # card title (auto-detected from HA if omitted)
show_numpad: true             # show number pad (default: false)
show_apps: true               # show app launch row (default: true)
show_color: true              # show color buttons (default: false)

Optional configuration:

Option Default Description
name Auto from HA Card title. Auto-detected from HA device registry if omitted.
show_numpad false Show a number pad (0–9) for channel entry or PIN input.
show_apps true Show app launch buttons (Explorer, Browser, Email, Calc, Search).
show_color false Show red/green/yellow/blue color buttons (mapped to F1–F4).

Features:

  • Power button — HID power signal for clean OS-level power control.
  • D-pad navigation — arrow keys + Enter, ideal for media apps and menus.
  • Back & Home — Escape and Windows key for quick navigation.
  • Volume — up, down, and mute with hold-to-repeat.
  • Channel — Page Up/Down with hold-to-repeat for channel surfing.
  • Media playback — play/pause, stop, previous, next, rewind, fast forward.
  • App launchers — quick launch Explorer, Browser, Email, Calculator, Search.
  • Number pad — optional 0–9 keypad for channel/PIN entry.
  • Color buttons — optional red/green/yellow/blue (F1–F4).
  • Auto device name — card title is auto-detected from Home Assistant's device registry.

Remote HA Card


Web Macros

When web_control: true is enabled, macros can be created, edited, and deleted directly from the web UI at /ble_keyboard — no reflash needed. Macros are stored in NVS flash and persist across reboots. Up to 16 macros are supported.

The web UI provides:

  • Add form with name, action textarea, and a preset dropdown (media, system, clipboard, consumer HID, text, delays)
  • Combo builder — toggle Ctrl/Shift/Alt/Win modifier buttons, then pick a key (F1-F12, arrows, letters, numbers, etc.) to insert combo:mod:key
  • Edit/Delete controls on each macro (pencil and X buttons)
  • Macro index shown as [0], [1], etc. next to each macro name — use with execute_macro(N) in YAML
  • YAML-defined buttons appear alongside macros but are not editable
  • Selecting a preset or key appends to the action field with |, making it easy to build multi-step macros

Multi-Step Macros

Macros support multiple commands separated by |. A 50ms delay is automatically inserted between steps. Use delay:N for explicit pauses (max 10000ms).

Examples:

Action string Description
combo:2:6 | delay:100 | combo:2:25 Copy, wait 100ms, Paste
combo:2:4 | delay:50 | combo:2:6 Select All, Copy
play_pause | delay:500 | next_track Play/Pause, wait 500ms, Next Track
combo:0:40 | delay:200 | combo:0:40 Enter twice with 200ms gap
combo:2:4 | delay:50 | string:hello Select All, type "hello"

Multi-step actions work everywhere: web macros, YAML buttons, execute_action(), and the /api/ble_keyboard/press endpoint.

Triggering Macros from YAML

Use execute_macro(index) to run a macro by its index (0-based), or execute_action("action_string") to run any action string:

binary_sensor:
  - platform: gpio
    pin: GPIO0
    name: "Macro Button"
    on_press:
      then:
        - lambda: |-
            id(my_keyboard).execute_macro(0);  // run first web macro
button:
  - platform: template
    name: "Copy-Paste"
    on_press:
      then:
        - lambda: |-
            id(my_keyboard).execute_action("combo:2:6 | delay:100 | combo:2:25");

Macro REST API

Method Endpoint Parameters Description
GET /api/ble_keyboard/buttons Returns all buttons and macros as JSON. Macros have "editable":true and "index":N.
POST /api/ble_keyboard/macro_add name, action Add a new macro (max 16).
POST /api/ble_keyboard/macro_update index, name, action Update an existing macro.
POST /api/ble_keyboard/macro_delete index Delete a macro by index.

Custom Text Input

You can send arbitrary text from Home Assistant to the paired host device without hardcoding it in the YAML. Link text entities to the keyboard component with custom_text_id, then use the send_custom_text action:

ble_keyboard:
  id: my_keyboard
  custom_text_id:
    - custom_text          # links the text entity below
    # - username_text      # add more text entities as needed

text:
  - platform: template
    name: "Custom Text"
    id: custom_text
    mode: text
    optimistic: true

button:
  - platform: ble_keyboard
    keyboard_id: my_keyboard
    name: "Send Custom Text"
    action: "send_custom_text"       # sends first text entity (index 0)
    # action: "send_custom_text:1"   # sends second text entity (index 1)

This adds a text input field and a send button to both Home Assistant and the web UI (via auto-registered buttons). A single ID also works: custom_text_id: custom_text.

You can also drive it from a Home Assistant automation — for example, updating the text entity from an input_text helper and then pressing the button:

automation:
  - alias: "Send text via BLE keyboard"
    trigger:
      - platform: state
        entity_id: input_text.ble_keyboard_text
    action:
      - service: text.set_value
        target:
          entity_id: text.bluetooth_keyboard_custom_text
        data:
          value: "{{ states('input_text.ble_keyboard_text') }}"
      - service: button.press
        target:
          entity_id: button.bluetooth_keyboard_send_custom_text

Note: Printable ASCII and Tab are supported on every layout. Non-ASCII characters work when they're part of the active layout's Unicode table (e.g. £, ¬, on uk). Unmapped characters and most control characters are silently skipped.


Keyboard layouts

The component supports multiple keyboard layouts. The active layout affects how characters in send_string are translated into USB HID (modifier, keycode) pairs. It must match the host PC's keyboard setting — typing @ from the ESP under us while the host is set to UK produces ", since the host reinterprets the same physical key under its own layout.

Supported layouts

ID Name Notes
us English (US) Default. ANSI shape.
uk English (UK) ISO shape. Adds £, ¬, via UTF-8 (AltGr for ).
de German (QWERTZ) ISO shape. Y/Z swapped. Adds ä, ö, ü, ß, , §, °, µ, ², ³ via UTF-8. Dead keys (^, `, ~, ´) auto-completed with a trailing space so they type as bare characters via send_string.
be Belgian (AZERTY) ISO shape. A↔Q, Z↔W swapped, M moves to home row right of L. Digits 0–9 require Shift (unshifted digit row is & é " ' ( § è ! ç à). Direct accented chars: é è à ç ù € £ ² ³ § µ. Dead-key + vowel sequences auto-composed by send_string for â ê î ô û (circumflex), Â Ê Î Ô Û (uppercase circumflex via Shift on the 2nd stroke), ä ë ï ö ü (diaeresis), and Ä Ë Ï Ö Ü. Literal ^ ` ~ use dead-key + space (DE-style). AltGr layer: `@ # { } [ ]

German (DE) AltGr characters

A standard German keyboard prints these characters in the lower-right corner of certain keys. The on-screen keyboards (both the device's web UI and the HA Lovelace card) render the same hint labels. To type them: toggle AltGr (right Alt) on the on-screen keyboard, then click the key. send_string resolves all of them directly via UTF-8 — no AltGr toggle needed.

Combo Char Combo Char
AltGr+q @ AltGr+8 [
AltGr+e AltGr+9 ]
AltGr+m µ AltGr+0 }
AltGr+2 ² AltGr+ß \
AltGr+3 ³ AltGr++ ~
AltGr+7 { AltGr+< |

Setting the layout

YAML (default at boot):

ble_keyboard:
  id: my_keyboard
  device_name: "ESP32 BLE KB"
  keyboard_layout: uk

Web UI (overrides YAML, persisted to NVS): open http://<device-ip>/ble_keyboard and use the layout dropdown in the Keyboard card header. The choice is saved and survives reboot. Erasing NVS reverts to the YAML default.

Precedence note: if you change keyboard_layout in YAML and reflash, the new value takes effect on the next boot — any previous web-UI override is automatically cleared. Web-UI overrides only persist across reboots while the YAML value stays the same. No factory reset needed to "see" a YAML edit.

Per host slot (YAML, auto-applied on switch): add layout: to any entry in the hosts: list to bind a layout to that slot. When you switch to that host (via service, button, or web UI), the device flips to its layout automatically. This is ephemeral — it does not overwrite a manual web-UI pick in NVS, and switching to a slot with no layout: keeps whatever was active.

ble_keyboard:
  id: my_keyboard
  host_slots: 4
  hosts:
    - slot: 0
      layout: us
    - slot: 1
      layout: uk

Matching the host's layout

The device layout only sets how the ESP turns characters into HID codes — the host then re-interprets those codes under its own layout. If they don't agree you'll see wrong symbols (e.g. # arriving as \ when the ESP is on uk but the host is on us).

  • Windows: Settings → Time & language → Language & region → pick the language (e.g. English (United Kingdom)) → Options → Keyboards → leave United Kingdom. Switch with Win+Space.
  • Android: Settings → System → Languages & input → Physical keyboard → tap the BLE keyboard's name → Set up keyboard layouts → enable English (UK). Android defaults every BLE keyboard to US until you do this. (Samsung / OneUI path: Settings → General management → Physical keyboard.)
  • iOS / iPadOS: Settings → General → Keyboard → Hardware Keyboard → tap the layout name pick British.
  • Linux (Wayland / GNOME): Settings → Keyboard → Input Sources → add English (UK), then move it to the top, or use setxkbmap gb on X11. For German use Deutsch / setxkbmap de.

Adding a new layout

The layout system is intentionally small. Adding a new layout (e.g. French AZERTY) touches just three places:

  1. components/ble_keyboard/keyboard_layouts.cpp — add HID_ASCII_MAP_XX[128] + (optionally) UNICODE_MAP_XX[] and append one entry to the LAYOUTS[] registry array.
  2. components/ble_keyboard/__init__.py — append "xx" to SUPPORTED_LAYOUTS.
  3. components/ble_keyboard/web_control.cpp — append an xx: { ROWS: [...] } entry to the JS LAYOUTS object. If you also ship the HA keyboard card, mirror the entry into docs/keyboard-card.js.

No header changes, no send_string changes, no NVS code changes. The web UI dropdown, /api/ble_keyboard/status JSON, and YAML validation pick the new layout up automatically.

Dead keys (characters that wait for a follow-up on the host, e.g. ^, `, ~, ´ on German): set the optional third field followup_keycode in HidKeyMapping/UnicodeKeyMapping to the HID scan code for space (0x2C). send_string will emit the dead key followed by space, which composes to the bare character on the host.

Notes

  • Characters with no mapping in the active layout are skipped silently (a debug log is emitted).
  • A layout switch in the middle of a typing operation can't corrupt in-flight text — keystrokes are pre-resolved at enqueue time using whatever layout was active then.
  • combo: actions (raw HID (modifier, keycode) pairs) are layout-independent by design. Macros built from combo: keep working unchanged after a layout change.
  • The web "Keyboard" card visual reflects the active layout (US shows ANSI, UK shows ISO with the extra \| key and £ on Shift+3, DE shows QWERTZ with ü/ö/ä/ß keys and German modifier labels).

Pairing with Windows

When you first flash the device or change the passkey:

  1. Open Bluetooth & other devices on Windows.
  2. If your device name (default: "ESP32 BLE KB") is already listed, Remove Device.
  3. Click Add device -> Bluetooth.
  4. Select your device name (default: "ESP32 BLE KB").
  5. Windows will prompt you to enter the PIN. Type your configured passkey (e.g., 123456) and click Connect.

Pairing with Android

Android does not support passkey pairing with BLE HID keyboards. For reliable pairing:

  1. Do not set a passkey in ble_keyboard (omit the passkey option entirely).
  2. Use passkey_mode: legacy (the default).
  3. In Android Bluetooth settings, remove any previous entry for your device name (default: ESP32 BLE KB) before re-pairing.
  4. Start pairing - it should connect instantly without prompting for a PIN.

Android uses Just Works pairing for BLE HID devices. Attempting to use passkeys will result in pairing failures or automatic fallback to Just Works.


Pairing with iOS

For iOS using passkey pairing:

  1. Set passkey and passkey_mode: secure_connections in ble_keyboard.
  2. Remove any previous bond for your device name (default: ESP32 BLE KB) from iOS Bluetooth settings.
  3. Reboot the ESP32 (or reflash), then pair again from iOS.
  4. Enter the configured passkey when prompted.

For Just Works pairing (no passkey), use passkey_mode: secure_connections for best compatibility.

After pairing, you should see all CCC subscriptions in the log (keyboard, consumer, system) confirming iOS has fully enumerated the HID service.

Notes:

  • passkey_mode: secure_connections is the tested and recommended mode for iOS.
  • The component includes Device Information and Battery services required by iOS for HOGP (HID over GATT Profile) compliance.
  • macOS is expected to work the same way but has not been explicitly tested.

Known Working Pairing Notes

The current implementation has been validated on Windows, Android, and iOS. Tested on Windows 11, Android 16, and iOS. For first-time pairing, Android may require more than one attempt while it refreshes BLE cache and bond state.

Recommended pairing modes:

  • Fastest pairing (recommended for Android/Windows): Omit passkey (Just Works) with passkey_mode: legacy. Pairs instantly on Android and Windows. For iOS, use passkey_mode: secure_connections.
  • Windows with passkey: Set passkey with passkey_mode: legacy. Pairs quickly with PIN entry.
  • iOS with passkey: Set passkey with passkey_mode: secure_connections (legacy mode does not work on iOS).

Recommended order:

  1. Turn off Bluetooth on other nearby hosts (especially Windows) to avoid auto-connect races.
  2. Remove old keyboard entries from the phone/PC.
  3. Retry pairing from the target host.

After the first successful bond, reconnect behavior is typically stable.


Troubleshooting

  • Not appearing in search: Ensure no other device is currently connected. The ESP32 stops advertising once a connection is established.
  • PIN prompt not appearing: Windows often caches old security profiles. Fully "Remove" the device from Windows Bluetooth settings and try again.
  • Windows needs multiple pairing attempts: Remove old Bluetooth entries first, then retry pairing after the first failed attempt. The component now avoids duplicate advertising restarts and keeps existing bonds unless the auth failure is a known 0x51 mismatch.
  • Android says "can't connect": Android often keeps stale BLE bonds. Remove the device from Bluetooth settings, reboot the ESP32, then pair again. If still failing, toggle phone Bluetooth off/on and retry.
  • Android pairing issues: Android does not support passkey pairing with BLE HID keyboards. Ensure no passkey is configured in your YAML - use Just Works pairing with passkey_mode: legacy. Remove old bonds and try again.
  • Wrong symbols on Android (# shows as \, " shows as @, etc.): Android defaults connected BLE keyboards to US layout. Change it under Settings → System → Languages & input → Physical keyboard → [device name] → Set up keyboard layouts → English (UK). See Matching the host's layout.
  • iOS not pairing: Set passkey_mode: secure_connections, remove old Bluetooth bonds on both devices, then pair again.
  • iOS pairs but no typing/control: Ensure you are using passkey_mode: secure_connections. Remove the bond on both the iOS device and the ESP32 (reboot/reflash), then pair again. After pairing, check the log for Consumer CCC=0x0001 and System CCC=0x0001 — if these are missing, iOS has not fully subscribed to the HID reports. Reflash and re-pair from a clean state.
  • Typing speed / dropped characters: The default key_delay_ms: 80 (40ms key-down + 40ms key-up) suits most connections. If characters are dropped on a slow BLE connection, increase this value (e.g. key_delay_ms: 120). If typing feels too slow, it can be reduced.
  • Hibernate not working: Hibernate uses the Windows Run dialog. Ensure the PC is not in a state where it is blocked (e.g., fullscreen app or UAC prompt). Also ensure hibernate is enabled: run powercfg /hibernate on in an admin command prompt.
  • PC not waking from sleep: Check that USB Wake Support (or similar) is enabled in your BIOS/UEFI Power Management settings.
  • Re-pair after firmware update: If the HID descriptor changes (e.g. after adding media keys), you must remove and re-pair the device in Windows Bluetooth settings.

About

ESP32 BLE HID Keyboard custom component for ESPHome

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages