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.
- 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: legacyfor Windows (Just Works for Android),passkey_mode: secure_connectionsfor 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, orbein 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,hibernateandshutdown. - 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:0xXXXXsyntax. - 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
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 Bluetoothsdkconfigthat's easy to get wrong), instantiates theble_keyboard(idkb), a BLE-link sensor, thesend_keysHA 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 theapp_*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 thepad_*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 ownble_device_idfor a dedicated controller device. (D-pad is omitted — no hat in the descriptor; use the navigation buttons inmedia_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.
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 fromBUTTON_1= 188, soBUTTON_N=187 + N). That's a gamepad button, not a keyboard/consumer usage, so sendbutton: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. Ifbutton:11doesn't open the menu, sweepbutton:1…button:32and 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.)
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: 0xA1B2Two things in the open: the PnP ID is read at bond time, so changing it means
re-pairing once; and VIDEO_APP_1–8 / FEATURED_APP_2–4 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).
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: 0x0011Why 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 | 0x42–0x45 |
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.
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: 12Add 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}- 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_connectionsuses 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 theweb_servercomponent. Defaults tofalse. - 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
textentities for custom text input. Automatically registers a "Send" button in the web UI for each. Usesend_custom_textorsend_custom_text:Naction to trigger. - keyboard_layout (Optional, string): Default keyboard layout. One of
us(default),uk,de,be. Controls howsend_stringmaps 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
passkeysetting (or Just Works if no global passkey). - passkey_mode (Optional, string):
legacy(default) orsecure_connections. Overrides the globalpasskey_modefor this slot.
- keyboard_id (Required, ID): The ID of the
ble_keyboardcomponent. - action (Required, string or mapping): The action to perform when the button is pressed. Accepts either a string or a dict with
typekey (see below).
The binary_sensor platform supports four types via the type key:
Reports whether the keyboard has completed BLE pairing with a host on the current connection.
- keyboard_id (Required, ID): The ID of the
ble_keyboardcomponent. - type (Optional, string):
paired(default). - name (Optional, string): Friendly entity name shown in Home Assistant.
State behavior:
- ON = a
GAP: Pairing Successfulevent occurred on the current connection. - OFF = keyboard is disconnected (including host-side unpair) or not yet paired in this session.
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_keyboardcomponent. - 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.
The sensor platform supports two types via the type key:
Exposes the RSSI (signal strength) of the currently connected host as an ESPHome sensor entity.
- keyboard_id (Required, ID): The ID of the
ble_keyboardcomponent. - 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: 15sPublishes 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_keyboardcomponent. - 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_hostUse 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 | 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. |
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 2Both formats are equivalent — the dict format is converted to the string format at compile time so there is no runtime difference.
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).
- Pair your first host — it is automatically saved to slot 0.
- 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.
- 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.
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".
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);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.
| 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. |
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.
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);- Copy
docs/mouse-card.jsto your Home Assistantconfig/www/folder. - In Home Assistant: Settings -> Dashboards -> Resources -> Add Resource
- URL:
/local/mouse-card.js - Type: JavaScript Module
- URL:
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.
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.
- Add
web_serverand enableweb_controlin your YAML:
web_server:
port: 80
ble_keyboard:
id: my_keyboard
web_control: true- Flash and open
http://<device-ip>/ble_keyboardin any browser or phone.
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: 60sIn 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.
- 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_namein 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
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"
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.
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);- Copy
docs/keyboard-card.jsto your Home Assistantconfig/www/folder. - In Home Assistant: Settings -> Dashboards -> Resources -> Add Resource
- URL:
/local/keyboard-card.js - Type: JavaScript Module
- URL:
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: ukOptional 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_slotsandswitch_hostESPHome service). - Auto device name — card title is auto-detected from Home Assistant's device registry.
- Keyboard layouts —
layout: us(default),layout: uk,layout: de, orlayout: berenders 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.
A custom Lovelace card that provides a modern media remote control with power, navigation D-pad, volume, media playback, and app launch buttons.
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);- Copy
docs/remote-card.jsto your Home Assistantconfig/www/folder. - In Home Assistant: Settings -> Dashboards -> Resources -> Add Resource
- URL:
/local/remote-card.js - Type: JavaScript Module
- URL:
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.
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 withexecute_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
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.
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 macrobutton:
- platform: template
name: "Copy-Paste"
on_press:
then:
- lambda: |-
id(my_keyboard).execute_action("combo:2:6 | delay:100 | combo:2:25");| 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. |
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_textNote: 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.
£,¬,€onuk). Unmapped characters and most control characters are silently skipped.
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.
| 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: `@ # { } [ ] |
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+< | | |
YAML (default at boot):
ble_keyboard:
id: my_keyboard
device_name: "ESP32 BLE KB"
keyboard_layout: ukWeb 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_layoutin 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: ukThe 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 gbon X11. For German use Deutsch /setxkbmap de.
The layout system is intentionally small. Adding a new layout (e.g. French AZERTY) touches just three places:
components/ble_keyboard/keyboard_layouts.cpp— addHID_ASCII_MAP_XX[128]+ (optionally)UNICODE_MAP_XX[]and append one entry to theLAYOUTS[]registry array.components/ble_keyboard/__init__.py— append"xx"toSUPPORTED_LAYOUTS.components/ble_keyboard/web_control.cpp— append anxx: { ROWS: [...] }entry to the JSLAYOUTSobject. If you also ship the HA keyboard card, mirror the entry intodocs/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.
- 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 fromcombo: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).
When you first flash the device or change the passkey:
- Open Bluetooth & other devices on Windows.
- If your device name (default: "ESP32 BLE KB") is already listed, Remove Device.
- Click Add device -> Bluetooth.
- Select your device name (default: "ESP32 BLE KB").
- Windows will prompt you to enter the PIN. Type your configured
passkey(e.g.,123456) and click Connect.
Android does not support passkey pairing with BLE HID keyboards. For reliable pairing:
- Do not set a
passkeyinble_keyboard(omit the passkey option entirely). - Use
passkey_mode: legacy(the default). - In Android Bluetooth settings, remove any previous entry for your device name (default: ESP32 BLE KB) before re-pairing.
- 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.
For iOS using passkey pairing:
- Set
passkeyandpasskey_mode: secure_connectionsinble_keyboard. - Remove any previous bond for your device name (default: ESP32 BLE KB) from iOS Bluetooth settings.
- Reboot the ESP32 (or reflash), then pair again from iOS.
- 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_connectionsis 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.
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) withpasskey_mode: legacy. Pairs instantly on Android and Windows. For iOS, usepasskey_mode: secure_connections. - Windows with passkey: Set
passkeywithpasskey_mode: legacy. Pairs quickly with PIN entry. - iOS with passkey: Set
passkeywithpasskey_mode: secure_connections(legacy mode does not work on iOS).
Recommended order:
- Turn off Bluetooth on other nearby hosts (especially Windows) to avoid auto-connect races.
- Remove old keyboard entries from the phone/PC.
- Retry pairing from the target host.
After the first successful bond, reconnect behavior is typically stable.
- 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
0x51mismatch. - 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
passkeyis configured in your YAML - use Just Works pairing withpasskey_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 forConsumer CCC=0x0001andSystem 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 onin 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.



