Sony KDL-40V4220 IR codes, and how I found them with a camera in the loop

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.

This work by Fredrik Wendt is licensed under CC by-sa.