Browse by type
Make your ESPHome devices speak the (machine) language of your living room with this native HDMI-CEC (Consumer Electronics Control) component!
on_message triggerson_message supports filtering based on source, destination, opcode and/or message contentshdmi_cec.send actionConnect the microcontroller to an HDMI connector (HDMI connectors and breakout boards can be found on Amazon and AliExpress)
| HDMI Pin | Connect to | Microcontroller pin |
|---|---|---|
| 13 (CEC Data Line) | => | Any input/output GPIO (e.g., GPIO26) |
| 17 (CEC Ground) | => | Ground |
| 18 (+5V (optional)) | => | 5V |
CEC uses 3.3V logic – safe for ESP32/ESP8266 (or any other microcontroller with 3.3V logic).
The video below demonstrates how to connect an HDMI breakout board to an ESP32 dev board:
https://github.com/user-attachments/assets/71e847da-4b04-443f-9d97-f764fa97a007
In your ESPhome YAML configuration, add this Git repository as an external component (e.g. below captive portal):
external_components:
- source: github://Palakis/esphome-hdmi-cec
Add the hdmi_cec: block:
hdmi_cec:
# Pick a GPIO pin that can do both input AND output
pin: GPIO26 # Required
# The address can be anything you want. Use 0xF if you only want to listen to the bus and not act like a standard device
address: 0xE # Required
# Physical address of the device. In this case: 4.0.0.0 (HDMI4 on the TV)
# DDC support is not yet implemented, so you'll have to set this manually.
physical_address: 0x4000 # Required
# The name that will be displayed in the list of devices on your TV/receiver
osd_name: "my device" # Optional. Defaults to "esphome"
# By default, promiscuous mode is disabled, so the component only handles directly-address messages (matching
# the address configured above) and broadcast messages. Enabling promiscuous mode will make the component
# listen for all messages (both in logs and the on_message triggers)
promiscuous_mode: false # Optional. Defaults to false
# By default, monitor mode is disabled, so the component can send messages and acknowledge incoming messages.
# Enabling monitor mode lets the component act as a passive listener, disabling active manipulation of the CEC bus.
monitor_mode: false # Optional. Defaults to false
You now have a functioning CEC receiver.
All of the following are optional – include only what you need.
Add under hdmi_cec::
hdmi_cec:
...
on_message:
- opcode: 0x36 # "Standby"
then:
logger.log: "Received standby command"
# Respond to "Menu Request" (not required, example purposes only)
- opcode: 0x8D
then:
hdmi_cec.send:
# both "destination" and "data" are templatable
destination: !lambda return source;
data: [0x8E, 0x01] # 0x01 => "Menu Deactivated"
You can filter by:
If no filter is set, you will catch all messages.
Add a button: section to create UI buttons:
button:
- platform: template
name: "Turn TV Off"
on_press:
hdmi_cec.send:
destination: 0
data: [0x36]
More button examples in the advanced ESPHome configuration example below.
Under api::
api:
services:
- service: hdmi_cec_send
variables:
cec_destination: int
cec_data: int[]
then:
- hdmi_cec.send:
destination: !lambda "return static_cast<unsigned char>(cec_destination);"
data: !lambda |-
std::vector<unsigned char> vec;
for (int i : cec_data) vec.push_back(static_cast<unsigned char>(i));
return vec;
When any HDMI-CEC message is received, send a Home Assistant event called "esphome.hdmi_cec". The event contains both the raw hexadecimal frame and a human-readable translation. These events are ideal for automations because they can be directly used as triggers in Home Assistant. They are also useful for debugging (visible under Developer Tools → Events) and do not consume space in Home Assistant’s database.
hdmi_cec::hdmi_cec:
...
on_message:
- then:
- homeassistant.event:
event: esphome.hdmi_cec # Home Assistant event type (visible in Developer Tools → Events)
data:
source: !lambda 'return source;' # Logical address of the device that sent the message
destination: !lambda 'return destination;' # Logical address of the target device
opcode: !lambda 'return data.size() ? data[0] : 0;' # First byte of data = command opcode
raw: !lambda 'return hdmi_cec::Frame(source, destination, data).to_string(true);' # Full frame in hex (e.g. "40:36")
translated: !lambda 'return hdmi_cec::Frame(source, destination, data).to_string();' # Human-readable form (e.g. "TV → Broadcast: Standby")
If you prefer to monitor messages directly in a dashboard or entity list, you can also expose decoded CEC messages as text sensors. This may make debugging easier because you can see the latest messages without switching to Developer Tools. However, keep in mind that text sensors persist their state in Home Assistant’s database and may slightly increase database size over time.
hdmi_cec::hdmi_cec:
...
on_message:
# CEC message decoder (human-readable translation)
- then:
- lambda: |-
std::string translated = hdmi_cec::Frame(source, destination, data).to_string();
id(cec_translated_message).publish_state(translated);
text_sensor: blocks (required):text_sensor:
- platform: template
name: "HDMI CEC Raw Message"
id: cec_raw_message
update_interval: never
- platform: template
name: "HDMI CEC Translated Message"
id: cec_translated_message
update_interval: never
Consider excluding these sensors from your Home Assistant database to save space. If MQTT is enabled, the text sensor values (raw and translated) will also be sent via MQTT
Under mqtt: and hdmi_cec::
mqtt:
broker: '192.168.1.100' # insert IP or DNS of your own MQTT broker (e.g. the IP of your HA server)
username: !secret mqtt_user # make sure your MQTT username is added to the secrets file in the ESPHome Add-on
password: !secret mqtt_password # make sure your MQTT password is added to the secrets file in the ESPHome Add-on
discovery: false # if you only want your own MQTT topics
hdmi_cec:
...
promiscuous_mode: true
on_message:
- then:
mqtt.publish:
topic: cec_messages
#Payload in CEC-O-Matic format
payload: !lambda |-
return hdmi_cec::Frame(source, destination, data).to_string(true);
Here’s a full YAML snippet that includes all optional features together (just delete what you don't need):
```yaml esphome: name: hdmi-cec-bridge friendly_name: HDMI CEC Bridge
esp32: board: esp32-c3-devkitm-1 framework: type: esp-idf
logger:
api: encryption: key: "..."
services: - service: hdmi_cec_send variables: cec_destination: int cec_data: int[] then: - hdmi_cec.send: destination: !lambda "return static_cast(cec_destination);" data: !lambda "std::vector charVector; for (int i : cec_data) { charVector.push_back(static_cast(i)); } return charVector;"
ota: - platform: esphome password: "..."
wifi: ssid: !secret wifi_ssid password: !secret wifi_password
# Enable fallback hotspot (captive portal) in case wifi connection fails ap: ssid: "HDMI CEC Fallback Hotspot" password: "..."
mqtt: broker: '192.168.1.100' # insert IP or DNS of your own MQTT broker (e.g. the IP of your HA server) username: !secret mqtt_user # make sure your MQTT username is added to the secrets file in the ESPHome Add-on password: !secret mqtt_password # make sure your MQTT password is added to the secrets file in the ESPHome Add-on discovery: false # if you only want your own MQTT topics
captive_portal:
external_components: - source: github://Palakis/esphome-hdmi-cec
hdmi_cec: # Pick a GPIO pin that can do both input AND output pin: GPIO10 # Required
# The address can be anything you want. Use 0xF if you only want to listen to the bus and not act like a standard device address: 0xE # Required
# Physical address of the device. In this case: 4.2.0.0 (The ESP32 is plugged into HDMI 2 on the receiver which is plugged into HDMI4 on the TV) # DDC support is not yet implemented, so you'll have to set this manually. physical_address: 0x4200 # Required
# The name that will be displayed in the list of devices on your TV/receiver osd_name: "HDMI Bridge" # Optional. Defaults to "esphome"
# By default, promiscuous mode is disabled, so the component only handles directly addressed messages (matching # the address configured above) and broadcast messages. Enabling promiscuous mode will make the component # listen for all messages (both in logs and the on_message triggers) promiscuous_mode: true # Optional. Defaults to false
# By default, monitor mode is disabled, so the component can send messages and acknowledge incoming messages. # Enabling monitor mode lets the component act as a passive listener, disabling active manipulation of the CEC bus. monitor_mode: false # Optional. Defaults to false
on_message:
# Send CEC messages as Home Assistant events - then: - homeassistant.event: event: esphome.hdmi_cec # Home Assistant event type (visible in Developer Tools → Events) data: source: !lambda 'return source;' # Logical address of the device that sent the message destination: !lambda 'return destination;' # Logical address of the target device opcode: !lambda 'return data.size() ? data[0] : 0;' # First byte of data = command opcode raw: !lambda 'return hdmi_cec::Frame(source, destination, data).to_string(true);' # Full frame in hex (e.g. "40:36") translated: !lambda 'return hdmi_cec::Frame(source, destination, data).to_string();' # Human-readable form (e.g. "TV → Broadcast: Standby")
# Send CEC messages via MQTT in CEC-O-Matic format - then: - mqtt.publish: topic: cec_messages payload: !lambda |- return hdmi_cec::Frame(source, destination, data).to_string(true);
# Publish decoded CEC messages as text sensors (raw and translated) - then: - lambda: |- hdmi_cec::Frame frame = hdmi_cec::Frame(source, destination, data); id(cec_raw_message).publish_state(frame.to_string(true)); id(cec_translated_message).publish_state(frame.to_string());
text_sensor: #Consider excluding these sensors from your Home Assistant database to save space. - platform: template name: "HDMI CEC Raw Message" id: cec_raw_message #Do not delete if used with CEC message decoder update_interval: never
button: - platform: template name: "Turn all HDMI devices off" on_press: hdmi_cec.send: destination: 0xF # Broadcast data:
$ claude mcp add esphome-native-hdmi-cec \
-- python -m otcore.mcp_server <graph>