Yamaha YAS-207's Bluetooth protocol reversed


Introduction

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)

Input:
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

Surround:
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

Sound:
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

Main:
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:

<0xcc><0xaa><length><payload*>

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/'
0305
0311
0312
0313
0315
020001
40781e
40781f
407829
40784a
40784c
40784d
407850
40786e
40786f
40787f
4078c9
4078d1
4078d9
4078da
4078db
4078dc
4078df
407e80
407e82
407ea2
407ea3
407ef1
0148545320436f6e74

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
122

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
		"{}"
	else
		"{" + fst.scan(/../).zip(snd.scan(/../)).map { |(x,y)|
          x == y ? x : "[#{x}#{y}]" }.join + "}"
	end
end

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

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)(..)(.*)(..)$/
		"((#$3))"
	else
		pkt
	end
end

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]}]"
	else
		annot = ""
		for pfx, action in prefix_action
			if pd.start_with?(pfx)
				annot = Array(action).map { |a| a.call(state, pd) }.join(' ')
				break
			end
		end
		puts "#{pkt} #{dir} #{beautify_payload(pd)}#{annot.empty? ? "" : " " + annot}"
	end
end

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

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

{ccaa031200[0a→0b][e1→e0]}
{ccaa0d0500010500[09→0b]10202000000320[6c→6a]}
{ccaa031200[0b→0c][e0→df]}
{ccaa0d0500010500[0b→0c]10202000000320[6a→69]}
{ccaa031200[0c→0d][df→de]}
{ccaa05150000[03→0d]20[c3→b9]}
{ccaa05150000[0d→0a]20[b9→bc]}
{ccaa051500[00→01][0a→00]20[bc→c5]}
{ccaa051500[01→00][00→03]20[c5→c3]}
{ccaa05150000[03→08]20[c3→be]}
{ccaa05150000[08→09]20[be→bd]}
{ccaa05150000[09→0c]20[bd→ba]}
{ccaa05150000[0c→0a]20[ba→bc]}
{ccaa0d050001[00→05]000c10202000000a20[67→62]}

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:

{ccaa051500[01→00][00→03]20[c5→c3]}

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"

Bingo!

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.
=end

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

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]))
	else
		puts ln
	end
end

So the relevant checksum computation:

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

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:

<ccaa><length><type><00><power><input><muted><volume><subwoofer><202000><surround><be+cv><csum>

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)