Make your ESPHome devices speak the (machine) language of your living room with this native HDMI-CEC (Consumer Electronics Control) component!
- Native CEC 1.3a implementation
- Implemented from scratch specifically for this component. No third-party CEC library used.
- Meant to be as simple, lightweight and easy-to-understand as possible
- Interrupts-based receiver (no polling at all). Handles low-level byte acknowledgements
- Receive CEC commands
- Handle incoming messages with
on_messagetriggers- Each trigger specified in
on_messagesupports filtering based on source, destination, opcode and/or message contents
- Each trigger specified in
- Built-in handlers for some of the system commands defined in the spec :
- "Get CEC Version"
- "Give Device Power Status"
- "Give OSD Name"
- Handle incoming messages with
- Send CEC commands
- Built-in
hdmi_cec.sendaction
- Built-in
- Automatic Physical Address Discovery through E-DDC
Connect 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 |
The HDMI breakout board shown is this model on AliExpress.
CEC uses 3.3V logic – safe for ESP32/ESP8266 (or any other microcontroller with 3.3V logic).
- Start by creating your device using ESPHome Device Builder (e.g., via Home Assistant’s ESPHome Add-on or ESPHome Web).
- Once your device is created, click "Edit" to access the YAML configuration.
- (If using an ESP32-C3, it’s recommended to use type: esp-idf)
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-cecAdd 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 we 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:
- "source": match messages coming from the specified address
- "destination": match messages meant for the specified address
- "opcode": match messages bearing the specified opcode
- "data": exact-match on message content
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 EHPHome 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:
...
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:
...
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:
- 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: neverConsider 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
5. Publish CEC Messages over MQTT (CEC-O-MATIC format)
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:
esphome:
name: hdmi-cec-bridge
friendly_name: HDMI CEC Bridge
esp32:
board: esp32-c3-devkitm-1
framework:
type: esp-idf
# Enable logging
logger:
# Enable Home Assistant API
api:
encryption:
key: "..."
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> charVector; for (int i : cec_data) { charVector.push_back(static_cast<unsigned char>(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 we 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-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: 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
- platform: template
name: "HDMI CEC Translated Message"
id: cec_translated_message #Do not delete if used with CEC message decoder
update_interval: never
# Example button configuration for common HDMI-CEC commands
# ----------------------------------------------------------
# The examples below use Apple TV (playback device 1) and PlayStation 4 (playback device 2)
# as references for typical CEC playback devices.
# Other devices may use different command opcodes —
# refer to your device’s CEC documentation if the examples do not work as expected.
button:
- platform: template
name: "Turn all HDMI devices off"
on_press:
hdmi_cec.send:
destination: 0xF # Broadcast
data: [0x36] # "Standby" opcode
- platform: template
name: "Turn TV on"
on_press:
hdmi_cec.send:
source: 1
destination: 0
data: [0x04] # Works with Samsung TVs. For LG, try [0x0D]; for Sony, [0x44, 0x6D].
- platform: template
name: "Turn TV off"
on_press:
hdmi_cec.send:
source: 1
destination: 0
data: [0x36]
- platform: template
name: "Volume up"
on_press:
hdmi_cec.send:
destination: 0x5 # Usually the Audio System
data: [0x44, 0x41]
- platform: template
name: "Volume down"
on_press:
hdmi_cec.send:
destination: 0x5
data: [0x44, 0x42]
- platform: template
name: "Mute"
on_press:
hdmi_cec.send:
destination: 0x5
data: [0x44, 0x43]
# --- Playback Device 1 (Apple TV example) ---
- platform: template
name: "Turn on Playback device 1"
on_press:
hdmi_cec.send:
destination: 4 # Typical address for first playback device (Apple TV)
data: [0x44, 0x6D] # "Power On Function" / "Play"
- platform: template
name: "Turn off Playback device 1"
on_press:
hdmi_cec.send:
destination: 4
data: [0x36] # "Standby"
- platform: template
name: "Playback 1 Home"
on_press:
hdmi_cec.send:
destination: 4
data: [0x44, 0x09] # "Root Menu"
- platform: template
name: "Playback 1 Select/OK"
on_press:
hdmi_cec.send:
destination: 4
data: [0x44, 0x00] # "Select"
- platform: template
name: "Playback 1 Back/Exit"
on_press:
hdmi_cec.send:
destination: 4
data: [0x44, 0x0D] # "Exit"
- platform: template
name: "Playback 1 Play/Pause"
on_press:
hdmi_cec.send:
destination: 4
data: [0x44, 0x44] # "Play" / "Pause Toggle"
# --- Playback Device 2 (PlayStation 4 example) ---
- platform: template
name: "Turn on Playback device 2"
on_press:
hdmi_cec.send:
destination: 8 # Typical address for second playback device (PS4)
data: [0x44, 0x6D] # May vary depending on firmware
- platform: template
name: "Turn off Playback device 2"
on_press:
hdmi_cec.send:
destination: 8
data: [0x36]
- platform: template
name: "Playback 2 Play/Pause"
on_press:
hdmi_cec.send:
destination: 8
data: [0x44, 0x46]
If you’re using an ESP32-C3 SuperMini, you can 3D-print a dedicated case designed by DIYtechie on MakerWorld.
The case is optimized for the ESP32-C3 SuperMini form factor and designed to fit all the required components (including thee HDMI sockets) referenced) in the smallest possible footprint. Follow MakerWorld link for detailed description and bill of materials.
| Platform | Supported | Notes |
|---|---|---|
| ESP32 | ✅ | Fully supported (use type: esp-idf for ESP32-C3 |
| ESP8266 | ✅ | Tested and works |
| RP2040 | ✅ | Tested and works |
| LibreTiny | ❌ | Not supported |

