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