Yamaha YAS-207's Bluetooth protocol reversed


As explained in the Introductory post, I’m reversing the control interfaces of the Yamaha YAS-207 soundbar.

In last post I managed to finally get the communication log between the “Home Theater Controller” app and the YAS-207 soundbar.

In the meantime I spent some time mashing “buttons” in the app and recording what messages are being sent.

In this post I’m going to dive into reversing the protocol.

Captured data (App → Soundbar)

These are my notes of what happened when I was changing settings from the app. Note these are all commands sent to the soundbar:

List of the commands captured and noted down (click to expand)

Periodical statuses:
ccaa020305f6 = status request (response e.g. ccaa0d05000107000a10202000000a2062)

ccaa0340784afb = set input hdmi, always followed by ccaa020311ea
ccaa034078d174 = set input analog, always followed by ccaa020311ea
ccaa034078291c = set input bluetooth, always followed by ccaa020311ea
ccaa034078df66 = set input tv, always followed by ccaa020311ea

ccaa034078c97c = set 3d surround, followed by ccaa020315e6
ccaa03407ef14e = set tvprogram, followed by ccaa020315e6
ccaa03407850f5 = set stereo, followed by ccaa020315e6
ccaa034078d96c = set movie, followed by ccaa020315e6
ccaa034078da6b = set music, followed by ccaa020315e6
ccaa034078db6a = set sports, followed by ccaa020315e6
ccaa034078dc69 = set game, followed by ccaa020315e6

ccaa03407e80bf = set clearvoice, followed by ccaa020315e6
ccaa03407e82bd = unset clearvoice, followed by ccaa020315e6
ccaa0340786fd6 = unset bass ext, followed by ccaa020315e6
ccaa0340786ed7 = set bass ext, followed by ccaa020315e6
ccaa0340784cf9 = sw+, followed by ccaa020313e8
ccaa0340784df8 = sw-, followed by ccaa020313e8

ccaa03407ea29d = mute on, followed by ccaa020312e9
ccaa03407ea39c = mute off, followed by ccaa020312e9
ccaa0340781e27 = volume up, followed by ccaa020312e9
ccaa0340781f26 = volume down, followed by ccaa020312e9
ccaa0340787fc6 = turn off, no follow up?
ccaa090148545320436f6e7453 = init, followed by ccaa03020001fa

Packet structure

Couple of things are immediately apparent:

Now, comparing these messages:

ccaa020305f6 = status request, 6 bytes
ccaa0340784afb = set input hdmi, 7 bytes
ccaa034078d174 = set input analog, 7 bytes
ccaa090148545320436f6e7453 = init, 13 bytes
ccaa0d05000107000a10202000000a2062 = status string, 17 bytes

I would take a guess that the packet structure is:


But for packet of length 6B (ccaa020305f6) the field says 0x02 (2): ccaa02[0305]f6.

So the end is probably checksum?

So the packet structure really is: <0xcc><0xaa><length><payload[len]><csum>.

I’ll need to figure out the checksum computation (later).

Making sense of the commands sent

As for types of packets, what gets sent to the device is a rather limited set:

$ bash parse-btsnoop.sh b.log | awk '$2 ~ /Sent/ { print $3 }' | \
  sort -u | sed -r 's/^ccaa..(.*)..$/\1/'

So there are 4 distinct groups, broken down by first byte:

But holy moly! If you look at the commands, they are generally 4078.. (with just couple of them 407e..). If you compare it with the table of Infrared remote codes, you get a match!!

The bluetooth 4078.. codes map 1:1 to the NEC codes. Check this out:

ccaa0340[784a]fb = set input hdmi vs. P=NEC A=0x87[78] C=0x[4A]

There’s a few missing (mute, stereo, surround, power, dimmer, bluetooth_standby), though.

The 407e.. codes are different. But I have a theory: The infrared remote is a stateless thing. So with “power” you have to say “toggle power”, not explicit “power on” or “power off”.

So I’m guessing that explains the infra 0x78 / 0xcc for power (toggle) vs the bluetooth 0x78 / 0x7f power (off).

What I don’t have, though, is a code for power on. I’ll have to sort that out later.

Making sense of the messages received

Making sense of the messages received is a bit tougher:

$ bash parse-btsnoop.sh b.log | awk '$2 ~ /Rcvd/ { print $3 }' | \
  sort -u | sed -r 's/^ccaa..(.*)..$/\1/' | wc -l

There’s 122 unique messages, so the same approach I used for commands will not work that well.

So… being a firm believer in the time old practice of diffing he hell out of things… I hacked up a quick annotator (in Ruby) that will help:

