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:
- every message starts with
0xcc, 0xaa
(sync bytes) - messages are variable length → length field is likely embedded
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:
0x01
: 1 message, init0x02
: 1 message, init followup0x03
: 5 messages, all likely status requests0x40
: 22 messages, all likely commands
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:
- The sequencing of the packets: command
0312
causes12....
to be returned, last byte seems to be volume - 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>
- length is always
0x0d
(signifying 13B payload) - type is always
0x05
(status) <00>
was so far always0x00
, but no idea what it is- power is 1B, either
00
(power off), or01
(power on) - input is 1B,
0x0
(hdmi),0xc
(analog),0x5
(bluetooth),0x7
(tv) - muted is 1B, either
00
(not muted), or01
(muted) - volume is 1B, int value from
0x00
to0x32
- subwoofer is 1B, int value from
0x00
to0x20
in steps of 4, with0x10
being neutral <202000>
was so far always the three bytes0x202000
, but no idea what it is- surround is 2B, values:
0x0d
(3d),0x0a
(tv),0x0100
(stereo),0x03
(movie),0x08
(music),0x09
(sports),0x0c
(game) - be+cv is 1B, bitfield (values ORed together):
0x20
(bass ext),0x4
(clearvoice)
So by way of example: ccaa0d05000100000910202000000a2466
means:
- powered on
- input hdmi
- not muted
- volume
0x09
- subwoofer
0x10
(neutral) - surround TV
- bass extension and clearvoice active
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)