My 2008 Sony BRAVIA KDL-40V4220 has no Ethernet and no Wi-Fi: no braviatv
integration, no IP control. CEC gets part of the way, but it addresses devices by their
physical address on the HDMI tree, and my Wii sits on Component, which has no
address. Selecting it needs infrared.
The published tables for this TV are wrong in an interesting way. Below: the codes that actually work, and the method — a USB webcam pointed at the TV, so Claude Code could run the try-a-code-and-look loop without me in it.
The codes
SIRC, 12 bit, device 1. data is what you hand to
remote_transmitter.transmit_sony with nbits: 12; dec is the same value for a
template number entity. OBC numbers come from irdb; everything
model-specific below was measured on my own set.
| TV shows | irdb function name | OBC | data |
dec |
|---|---|---|---|---|
| Power ON (discrete) | POWER ON | 46 | 0x750 |
1872 |
| Power OFF (discrete) | POWER OFF | 47 | 0xF50 |
3920 |
| Power toggle | POWER ON/OFF | 21 | 0xA90 |
2704 |
| TV (tuner) | INPUT TV TUNER | 36 | 0x250 |
592 |
| AV1 (SCART 1) | INPUT VIDEO 1 | 64 | 0x030 |
48 |
| AV2 (SCART 2) | INPUT VIDEO 2 | 65 | 0x830 |
2096 |
| AV3 (composite) | INPUT VIDEO 3 | 66 | 0x430 |
1072 |
| PC (VGA) | INPUT RGB 1 | 67 | 0xC30 |
3120 |
| PC (VGA) — same input | INPUT RGB 2 | 68 | 0x230 |
560 |
| HDMI 1 | INPUT VIDEO 4 | 71 | 0xE30 |
3632 |
| HDMI 2 | INPUT VIDEO 5 | 72 | 0x130 |
304 |
| HDMI 3 | INPUT VIDEO 6 | 73 | 0x930 |
2352 |
| no effect | INPUT VIDEO 7–12 | 74–79 | — | — |
| Input list (“Ext. inputs”) | INPUT SCROLL | 37 | 0xA50 |
2640 |
| Cursor up | CURSOR UP | 116 | 0x2F0 |
752 |
| Cursor down | CURSOR DOWN | 117 | 0xAF0 |
2800 |
| Enter | CURSOR ENTER | 101 | 0xA70 |
2672 |
| Info (shows current input) | INFO | 58 | 0x5D0 |
1488 |
| Volume up / down / mute | 18/19/20 | 0x490 / 0xC90 / 0x290 |
1168 / 3216 / 656 |
Discrete power on and off both exist, so a script can’t drift out of sync.
“Video 4” is not Component
Tables from this era list generic slots — INPUT VIDEO 1…12, INPUT RGB 1/2 — and
several sources map VIDEO 4/5 to the Component inputs and VIDEO 6/7/8 to HDMI. That
mapping is from the US KDL-40V4100. On this European V4220 it is shifted:
VIDEO 4/5/6 are HDMI 1/2/3, and VIDEO 7–12 do nothing. Two SCART sockets the US
sets don’t have is the likely reason. Slot numbers are per model; try them on yours.
Component has no discrete code
The TV’s input list (INPUT SCROLL) reads:
TV
AV1 VIDEOBANDSP. <- my label for the VHS on SCART
AV3 WII
HDMI 1 CHROM
HDMI 2 SWITC
HDMI 3 PC
Component WIINY <- where the Wii actually lives
Labels are user-set, so yours differ. AV2 is missing from the list even though 0x830
still selects it — the TV hides unused inputs.
No OBC in the device-1 table selects Component; I swept every candidate. What works is the TV’s own menu, with a first step that anchors the sequence:
0x250 TV tuner # known start: the list opens on the CURRENT input
0xA50 INPUT SCROLL # opens the input list, "TV" highlighted
0x2F0 CURSOR UP # wraps to the LAST entry = Component
0xA70 ENTER
Six CURSOR DOWN presses also work, but up-wrap survives the day a hidden input (AV2)
reappears and shifts everything below it.
Hardware and ESPHome config
A Seeed XIAO Smart IR Mate (~$11): XIAO ESP32-C3, three emitters and a
receiver, USB-C, pre-flashed with ESPHome, and the only device in ESPHome’s curated
infrared-proxies repo. Range is the catch — ~2.5 m upright, 4 m tilted —
so it sits on the media cabinet. Power it from a mains charger that is always on, not
the TV’s USB port: that port dies with the TV, and then the blaster can’t turn it back
on.
Pins per the official YAML: TX GPIO3, RX GPIO4 (inverted), board
seeed_xiao_esp32c3, esp-idf.
remote_transmitter:
id: ir_tx
pin: GPIO3
carrier_duty_percent: 50%
non_blocking: true
remote_receiver:
- id: ir_rx
pin:
number: GPIO4
inverted: true
dump: all
tolerance: 25%
idle: 10ms
script:
- id: sirc
mode: restart
parameters:
code: int
then:
- remote_transmitter.transmit_sony:
data: !lambda 'return code;'
nbits: 12
repeat:
times: 5
wait_time: 25ms
# Component has no discrete code; walk the TV's own input list instead.
- id: tv_component
mode: restart
then:
- script.execute: {id: sirc, code: 0x250} # TV tuner = known start
- delay: 1500ms
- script.execute: {id: sirc, code: 0xA50} # open input list
- delay: 1500ms
- script.execute: {id: sirc, code: 0x2F0} # up = wrap to last entry
- delay: 900ms
- script.execute: {id: sirc, code: 0xA70} # enter
button:
- platform: template
name: "TV Wii (Component)"
on_press: {script.execute: {id: tv_component}}
# code hunting: type a value into a template number, then press this
- platform: template
name: "Send SIRC (debug)"
on_press:
- script.execute:
id: sirc
code: !lambda 'return (int) id(sirc_dbg).state;'
SIRC wants 3+ repeats at a 45 ms frame period; wait_time: 25ms works, and field
reports of 30 ms working and 35 ms failing match what I saw. Every button appears in
Home Assistant as a button entity once the device is adopted — there is no Sony
integration on top of HA’s infrared platform, so brand codes go from the ESPHome side.
Turning an OBC number into the value ESPHome wants
irdb gives OBC numbers, not the 12-bit word. SIRC sends 7 command bits LSB-first
then 5 address bits LSB-first, while ESPHome’s encoder clocks data out
MSB-first over nbits:
def sirc12(obc, address=1):
d = 0
for i in range(7): # command, LSB first on the wire
if (obc >> i) & 1:
d |= 1 << (11 - i)
for j in range(5): # address, LSB first on the wire
if (address >> j) & 1:
d |= 1 << (4 - j)
return d
Sanity check: sirc12(46) must be 0x750.
Aspect ratio is a different protocol
The WIDE button is not 12-bit device 1. It is SIRC 15 bit, device 164, a separate code set — sweep the 12-bit table as hard as you like and it isn’t in there.
Let the remote teach you the code. The IR Mate has a receiver, so with dump: all
you point the TV’s remote at the blaster, press the button you’re missing, and read the
ESPHome log:
[remote.sony] Received Sony: data=0x00005E25, nbits=15
Volume and mute in the same capture came back as 12-bit 0x490 / 0xC90 / 0x290,
confirming the TV runs both code sets side by side. Same decoding as before, different
field widths — 7 command bits, then 8 address bits:
def sirc15(obc, address=164):
d = 0
for i in range(7):
if (obc >> i) & 1:
d |= 1 << (14 - i)
for j in range(8):
if (address >> j) & 1:
d |= 1 << (7 - j)
return d
sirc15(61) must be 0x5E25.
| Function | irdb name | OBC | data |
dec | On the V4220 |
|---|---|---|---|---|---|
| Wide mode (toggles) | WIDE MODE / ASPECT | 61 | 0x5E25 |
24101 | works |
| Wide Zoom | SCREEN: WIDE ZOOM | 66 | 0x2125 |
8485 | no effect |
| Full | SCREEN: NORMAL/FULL | 59 | 0x6E25 |
28197 | no effect |
| Normal (4:3) | SCREEN: NORMAL | 49 | 0x4625 |
17957 | no effect |
| Zoom | SCREEN: ZOOM/SUBTTL | 50 | 0x2625 |
9765 | no effect |
| Subtitle 16:9 | SCREEN: SUBTITLE16X9 | 69 | 0x5125 |
20773 | not tested |
The discrete modes would beat a cycling button for the same reason discrete power does,
but on this set they produce nothing at all — no on-screen box, no change. Same pattern
as INPUT VIDEO 7–12: irdb’s device-164 table is a union across models.
WIDE toggles, and needs a rapid double press
The setting has two values here, 4:3 and Wide. The first press opens the “Skärmformat” box showing the value already selected and changes nothing; a second press while the box is still up advances it. Presses more than a few seconds apart are just two reads — which is why single presses looked dead.
That gives a free state read: IR is one-way, but press WIDE once, read the box, and you know the current mode with the mode unchanged.
And the mode does not persist — selecting Component resets it to 4:3 every time, verified after switching inputs and after a power cycle. That is what makes a toggle safe to automate: the state right after selecting the input is known, so one double press lands on Wide deterministically. Verified from three different starting points.
script:
- id: sirc15
mode: restart
parameters:
code: int
then:
- remote_transmitter.transmit_sony:
data: !lambda 'return code;'
nbits: 15
repeat:
times: 5
wait_time: 25ms
- id: tv_wide_toggle
mode: restart
then:
- script.execute: {id: sirc15, code: 0x5E25}
- delay: 600ms
- script.execute: {id: sirc15, code: 0x5E25}
The input-selection script then ends with - delay: 2500ms and
- script.execute: {id: tv_wide_toggle}.
Part of this was not a TV problem: the Wii was still set to 480i, and
Wii Settings -> Screen -> TV Resolution -> EDTV/HDTV (480p) was the right first move.
It just wasn’t the whole fix. Check the console’s widescreen setting too
(Widescreen Settings -> 16:9) — if the source puts out 4:3, forcing the TV to Wide
only stretches the picture.
The method: let the model watch the TV
The slow part of IR work is the feedback loop: send a code, have a human look at the screen and report back. Fine for five codes, miserable for fifty. So I pointed a USB webcam at the TV and let Claude Code close it — send, capture, read the TV’s own banner, write down what the code did. The whole sweep took a couple of minutes and no button presses from me.
Drive the blaster over the ESPHome native API. aioesphomeapi
connects straight to the device with its encryption key — no Home Assistant in the way:
cli = APIClient("192.168.x.y", 6053, None, noise_psk=KEY)
await cli.connect(login=True)
entities, _ = await cli.list_entities_services()
cli.button_command(next(e.key for e in entities if e.name == "TV Info"))
TV Info (0x5D0) is the read-back primitive. The input banner disappears after a
few seconds, so a capture taken slightly late tells you nothing. Info re-displays the
current input without changing it.
Address the camera by name, not index. On macOS ffmpeg -f avfoundation -i "1" is
not stable — virtual cameras come and go, and index 1 silently became a different
camera mid-session. -i "Logitech StreamCam" doesn’t move.
A single 720p frame can’t read the OSD. The screen is a dark, low-contrast target with a bright window behind it. Capture at 1920x1080, average ~25 frames (the OSD is static, the noise isn’t), and stretch the crop’s own histogram — a global auto-contrast is useless when a blown-out window dominates.
The receiver is half of the loop. dump: all plus the real remote teaches you any
code a table doesn’t have, including the nbits you need to send it back.
Read the OSD, not the pixels. Screen brightness is a terrible state proxy: an input
showing a black picture measures exactly like a TV that is off, and a game’s own
vignette reads like letterboxing. The TV’s own text is ground truth. And don’t poll in a
tight loop — an until nc -z host 6053 wait hammered the ESP with a connect every
0.8 s and flooded its log.
Two ESPHome gotchas
esphome upload does not compile. It flashes whatever binary is already in the
build directory, so editing the YAML and running upload reboots the device into the
old firmware — same entity list, no error, and you start suspecting the OTA. Use
esphome run, or compile then upload.
The IR Mate’s factory firmware has an OTA password. The first wireless install fails
with Authentication invalid. Is the password correct?, which looks like it must be
flashed over USB. It doesn’t: Seeed publish the password in their own repo.
Don’t copy it into your YAML, or your firmware inherits it — call ESPHome’s OTA module
with it once, using the binary you already built, and from then on your own password
owns the device. The AP password 12345678 from the setup portal is not the OTA
password.