Skip to main content

Waking a Harman Kardon Aura from the Dead -- Bluetooth, PipeWire, and Too Many Rabbit Holes

Man… what a journey. It’s Sunday, March 1st, and I can see the sunrise from my window. This wasn’t some quick one-night hack; I’ve actually been wrestling with this project for months. But this weekend, I hit a point of no return. I was determined to finally cross the finish line. I previously swore to myself that I would stop these late-night projects, but here I am, crazy happy because I finally made it…

In the previous post, I reverse engineered the Harman Kardon Aura Plus’s control protocol and built a Home Assistant integration around it. Volume, bass, EQ, mute, power-off. All working over raw TCP on port 10025.

There was just one problem. A fatal one, actually.

I could turn the speaker off. But I couldn’t turn it back on.


The One-Way Door

When you send power-off via the XML protocol, the speaker enters standby mode. Per the owner’s manual, standby keeps the Bluetooth radio alive but shuts down the WiFi interface entirely. Port 10025 stops responding. Ping times out. The speaker is, for all network purposes, dead.

I confirmed this systematically. Every power-related command I could think of:

CommandResult
power-offWorks. Speaker enters standby. WiFi goes dark, BT stays alive.
power-onConnection timeout. WiFi is offline.
sleepTimeout. Speaker was already off.
standbyTimeout. Same story.
power-toggleTimeout. Sense a pattern?

The speaker does something interesting before entering standby, though. It sends back a status response before going silent:

<harman>
  <mm>
    <common>
      <status>
        <name>power</name>
        <para>on</para>
      </status>
    </common>
  </mm>
</harman>

“I’m on!” it says, moments before going dark. Classic.

So I had a full-featured Home Assistant integration that could do everything except the most basic thing: wake the speaker up. If I powered it off from HA, I had to physically walk over and press the button. Which kind of defeats the entire purpose of home automation.


The Clue

Here’s what I noticed from real-world use: connecting to the speaker via Bluetooth from my iPhone and playing music wakes it up. After a few seconds of audio, the WiFi interface comes back online, port 10025 opens, and HA can take over.

This actually makes perfect sense once you know the speaker is in standby, not fully off. The Bluetooth radio is still listening for connections. The manual even documents this behavior. The WiFi and audio DSP are powered down, but BT stays awake waiting for a source.

That was the lead. If Bluetooth audio can wake the speaker, I just need to automate that.


macOS – The Quick Win

On my MacBook, this turned out to be surprisingly straightforward. Three tools from Homebrew:

  • blueutil: command-line Bluetooth control (connect/disconnect by MAC)
  • SwitchAudioSource: programmatically change the system audio output
  • mpv: stream audio to the speaker
#!/bin/bash
BT_MAC="B8:69:C2:15:29:FD"
BT_NAME="HK Aura BT"
INTRO_SONG_URL="https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"

blueutil --connect "$BT_MAC"
sleep 3
SwitchAudioSource -s "$BT_NAME"
mpv --no-video --quiet --length=2 "$INTRO_SONG_URL"

Connect Bluetooth, switch audio output, play two seconds of music. Done. The speaker wakes up every time.

The SwitchAudioSource step matters. Without it, mpv plays through the Mac’s built-in speakers instead of the Bluetooth device. macOS doesn’t automatically route audio to a newly connected Bluetooth device. Because of course it doesn’t.

But I don’t want to SSH into my MacBook every time I want to wake a speaker. I need this running on my Raspberry Pi, the same machine hosting my Home Assistant cluster.


Linux – Where Things Got Interesting

I recently made the switch from Home Assistant OS (HAOS) to a Home Assistant Container setup running on a k3s cluster. I previously tried to solve this on HAOS, but I quickly hit a wall. The OS is intentionally locked down. I wasn’t able to install the necessary Bluetooth and audio packages (pipewire, bluez, etc.) because the underlying system just doesn’t allow that kind of low-level modification.

Moving to a standard Linux container setup on Ubuntu gave me the freedom I needed, but it also meant the “training wheels” were off.

I SSH’d into my Pi 5 (hostname “Jarvis”, running Ubuntu 25.04 for ARM64) and took stock of what I was working with.

dpkg -l | grep -E "bluez|pulse|pipewire|alsa"
# bluez 5.82
# ...that's it.

BlueZ was installed. Nothing else. No audio server, no PulseAudio, no PipeWire, no ALSA user-space tools. Just a bare Bluetooth stack with no idea what to do with audio.

On macOS, the audio pipeline is invisible. CoreAudio handles everything. On a headless Linux box, you have to build it yourself.

Building the Audio Stack

