Waking up a Soundbar: Home Assistant + ESP32 + Bluetooth

I've fully embraced "the robots" - using LLMs to build and automate things that used to be really hard. I wrote this up (originally, I had the robots write it...but I hated it, so I rewrote it myself with a bit of help from Claude) simply to share the realm of the possible: here's a neat thing you can do. Maybe you've got a piece of tech that just doesn't cooperate with others; you can use this writeup for ideas on how to glue things together in exciting (or Rube-Goldbergian) ways!

Background

I've been doing home automation for a VERY long time (I still intend to write some articles about my ancient Perl script that automated the dozen X10 devices I had around my house...but that's for another day); more recently, I've switched from Raspberry Pis and custom Perl/Python/Node.js scripts to Home Assistant and ESP32s. Combine that with "the robots" (i.e. Claude Code, Codex, or whatever your tool of choice is), and I've managed to automate WAY more of my house than I could before...which is where we start here.

I recently bought a soundbar (a TCL S65K) for our TV downstairs because I wanted "easy" surround sound (instead of the complex setups I've had in the past that required a Philips Pronto remote with custom programming and timed triggers to turn on the TV with a discrete "on" code, then the amplifier, then set the right input channel). I thought a soundbar would be a simple, turnkey solution...of course it wasn't.

The Problem

It turns out that when you combine an older TV with a newer soundbar, things don't work as easily as they're supposed to (especially if the soundbar and the TV aren't the same brand). The TV is served by a Roku box, and I tried to connect the soundbar to the TV's HDMI ARC port - which worked, until I tried playing Dolby Digital Plus audio, which caused the HDMI link to fail. Even turning off Dolby Digital Plus was causing problems - likely because the TV's HDMI ARC port simply wasn't passing the audio correctly.

Eventually, I changed the direction of the wiring to pass the Roku HDMI through the soundbar:

Roku → soundbar → TV

That reliably passed audio and video to the TV (and it was even in sync...another problem I was facing in the previous configuration) but it left me with a new challenge: the soundbar wouldn't automatically turn on when I powered on the Roku device.

This is, clearly, a first-world problem. But anyone who does home automation and has a spouse or a 10-year-old understands the need to make these things as simple as possible...with one remote control. Now we had to have the soundbar remote around - just to power it on.

I'd hoped to be done gluing things together in my entertainment system...but here I was, looking to go find my glue again. (Alternatively, I could have bought a new TV, which likely would have solved this problem - but I had zero desire to do that since a) the TV worked perfectly fine and b) I'm cheap.) I needed a way to power the soundbar on (and I really didn't want to wire in yet-another-IR-transmitter to my home automation system). The soundbar would go to sleep on its own just fine - so I just needed a way to power it on.

The Clue

While I was troubleshooting my connection, I used the soundbar's phone app to tune the acoustics of the room. It dawned on me that the phone app could talk to the soundbar without my phone actually being paired to the soundbar AND that the app could power the soundbar on and off. Based on my limited knowledge of Bluetooth and other Scandinavian kings, I suspected it was communicating over Bluetooth Low Energy (BLE). So - what if I could send a BLE signal (from Home Assistant) whenever the Roku woke up? I had already wired in some lighting cues based on when the Roku woke up (using the Roku integration within Home Assistant), so I knew that I could reliably detect the state change and trigger an automation.

Of course, firing off a BLE signal would require knowledge of how to send a BLE signal. And knowing what it looked like. Or how it worked. None of this I knew...so I fired up Claude and asked if it was possible to capture and send a BLE signal for a soundbar with my Home Assistant setup (I have a home-automation Claude skill that I developed that vastly simplifies all tasks surrounding my Home Assistant setup, so it already knew my device configuration and the tools it had at its disposal).

The robots walked me through the following steps on my Mac and iPhone (I captured the steps here in case anyone wants to follow along for a similar setup).

Step 1: Record the Signal

  1. On the iPhone, install the "Bluetooth for iOS" logging profile from Apple's Profiles and Logs page (a free Apple ID works). Install it under Settings → General → VPN & Device Management and restart the phone.
  2. On the Mac, download "Additional Tools for Xcode" from Apple's developer downloads. You don't need Xcode itself, just PacketLogger from the Hardware folder.
  3. Plug the phone into the Mac, open PacketLogger, and choose File → New iOS Trace.
  4. Toggle Bluetooth off and on, then open the soundbar's app.
  5. Do the thing you want to automate several times and write down the time of each press. I turned the soundbar on with the app and off with the remote five times, then switched inputs a few times.
  6. Save the trace as text, and remove the logging profile from the phone when you're done (it's hard on the battery).

Step 2: Find the Commands

I ran the trace for about 5 minutes, and it created about 20,000 lines of logging covering every Bluetooth gadget near my phone. I handed Claude the file and my list of timestamps, and it did the digging. If you're doing this by hand (and honestly, why would you?), the process is: search for something that looks like your product's name to find the device, look only at that device's lines that start with "ATT" (Send is the app talking, Receive is the device answering), and line them up against your notes. Here's what showed up at every "on":

ATT Send     Write Command - Handle:0x0009 - E49A25E0-F69A-11E8-8EB2-F2801F1B9FD1 - Value: 5243 0101 000C 0006 0006 0000 0000
ATT Receive  Handle Value Notification - Handle:0x000B - E49A28E1-F69A-11E8-8EB2-F2801F1B9FD1 - Value: 5243 0201 008F 0006 0088 0001 0000

The first is the app saying "power"; the second is the soundbar answering "I'm on now". All five power commands were the same 14 bytes: no encryption, no pairing, no handshake. That was the best possible news, because it meant we could simply replay them. Claude also worked out the rest of the format from the input-switching commands (a command number, a value, and a checksum that's just the other bytes added up), and noticed something I hadn't asked about: the soundbar reports its own power state, even when you use the regular remote.

Step 3: Test It

Before involving any hardware, Claude wrote a short Python script to replay the command from my Mac:

#!/usr/bin/env python3
"""Send a command to the soundbar over BLE and print what it says back.

  python3 tcl_s65k.py power | status | listen | input arc|bt|hdmi
"""
import asyncio
import sys

from bleak import BleakClient, BleakScanner

NAME = "tcl_XXXXXXXX_XXXX"  # the name your soundbar advertises
WRITE_UUID = "e49a25e0-f69a-11e8-8eb2-f2801f1b9fd1"
NOTIFY_UUID = "e49a28e1-f69a-11e8-8eb2-f2801f1b9fd1"
SERVICE_UUID = "0000f500-0000-1000-8000-00805f9b34fb"

CMD_STATUS, CMD_POWER, CMD_INPUT = 0x01, 0x06, 0x0F
INPUTS = {"arc": 1, "bt": 5, "hdmi": 6}
NOTIFY_NAMES = {0x88: "power", 0x91: "input", 0x84: "0x84 (volume?)", 0x90: "0x90", 0x92: "0x92", 0x8F: "0x8f"}


def frame(cmd, value=0, ftype=0x01):
    body = bytes([0x00, 0x06, 0x00, cmd, 0x00, value, 0x00, 0x00])
    return bytes([0x52, 0x43, 0x01, ftype, 0x00, sum(body) & 0xFF]) + body


def on_notify(_, data):
    line = data.hex(" ")
    if len(data) >= 24 and data[:2] == b"RC" and data[3] == 0x05:
        line += f"   <- status: power = {data[14]}, input = {data[23]}"
    elif len(data) >= 12 and data[:2] == b"RC" and data[3] == 0x01:
        line += f"   <- {NOTIFY_NAMES.get(data[9], hex(data[9]))} = {data[11]}"
    print(line)


async def main():
    args = sys.argv[1:] or ["listen"]
    if args[0] == "power":
        out = frame(CMD_POWER)
    elif args[0] == "status":
        out = frame(CMD_STATUS, ftype=0x05)
    elif args[0] == "input":
        out = frame(CMD_INPUT, INPUTS[args[1]])
    else:
        out = None

    dev = await BleakScanner.find_device_by_name(NAME, timeout=20)
    if dev is None:
        sys.exit(f"{NAME} not found (is the phone app still connected?)")
    async with BleakClient(dev) as client:
        svc = client.services.get_service(SERVICE_UUID)
        await client.start_notify(svc.get_characteristic(NOTIFY_UUID), on_notify)
        if out:
            print("->", out.hex(" "))
            await client.write_gatt_char(svc.get_characteristic(WRITE_UUID), out, response=False)
        print("listening, ctrl-c to quit")
        await asyncio.sleep(3600)


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

Then, to run it:

python3 -m venv ~/soundbar-venv
source ~/soundbar-venv/bin/activate
pip install --upgrade pip      # the pip that ships with macOS is too old for bleak
pip install bleak
python tcl_s65k.py power

Force-quit the soundbar's phone app first, and say yes when your Mac asks whether the terminal can use Bluetooth. I ran it, and the soundbar turned...off.

-> 52 43 01 01 00 0c 00 06 00 06 00 00 00 00
52 43 02 01 00 8e 00 06 00 88 00 00 00 00   <- power = 0

It was already on, so the "power on" command is really a power toggle. Neither of us could have known that from the trace, since I'd only used the app to turn it on. A toggle is a great way to build an automation that helpfully turns the sound off right as you sit down. But because the soundbar reports its state, the fix was simple: only send the toggle when the soundbar says it's off.

Step 4: Ship It...to a Microcontroller

I already had an ESP32 within Bluetooth range, working as a motion sensor in what our family calls the potato closet (yet another long story). Claude added the soundbar to its ESPHome config, compiled it to make sure it built, and I clicked install:

esp32_ble_tracker:

ble_client:
  - mac_address: AA:BB:CC:DD:EE:FF   # your soundbar's address, from the trace
    id: soundbar
    on_connect:
      then:
        # ask for a status report so we know right away if it's on or off
        - delay: 2s
        - ble_client.ble_write:
            id: soundbar
            service_uuid: F500
            characteristic_uuid: E49A25E0-F69A-11E8-8EB2-F2801F1B9FD1
            value: [0x52, 0x43, 0x01, 0x05, 0x00, 0x07, 0x00, 0x06, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]

binary_sensor:
  - platform: template
    name: Soundbar Power State
    id: soundbar_power
    device_class: power

sensor:
  # Everything the soundbar tells us shows up here
  - platform: ble_client
    type: characteristic
    ble_client_id: soundbar
    id: soundbar_notify
    internal: true
    service_uuid: F500
    characteristic_uuid: E49A28E1-F69A-11E8-8EB2-F2801F1B9FD1
    notify: true
    update_interval: never
    lambda: |-
      if (x.size() < 12 || x[0] != 'R' || x[1] != 'C') return NAN;
      if (x[3] == 0x05 && x.size() >= 24) {
        // the long status report: byte 14 is power
        id(soundbar_power).publish_state(x[14] == 1);
      } else if (x[3] == 0x01 && x[9] == 0x88) {
        // "my power state just changed"
        id(soundbar_power).publish_state(x[11] == 1);
      }
      return x[9];

script:
  - id: send_power_toggle
    then:
      - ble_client.ble_write:
          id: soundbar
          service_uuid: F500
          characteristic_uuid: E49A25E0-F69A-11E8-8EB2-F2801F1B9FD1
          value: [0x52, 0x43, 0x01, 0x01, 0x00, 0x0C, 0x00, 0x06, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00]

switch:
  # The power command is a toggle, so only send it when the soundbar's
  # reported state is different from what was asked for
  - platform: template
    name: Soundbar Power
    lambda: return id(soundbar_power).state;
    turn_on_action:
      - if:
          condition:
            binary_sensor.is_off: soundbar_power
          then:
            - script.execute: send_power_toggle
    turn_off_action:
      - if:
          condition:
            binary_sensor.is_on: soundbar_power
          then:
            - script.execute: send_power_toggle

  # The soundbar only allows one Bluetooth connection at a time. Flip this
  # off when you want to use the phone app.
  - platform: ble_client
    ble_client_id: soundbar
    name: Soundbar BLE Link
    restore_mode: ALWAYS_ON

In plain English: the ESP32 stays connected to the soundbar, listens to everything it says so it always knows whether it's on (even if you use the regular remote), and gives Home Assistant a power switch that can only do the right thing. One gotcha: the soundbar accepts one Bluetooth connection at a time, so the "BLE Link" switch is there to let go of it when I want to use the phone app.

Step 5: Putting it all together

After all of that, the automation itself is almost embarrassingly small:

- alias: Basement soundbar on when Roku wakes
  triggers:
  - trigger: state
    entity_id: media_player.basement_roku
    from: 'off'
    not_to:
    - unavailable
    - unknown
  actions:
  - action: switch.turn_on
    target:
      entity_id: switch.soundbar_power
  mode: single

When the Roku wakes up, turn on the soundbar. Home Assistant only checks on the Roku every ten seconds or so, so the soundbar joins a few seconds after the TV...and it worked perfectly the first time: one remote, the TV wakes up, the soundbar wakes up, watching of things commences. Nobody watching needs to know there's a microcontroller in the potato closet whispering to the soundbar (I have to admit: Claude wrote that last sentence and I feel like it's the most Brian thing it's ever written).

Takeaways

Reverse engineering a BLE app from my phone was not on my "home automation bingo card" before. I'm sure that, given enough time, I could have figured it out (like I figured out the convoluted IR setup I have with lirc and my home-rolled HDMI-CEC controller on my other TV), but I have kids...time is something I have much less of than I used to.

The cool thing to me was that with just a little bit of LLM help, we figured it out (well, Claude figured it out mostly) and got it wired into my existing Home Assistant setup. Once you've got the building blocks in place (Home Assistant, an ESP32, a subscription to Claude Code), it really took me about 30 minutes of my own time to get this rolling (ignoring all the time I spent fighting with HDMI connections - which I suppose is better than the olden days of fighting with RCA and SPDIF connections). If I had been using the API for Claude, this would have been about $10 worth of tokens.