# HDMI-CEC bus sniffer
#
# Passive capture device. Listens to everything on the CEC bus and logs it. It
# never transmits, never ACKs, and never touches the Onkyo RI side. Home
# Assistant is not involved: the log IS the capture.
#
#   esphome run sniffer.yaml                          # first flash, over USB
#   esphome logs sniffer.yaml 2>&1 | tee -a cap.log   # then over the network
#
# Capture with `2>&1` and `tee -a`: esphome writes its own connect/disconnect
# notices to stderr, and plain `tee` truncates. The [HH:MM:SS.mmm] prefix
# carries no date, so pipe through `ts` for runs spanning more than a day.
#
# Wiring: HDMI pin 13 -> GPIO16, HDMI pin 17 -> GND. No level shifter (CEC is
# 3.3V), and no 5V from HDMI pin 18 when the board is USB-powered. Stub it into
# a SPARE HDMI port on the TV: CEC pin 13 is a shared bus across the ports, so
# a spare input sees the same traffic without going inline with anything.
#
# ADD NOTHING THAT RUNS PER-FRAME. The first version carried a logging lambda,
# a text_sensor publish and a homeassistant.event on every frame, and the device
# died mid-capture. All of it ran inside the component's loop(), which does not
# yield between queued frames (upstream #52). None of it was necessary: the
# component already logs every frame itself at hdmi_cec.cpp:109, and pings at
# :104. With decode_messages on, that line is the capture.

substitutions:
  device_name: hdmi-cec-sniffer
  cec_pin: GPIO16
  # The HDMI port this is plugged into, as a CEC physical address: port 1 =
  # 0x1000, port 2 = 0x2000, and so on. Unused in monitor mode, since the
  # device never announces itself, but the component requires a value.
  cec_physical_address: "0x4000"

esphome:
  name: ${device_name}
  friendly_name: "HDMI-CEC Sniffer"
  comment: "Passive CEC bus capture."
  project:
    name: "svigneau.hdmi-cec-sniffer"
    version: "2.2.0"

esp32:
  board: esp32dev
  # Give the task watchdog room: the CEC component drains its whole frame queue
  # without yielding (upstream #52), which is expected behaviour here rather
  # than a bug that can be fixed locally.
  # ref: https://github.com/Palakis/esphome-native-hdmi-cec/issues/52
  watchdog_timeout: 30s
  framework:
    type: esp-idf
    advanced:
      # +40KB IRAM for free on this bootloader. Worth taking on a device that
      # died once. ESP32 only — this key must not appear in an S3 config.
      sram1_as_iram: true
      # minimum_chip_revision is deliberately NOT set. It would shrink the
      # binary, but the build would then silently fail to boot on an older
      # WROOM, and spare modules are the fallback plan.

logger:
  # VERBOSE is the compile-time ceiling, needed so the ping log line exists at
  # all. Everything except the CEC component is filtered back down at runtime,
  # because logging VERBOSE for every component is a large amount of traffic to
  # push over WiFi continuously. The `logs:` block also compiles in the runtime
  # tag map, so `logger.set_level` can quiet pings without a reflash.
  level: VERBOSE
  logs:
    hdmi_cec: VERBOSE   # frames at DEBUG, pings at VERBOSE — the capture
    api: INFO
    api.service: INFO
    wifi: INFO
    mdns: INFO
    ota: INFO
    esp32.preferences: INFO

api:
  encryption:
    key: !secret api_encryption_key
  # Default 15min reboots the device whenever no client is connected, which
  # would punch holes in an overnight capture every time `esphome logs` dropped.
  reboot_timeout: 0s

ota:
  - platform: esphome
    password: !secret ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  # Default LIGHT power save adds latency and drops to the AP under load. This
  # device is USB-powered and needs to stay reachable for a day.
  power_save_mode: none

# web_server: intentionally not included. It holds connections and costs RAM,
# and it was a suspect in the first failure.

external_components:
  - source: github://Palakis/esphome-native-hdmi-cec
    # TODO: pin to a commit rather than tracking main, and switch to a fork
    # carrying the #50 / #52 fixes.

# The only telemetry, and the only reason `debug:` is here. This config runs at
# VERBOSE with the TV's 560 pings a minute, which is the exact burst load that
# makes upstream #52 real — so loop time is a live canary here in a way it is
# not on the bridge. It is printed in the heartbeat below rather than left as an
# entity, because the capture file is the only thing being read during a run.
debug:
  update_interval: 60s

sensor:
  - platform: debug
    loop_time:
      name: "Loop Time Max"
      id: loop_time_max
      entity_category: diagnostic

hdmi_cec:
  pin: ${cec_pin}
  address: 0xF                    # unregistered — pure observer
  physical_address: ${cec_physical_address}
  promiscuous_mode: true          # see directed traffic, not just broadcasts
  monitor_mode: true              # never ACK, never transmit, never disturb
  decode_messages: true           # human-readable decode in the log
  # No on_message. See the note in the header.

# Liveness heartbeat, and the reason the first long run was ambiguous: it had a
# 6h44m stretch with no output, which turned out to be the TV in standby. That
# is a real finding, but nothing in the file could distinguish it from "the
# capture died at 13:07". If these lines stop, the capture stopped; if they
# continue through a silent stretch, the silence is data. Per-minute, not
# per-frame, so it does not violate the header note. A reboot shows as the
# uptime resetting. Uptime comes from millis(), which rolls over after ~49.7
# days.
interval:
  - interval: 60s
    then:
      - logger.log:
          level: INFO
          tag: "capture"
          format: "alive: uptime %.0f s, loop max %.0f ms"
          args:
            - "millis() / 1000.0f"
            - "id(loop_time_max).state"