sudo apt-get install -y \
    pipewire \
    pipewire-pulse \
    libspa-0.2-bluetooth \
    wireplumber \
    mpv

Each package has a job:

  • pipewire: the audio server (modern replacement for PulseAudio)
  • pipewire-pulse: PulseAudio compatibility layer so tools like pactl and mpv --ao=pulse work
  • libspa-0.2-bluetooth: this is the critical one. It’s PipeWire’s Bluetooth plugin that registers A2DP endpoints with BlueZ. Without it, BlueZ knows Bluetooth exists but has no idea it can stream audio.
  • wireplumber: PipeWire’s session manager, handles policy decisions like which device to connect and how to route audio
  • mpv: for actually playing audio

Packages installed. Bluetooth powered on. Time to pair the speaker and test.

Not quite.


The WirePlumber Headless Bug – Three Hours I’ll Never Get Back

After installing everything, I powered on the Bluetooth adapter and checked its capabilities:

bluetoothctl show

The UUID list showed:

UUID: Generic Attribute Profile    (00001801-0000-1000-8000-00805f9b34fb)
UUID: Generic Access Profile       (00001800-0000-1000-8000-00805f9b34fb)
UUID: PnP Information              (00001200-0000-1000-8000-00805f9b34fb)

Three generic UUIDs. No Audio Source. No Audio Sink. No A2DP anything.

These UUIDs are registered by PipeWire’s BlueZ SPA plugin, managed by WirePlumber. If they’re missing, the Bluetooth stack doesn’t know it can do audio. I’d installed all the packages. PipeWire was running. WirePlumber was running. But the Bluetooth audio bridge wasn’t being created.

I tried connecting to the speaker anyway:

bluetoothd: a2dp-sink profile connect failed for B8:69:C2:15:29:FD: Protocol not available

“Protocol not available.” The Bluetooth stack found the speaker’s A2DP Audio Sink service via SDP discovery but had no handler for it. PipeWire was supposed to provide that handler. Something was blocking it.

I dug into WirePlumber’s logs and found the answer buried in a feature I’d never heard of: seat monitoring.

WirePlumber queries systemd-logind to check if the current session’s seat is “active.” On a desktop, this makes sense. Don’t grab Bluetooth when the screen is locked or the user is away. It’s a privacy/security feature.

On a headless Raspberry Pi accessed via SSH? The session state is "online", not "active". There’s no physical seat. No login screen. No console. WirePlumber sees this and says “the user isn’t here” and refuses to start the Bluetooth monitor. The A2DP endpoints never get registered.

This is, technically, working as designed. It’s also completely useless for a headless audio server.

The Fix

mkdir -p ~/.config/wireplumber/wireplumber.conf.d

cat > ~/.config/wireplumber/wireplumber.conf.d/50-bluetooth-no-seat.conf << 'EOF'
wireplumber.profiles = {
  main = {
    monitor.bluez.seat-monitoring = disabled
  }
}
EOF

systemctl --user restart wireplumber

One config file. Five lines. Three hours of debugging.

After restarting WirePlumber, I ran bluetoothctl show again:

UUID: Audio Source                  (0000110a-0000-1000-8000-00805f9b34fb)
UUID: Audio Sink                    (0000110b-0000-1000-8000-00805f9b34fb)
UUID: A/V Remote Control            (0000110e-0000-1000-8000-00805f9b34fb)
UUID: A/V Remote Control Target     (0000110c-0000-1000-8000-00805f9b34fb)
UUID: A/V Remote Control Controller (0000110f-0000-1000-8000-00805f9b34fb)
UUID: Generic Attribute Profile     (00001801-0000-1000-8000-00805f9b34fb)
UUID: Generic Access Profile        (00001800-0000-1000-8000-00805f9b34fb)
UUID: PnP Information              (00001200-0000-1000-8000-00805f9b34fb)

There they are. Audio Source, Audio Sink, AVRCP. The A2DP endpoints were alive.


Understanding What’s Actually Happening – The btmon Traces

Before moving forward, I wanted to understand what happens at the Bluetooth level when connecting to the speaker. I captured two btmon traces: one on a machine with a working audio stack, and one without.

The traces revealed the full handshake:

