# HDMI-CEC and Onkyo RI Bridge
#
# This device bridges together the HDMI-CEC bus, Onkyo RI serial
# remote control (write-only), and Home Assistant via ESPHome.
#
# Configured for a Seeed Studio XIAO ESP32S3
# and Onkyo A-9050 receiver.
#
# ref: https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/
# https://github.com/docbender/Onkyo-RI#a-9050-receiver
#
# Wiring:
# - HDMI pin 13 -> D2/GPIO3 direct (CEC is 3.3V)
# - HDMI pin 17 -> GND
# - D0/GPIO1 -> BSS138 level shifter -> 3.5mm tip (RI is 5V)
# - 3.5mm sleeve to GND
substitutions:
device_name: hdmi-cec-onkyo-ri-bridge
cec_pin: GPIO3 # D2
ri_pin: GPIO1 # D0
cec_address: "0xF" # 0xF means it never ACKs and is not addressable.
# HDMI 3, must match where it is plugged in because DDC isn't implemented.
cec_physical_address: "0x3000"
esphome:
# Name and Friendly Name get suffixed with the NIC MAC address, resulting in:
# something like "hdmi-cec-onkyo-ri-bridge-0c542c" /
# "HDMI-CEC and Onkyo RI Bridge 0c542c"
name: ${device_name}
friendly_name: "HDMI-CEC and Onkyo RI Bridge"
name_add_mac_suffix: true
comment: "Configured for Seeed Studio XIAO ESP32S3 and Onkyo A-9050."
min_version: 2026.8.0
project:
name: "svigneau.hdmi-cec-onkyo-ri-bridge"
version: "2.1.0"
on_boot:
# Log this banner after everything else has started up.
- priority: -100
then:
- logger.log:
level: INFO
tag: "bridge"
format: "boot: v%s — CEC on %s (addr %s, physical %s), Onkyo RI on %s"
args:
- "ESPHOME_PROJECT_VERSION"
- '"${cec_pin}"'
- '"${cec_address}"'
- '"${cec_physical_address}"'
- '"${ri_pin}"'
esp32:
board: seeed_xiao_esp32s3
# Set a longer timeout to work around esphome-native-hdmi-cec issue #52.
# ref: https://github.com/Palakis/esphome-native-hdmi-cec/issues/52
watchdog_timeout: 30s
framework:
type: esp-idf
# Switch to DEBUG level if wanting to log HDMI-CEC details.
# Useful for adding new, special events to Home Assistant.
logger:
level: INFO
api:
encryption:
key: !secret api_encryption_key
# Do not automatically reboot when no client is connected. This keeps a
# Home Assistant reboot/outage from restarting the device.
reboot_timeout: 0s
actions:
# Any 12-bit RI code, for commands the buttons below do not cover.
- action: onkyo_ri_send_code
variables:
code: int
then:
- if:
condition:
lambda: "return code >= 0 && code <= 0xFFF;"
then:
- script.execute:
id: ri_send
code: !lambda "return code;"
label: "HA action"
else:
- logger.log:
level: ERROR
tag: "ri"
format: "REJECTED code 0x%03X — RI frames are 12-bit (0x000-0xFFF)"
args: ["(unsigned) code"]
on_client_connected:
- logger.log:
level: INFO
tag: "bridge"
format: "API client connected: %s"
args: ["client_info.c_str()"]
on_client_disconnected:
- logger.log:
level: WARN
tag: "bridge"
format: "API client disconnected: %s"
args: ["client_info.c_str()"]
ota:
- platform: esphome
password: !secret ota_password
on_begin:
- logger.log:
level: INFO
tag: "bridge"
format: "OTA started"
on_progress:
- logger.log:
level: INFO
tag: "bridge"
format: "OTA %.0f%%"
args: ["x"]
on_end:
- logger.log:
level: INFO
tag: "bridge"
format: "OTA finished, rebooting"
on_error:
- logger.log:
level: ERROR
tag: "bridge"
format: "OTA FAILED, error %u, old firmware still running"
args: ["(unsigned) x"]
safe_mode:
# If the device doesn't boot properly 10 times in a row, fall back to
# safe mode for recovery.
num_attempts: 10
reboot_timeout: 5min
on_safe_mode:
- logger.log:
level: ERROR
tag: "bridge"
format: "SAFE MODE: Last firmware failed to boot 10 times."
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
# Keep wireless from going to sleep; not needed for mains powered devices.
power_save_mode: none
# ap: and captive_portal: are intentionally not here. No fallback needed.
# web_server: intentionally not included; web UI not needed.
external_components:
# Include the external HDMI-CEC component since ESPHome doesn't support it
# natively.
- source: github://Palakis/esphome-native-hdmi-cec
# Include my PR adding Onkyo RI support. Remove if/when PR merges.
- source: github://pr#18595
components: [remote_base]
# ===========================================================================
# HDMI-CEC
# ===========================================================================
hdmi_cec:
pin: ${cec_pin}
address: ${cec_address}
physical_address: ${cec_physical_address}
# Needed to ensure we receive everything.
# Note: If we are set to 0xF for cec_address, we will receive all
# broadcasts, but need this to receive everything else.
promiscuous_mode: true
# Monitor mode keeps us from sending to the bus. Set to false if/when
# we want to experiment with waking things.
monitor_mode: true
# When in DEBUG, gives us more readable messages, like:
# [received] 4F:82:50:00 => PlaybackDev1 to All: <Active Source>[5.0.0.0]
decode_messages: true
# Caution: esphome.hdmi_cec_frame is an API: Home Assistant automations
# bind to these field names, so renaming them can break things.
on_message:
- then:
- homeassistant.event:
event: esphome.hdmi_cec_frame
data:
source: !lambda |-
char buf[3];
snprintf(buf, sizeof(buf), "%X", source);
return std::string(buf);
destination: !lambda |-
char buf[3];
snprintf(buf, sizeof(buf), "%X", destination);
return std::string(buf);
opcode: !lambda |-
char buf[3];
snprintf(buf, sizeof(buf), "%02X", data[0]);
return std::string(buf);
payload: !lambda |-
std::string out;
char buf[3];
for (size_t i = 1; i < data.size(); i++) {
if (i > 1) { out += ':'; }
snprintf(buf, sizeof(buf), "%02X", data[i]);
out += buf;
}
return out;
# ===========================================================================
# Onkyo RI Output
# ===========================================================================
remote_transmitter:
pin: ${ri_pin}
non_blocking: true
carrier_duty_percent: 100% # Unused, but required.
# Every RI command goes through here, so the log line and the code on the wire
# cannot drift apart. Queued, so two commands close together remain in order.
script:
- id: ri_send
mode: queued
max_runs: 10
parameters:
code: int
label: string
then:
- logger.log:
level: INFO
tag: "ri"
format: "TX %s -> 0x%03X"
args:
- "label.c_str()"
- "(unsigned) code"
- remote_transmitter.transmit_onkyori:
data: !lambda "return (uint16_t) code;"
button:
- platform: template
name: "Onkyo RI: Power On"
id: ri_power_on
icon: "mdi:power-on"
on_press:
- script.execute: { id: ri_send, code: 0xD9, label: "Power On" }
- platform: template
name: "Onkyo RI: Power Off"
id: ri_power_off
icon: "mdi:power-off"
on_press:
- script.execute: { id: ri_send, code: 0xDA, label: "Power Off" }
- platform: template
name: "Onkyo RI: Power Toggle"
id: ri_power_toggle
icon: "mdi:power"
on_press:
- script.execute: { id: ri_send, code: 0x4, label: "Power Toggle" }
- platform: template
name: "Onkyo RI: Volume Up"
id: ri_volume_up
icon: "mdi:volume-plus"
on_press:
- script.execute: { id: ri_send, code: 0x2, label: "Volume Up" }
- platform: template
name: "Onkyo RI: Volume Down"
id: ri_volume_down
icon: "mdi:volume-minus"
on_press:
- script.execute: { id: ri_send, code: 0x3, label: "Volume Down" }
- platform: template
name: "Onkyo RI: Mute"
id: ri_mute
icon: "mdi:volume-low"
on_press:
- script.execute: { id: ri_send, code: 0xD7, label: "Mute" }
- platform: template
name: "Onkyo RI: Unmute"
id: ri_unmute
icon: "mdi:volume-high"
on_press:
- script.execute: { id: ri_send, code: 0xD8, label: "Unmute" }
- platform: template
name: "Onkyo RI: Toggle Mute"
id: ri_mute_toggle
icon: "mdi:volume-mute"
on_press:
- script.execute: { id: ri_send, code: 0x5, label: "Toggle Mute" }
- platform: template
name: "Onkyo RI: Next Input"
id: ri_input_next
icon: "mdi:chevron-right-box"
on_press:
- script.execute: { id: ri_send, code: 0xD5, label: "Next Input" }
- platform: template
name: "Onkyo RI: Previous Input"
id: ri_input_prev
icon: "mdi:chevron-left-box"
on_press:
- script.execute: { id: ri_send, code: 0xD6, label: "Previous Input" }
- platform: template
name: "Onkyo RI: Input D1"
id: ri_input_d1
icon: "mdi:numeric-1-box-outline"
on_press:
- script.execute: { id: ri_send, code: 0x20, label: "Input D1" }
- platform: template
name: "Onkyo RI: Input D2"
id: ri_input_d2
icon: "mdi:numeric-2-box-outline"
on_press:
- script.execute: { id: ri_send, code: 0xE0, label: "Input D2" }
- platform: template
name: "Onkyo RI: Input D3"
id: ri_input_d3
icon: "mdi:numeric-3-box-outline"
on_press:
- script.execute: { id: ri_send, code: 0x170, label: "Input D3" }
# Log a heartbeat; goes to serial, and over the API only to a client that
# subscribes to logs, such as when using `esphome logs device.yaml` remotely.
# Note that uptime rolls over after ~49.7 days due to millis() limitations.
# This is useful when capturing logs to a file, such as for HDMI_CEC analysis.
# This does not reach Home Assistant so there's no log inflation concern.
interval:
- interval: 300s
then:
- logger.log:
level: INFO
tag: "capture"
format: "alive: uptime %.0f s"
args:
- "millis() / 1000.0f"