# 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"