sequenceDiagram
    participant Pi as Raspberry Pi<br/>(BlueZ + PipeWire)
    participant Speaker as HK Aura BT<br/>B8:69:C2:15:29:FD

    Pi->>Speaker: Create Connection (BR/EDR Classic)
    Speaker-->>Pi: Connect Complete (~3.8s)
    Pi->>Speaker: Read Remote Features
    Speaker-->>Pi: Feature Bitmap (EDR, SSP, A2DP capable)
    Pi->>Speaker: Simple Secure Pairing (IO: NoInputNoOutput)
    Speaker-->>Pi: Pairing Complete, Link Key Generated
    Pi->>Speaker: L2CAP → SDP Service Discovery
    Note over Speaker: Returns 5 service records<br/>across 11 continuation packets<br/>(SDP MTU: 48 bytes)
    Speaker-->>Pi: Audio Sink, AVRCP, Serial Port, Vendor-specific (ESPL)
    Pi->>Speaker: A2DP Stream Setup
    Note over Pi,Speaker: Audio data flows → Speaker wakes

A few things stood out from the SDP discovery:

  • The speaker advertises A2DP v1.3 (Audio Sink), AVRCP v1.5 (remote control), and a Serial Port Profile (probably for firmware updates or diagnostics)
  • There’s a vendor-specific service with UUID 00000000-deca-fade-deca-deafdecacaff named “Wireless iAP” / “ESPL”, HARMAN’s proprietary configuration protocol. Found the same UUID in the decompiled APK.
  • The speaker’s SDP MTU is only 48 bytes, so the full service database gets split across 11 continuation packets. The speaker isn’t exactly generous with bandwidth during discovery.
  • Device class 0x240414 confirms it: Audio/Video, Loudspeaker. Good to know it knows what it is.

The critical difference between the two traces? On the machine without PipeWire, everything up to SDP discovery worked perfectly. BlueZ found the speaker’s A2DP service. Then:

a2dp-sink profile connect failed: Protocol not available

BlueZ discovered the service but had no audio server to hand it off to. That’s the exact error that led me to the WirePlumber seat monitoring fix.


Connection Alone Isn’t Enough

With A2DP registered, I paired and connected:

bluetoothctl pair B8:69:C2:15:29:FD
bluetoothctl trust B8:69:C2:15:29:FD
bluetoothctl connect B8:69:C2:15:29:FD
[NEW] Endpoint /org/bluez/hci0/dev_B8_69_C2_15_29_FD/sep1
[NEW] Transport /org/bluez/hci0/dev_B8_69_C2_15_29_FD/sep1/fd3
Connection successful

The A2DP Stream End Point and transport file descriptor confirmed the audio channel was established. PipeWire created a bluez_output.B8_69_C2_15_29_FD.1 sink.

I checked TCP port 10025 on the speaker’s WiFi IP. Still closed. The speaker was Bluetooth-connected but not awake.

Turns out, just establishing a Bluetooth connection isn’t enough. The speaker distinguishes between “BT connected but idle” and “BT actively receiving audio.” Only actual audio data flowing through the A2DP stream triggers the full wake-up sequence. The speaker’s audio DSP stays off until it detects incoming audio. Probably a power-saving design.

I piped a generated 440Hz sine wave through paplay:

paplay /tmp/wake_tone.wav

The speaker clicked, hummed to life, and port 10025 opened. Victory.

I also tried sending raw silence (dd if=/dev/zero | paplay --raw). The speaker ignored it entirely. It needs real encoded audio content, not digital nothing. Fair enough.

SSH Environment Issues – Yet Another Rabbit Hole

Running audio tools over SSH on a headless system introduces its own set of problems:

  • XDG_RUNTIME_DIR not set. PipeWire and PulseAudio need this to find their sockets. SSH sessions don’t always set it.
  • DBUS_SESSION_BUS_ADDRESS not set. WirePlumber communicates over D-Bus. SSH sessions don’t set this either.
  • PipeWire-Pulse not active. On headless systems, the service sometimes doesn’t start automatically.

The fixes are straightforward once you know about them:

export XDG_RUNTIME_DIR=/run/user/$(id -u)
export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
systemctl --user start pipewire-pulse

But figuring out why pactl was refusing to connect when PipeWire was clearly running? That took longer than I’d like to admit.


The Final Wake Script

After all the debugging, and false starts, the wake script evolved into something robust:

flowchart TD
    A[Start] --> B{TCP 10025 open?}
    B -->|Yes| C[Speaker already awake - exit]
    B -->|No| D[bluetoothctl connect]
    D --> E{Connected?}
    E -->|No| F[Wait 3s, retry once]
    F --> E
    E -->|Yes| G[Wait for bluez_output sink]
    G --> H[Set default PulseAudio sink to BT]
    H --> I[mpv streams 5s of audio via A2DP]
    I --> J[Wait 5s for speaker to initialize]
    J --> K[bluetoothctl disconnect]
    K --> L{Poll TCP 10025}
    L -->|Open| M[Speaker awake - exit 0]
    L -->|Timeout 60s| N[Failed - exit 1]