annotate-commlog.rb (click to expand)

#!/usr/bin/env ruby

# this reads whatever parse-btsnoop.* spits out and annotates known messages

known = {
	"ccaa0340784afb" => "set input hdmi", "ccaa034078d174" => "set input analog",
	"ccaa034078291c" => "set input bluetooth", "ccaa034078df66" => "set input tv",
	"ccaa020311ea" => "input followup", "ccaa034078c97c" => "set 3d surround",
	"ccaa03407ef14e" => "set tvprogram", "ccaa03407850f5" => "set stereo",
	"ccaa034078d96c" => "set movie", "ccaa034078da6b" => "set music",
	"ccaa034078db6a" => "set sports", "ccaa034078dc69" => "set game",
	"ccaa03407e80bf" => "set clearvoice", "ccaa03407e82bd" => "unset clearvoice",
	"ccaa0340786fd6" => "unset bass ext", "ccaa0340786ed7" => "set bass ext",
	"ccaa020315e6" => "surround followup", "ccaa0340784cf9" => "sw up",
	"ccaa0340784df8" => "sw down", "ccaa020313e8" => "subwoofer followup",
	"ccaa090148545320436f6e7453" => "init", "ccaa03020001fa" => "init followup",
	"ccaa03407ea29d" => "mute on", "ccaa03407ea39c" => "mute off",
	"ccaa0340781e27" => "volume up", "ccaa0340781f26" => "volume down",
	"ccaa020312e9" => "volume/mute followup", "ccaa0340787fc6" => "turn off",
	"ccaa020305f6" => "status req", "ccaa080400013219020a009c" => "rx device id",
	"ccaa03000200fb" => "rx init fu reply",

def diff(fst, snd)
	if fst == snd
		"{" + fst.scan(/../).zip(snd.scan(/../)).map { |(x,y)|
          x == y ? x : "[#{x}#{y}]" }.join + "}"

def diff_factory(field)
	lambda { |state, msg|
      out = if state[field] then diff(state[field], msg) else "" end;
      state[field] = msg; out }

prefix_action = {
	"ccaa0d05" => diff_factory(:status), "ccaa0211" => diff_factory(:input),
	"ccaa0312" => diff_factory(:vol), "ccaa0213" => diff_factory(:woofer),
	"ccaa0515" => diff_factory(:surround),

def beautify_payload(pkt)
	if pkt =~ /^(ccaa)(..)(.*)(..)$/

state = {}

STDIN.sync = true
STDOUT.sync = true
STDIN.each do |ln|
	pkt, dir, pd, _ = ln.strip.split(/\s+/)
	if known[pd]
		puts "#{pkt} #{dir} #{beautify_payload(pd)} [#{known[pd]}]"
		annot = ""
		for pfx, action in prefix_action
			if pd.start_with?(pfx)
				annot = Array(action).map { |a| a.call(state, pd) }.join(' ')
		puts "#{pkt} #{dir} #{beautify_payload(pd)}#{annot.empty? ? "" : " " + annot}"

That gives me output like:

1701 Sent ((0148545320436f6e74)) [init]
1735 Rcvd ((0400013219020a00)) [rx device id]
1761 Rcvd ((05000105000910202000000320))
1763 Sent ((020001)) [init followup]
1778 Rcvd ((000200)) [rx init fu reply]
1983 Sent ((0305)) [status req]
2003 Rcvd ((05000105000910202000000320)) {}
2236 Sent ((0305)) [status req]
2263 Rcvd ((05000105000910202000000320)) {}
2299 Sent ((40781e)) [volume up]
2323 Sent ((0312)) [volume/mute followup]
2341 Rcvd ((12000a))
2382 Sent ((40781e)) [volume up]
2385 Sent ((0312)) [volume/mute followup]
2388 Rcvd ((12000b)) {ccaa031200[0a→0b][e1→e0]}
2389 Sent ((0305)) [status req]
2392 Rcvd ((05000105000b10202000000320)) {ccaa0d0500010500[09→0b]10202000000320[6c→6a]}
2393 Sent ((40781e)) [volume up]
2396 Sent ((0312)) [volume/mute followup]
2399 Rcvd ((12000c)) {ccaa031200[0b→0c][e0→df]}
2400 Sent ((0305)) [status req]
2403 Rcvd ((05000105000c10202000000320)) {ccaa0d0500010500[0b→0c]10202000000320[6a→69]}

with name of known codes in [] and diff against previous occurence of a code in {}.

And the output hints at two things:

  1. The sequencing of the packets: command 0312 causes 12.... to be returned, last byte seems to be volume
  2. The checksum at the end looks like a simple arithmetic (more about that in a moment)

Making sense of the checksum

Check these three diffs:

2392 Rcvd ((05000105000b10202000000320)) {ccaa0d0500010500[09→0b]10202000000320[6c→6a]}
2403 Rcvd ((05000105000c10202000000320)) {ccaa0d0500010500[0b→0c]10202000000320[6a→69]}
3874 Rcvd ((1500000320)) {ccaa051500[01→00][00→03]20[c5→c3]}

In the first case, for +2 change in the payload, there was -2 change in the checksum. In the second case, for +1 change in the payload, there was -1 change in the checksum. In the third case, for -1+3=+2 change in the payload, there was -2 change in the checksum.

So the checksum is likely just a simple sum of the payload… with a sign change?

Ruby to confirm:

verify-csum.rb (click to expand)

#!/usr/bin/env ruby

Based on these diffs (courtesy of annotate-commlog.rb):


It's clear that the checksum isn't CRC, because 1-bit change results in
corresponding 1-bit change in the checksum.

What's more, based solely on this:


it looks like the payload changed by -1+3=+2 and the csum changed by -2.

So perhaps it's a simple sum, but inverted?

irb> "%x" % (-"ccaa051500010020c5"[0..-3].scan(/../).map { |x| x.to_i(16) }.sum)
=> "..fe4f"

... nope. Maybe without the sync header?

irb> "%x" % (-"ccaa051500010020c5"[4..-3].scan(/../).map { |x| x.to_i(16) }.sum)
=> "..fc5"

... hey, that looks a lot like "c5", but I need to trim it to one byte.

irb> "%02x" % (-"ccaa051500010020c5"[4..-3].scan(/../).map { |x| x.to_i(16) }.sum & 0xff)
=> "c5"


So I'm going to write this script to parse commlog and recompute checksum, to
verify there are no nasty little hobbitses. (surprises)

Use thusly:

$ diff -u commlog <(ruby verify-csum.rb < commlog)

Spoiler alert: no surprises, it worked a treat.

def calc_csum(pd)
	"%02x" % (-pd.scan(/../).map { |x| x.to_i(16) }.sum & 0xff)

STDIN.each do |ln|
	pkt, dir, pd, _ = ln.strip.split(/\s+/)
	if pd =~ /^ccaa[0-9a-f]+$/
		puts ln.gsub(pd, pd[0..-3] + calc_csum(pd[4..-3]))
		puts ln

So the relevant checksum computation:

def calc_csum(pd)
	"%02x" % (-pd.scan(/../).map { |x| x.to_i(16) }.sum & 0xff)

Making sense of the status messages

The 0310 (power), 0311 (input), 0312 (mute, volume), 0313 (woofer), 0315 (surround, clearvoice, bass ext) messages are somewhat simple to grok.

What was more difficult was the 0305 (full status, queried periodically by the app) because it seems to encode the entire status of the device.

Alas, it seems to be mostly composite of the other 031? messages.

I still don’t understand parts of it, but what I do know:


So by way of example: ccaa0d05000100000910202000000a2466 means:

I would like to understand what the <00> and <202000> fields mean, but since they don’t seem to change for me, I’d venture a guess that they have some meaning on other – higher-end – models.

Making sense of the intitial exchange

I also have no clue about the meaning of the initial exchange:

The value sent by the App 0148545320436f6e74 ("\x01HTS Cont") is likely fixed ID. Not sure what the 0400013219020a00 reply means. It might be hiding the firmware version ID and model ID. Then again, this info could be also fetchable from the PNPInformation (see the basic recon article), so maybe not?

Furthermore, the follow-up message 020001 elicits static 000200 reply, and it seems that this message unlocks the possibility to send the 0x40 commands.

But I am not sure… so I’m going to write my client to do the entire handshake.

Figuring out the “power on” command

Even though this post is pretty long already, there’s one piece missing: the “power on” command.

I took a look at the other on/off commands:

407___ power_on
40787f power_off
407e80 clearvoice_on
407e82 clearvoice_off
40786e bass_ext_on
40786f bass_ext_off
407ea2 mute_on
407ea3 mute_off

and guessed that power_on will be: 40787e. It worked. First try. :-)

Closing words

To conclude… I now have enough understanding of the bluetooth control protocol to build me some simple commandline utility.

Also, surprisingly, when you enable “Bluetooth standby” on the device, you can stay connected to the SPP even when the device is turned off (to standby). That further simplifies the control.

In the next installment (under the yas 207 tag) I’ll take a stab at some minimal remote control script/daemon. Stay tuned. (ETA: the next weekend)