The script has cascading audio fallbacks: mpv --ao=pulse first, then --ao=pipewire, then pw-play /dev/zero as a last resort. It handles SSH environment setup, PipeWire service auto-start, connection retries, and a 60-second TCP polling loop.


The Repository

I’ve open-sourced the full implementation on same repo as the integration under scripts. It includes the production-ready Linux wake script, the macOS “quick-win” version, and a setup script for Linux that handles the PipeWire/WirePlumber configuration automatically so you don’t have to manually fight with the seat monitoring bug.


Wiring It Into Home Assistant

My Home Assistant runs as a Kubernetes pod with hostNetwork: true on the same Pi. This means the HA container can reach the host at 127.0.0.1. But the wake script needs to run on the host, not inside the container. It needs access to the host’s Bluetooth stack and PipeWire session.

The solution: SSH from the HA container to the host.

# configuration.yaml
shell_command:
  wake_hk_aura_bt: >-
    ssh -i /config/.ssh/jarvis_key
        -o StrictHostKeyChecking=no
        -o ConnectTimeout=5
        -o BatchMode=yes
        vakintosh@127.0.0.1
        '/usr/local/bin/wake_speaker_linux.sh'

I also added a TCP liveness probe, a binary_sensor that polls port 10025 every 15 seconds to know if the speaker is awake:

binary_sensor:
  - platform: command_line
    name: "HK Aura TCP Alive"
    unique_id: hk_aura_tcp_alive
    command: >-
      python3 -c "import socket; s=socket.socket(); s.settimeout(3);
      r=s.connect_ex(('192.168.1.100',10025)); s.close();
      print(0 if r else 1)"
    payload_on: "1"
    payload_off: "0"
    scan_interval: 15
    device_class: connectivity

The Wake Guard Automation

The real magic is the automation that makes the wake transparent. When Music Assistant starts playing to the speaker and the speaker isn’t awake, the automation intercepts:

  1. Pauses Music Assistant
  2. Runs the Bluetooth wake sequence (SSH to host, connect BT, stream audio, wait for TCP 10025)
  3. Resumes Music Assistant

From the user’s perspective, you just hit play and the speaker comes alive. There’s a ~30-second delay while the wake happens, but it’s fully automatic. No button pressing, no app switching, no walking across the room.


The Cluster (Briefly)

The whole stack runs on a k3s cluster: Home Assistant, Music Assistant, ESPHome, Mosquitto, and more. Getting the speaker wake to work inside this environment had its own challenges with container capabilities, hostNetwork, and workload placement. But that’s a story for another post. (It involves a 7-commit battle with s6-overlay, OOM kills, and a day spent debugging Flannel VXLAN tunnels. It was fun.)


Key Takeaways

  1. The HK Aura Plus has a standby mode that kills WiFi but keeps Bluetooth alive. The power-off command doesn’t fully shut down the speaker. It enters standby where the WiFi interface and audio DSP are powered down, but the Bluetooth radio stays active and listening. Waking it requires a BT A2DP connection with actual audio data flowing. This is why power-on over WiFi will never work. The network stack is off. It’s not a missing command, it’s a hardware-level design choice.

  2. WirePlumber’s seat monitoring will silently break Bluetooth audio on headless systems. If your A2DP UUIDs aren’t showing up in bluetoothctl show, this is probably why. One config file fixes it. Five lines. You’re welcome.

  3. Bluetooth connection without audio is not a wake event. The speaker needs actual encoded audio flowing through the A2DP stream. Raw silence doesn’t count. This distinction between “connected” and “streaming” is subtle but critical.

  4. Building an audio pipeline on headless Linux is not trivial. You need BlueZ + PipeWire + PipeWire-Pulse + libspa-0.2-bluetooth + WirePlumber, plus correct environment variables for SSH sessions. Each component has a specific role, and if any one is missing, the whole chain breaks silently.

  5. The SSH bridge pattern (HA container to host via SSH) is an effective way to access host-level resources (Bluetooth, audio) from inside a container without requiring privileged access or device passthrough.


What’s Next

  • The cluster post. The full story of hardening the k3s stack, the s6-overlay battle, and getting Music Assistant to discover the speaker inside Kubernetes. That one deserves its own article.

It’s now past sunrise. The terminal is still open, and the speaker wakes itself up on command. Was it worth three straight nights of chasing WirePlumber seat policies and Bluetooth SDP packets? Absolutely. Now if you’ll excuse me, I have a 43-minute sunrise alarm to sleep through.

As a friend of mine has been telling me a lot recently: Sleep tight, and don’t let the bedbugs bite.