Yamaha YAS-207's Minimal Client (and a Soundbar Fake)
Note: Updated on 2021-04-24 to fix the zero-msg DoS in the codec. Oops.
Introduction
As explained in the Introductory post, I’m reversing the control interfaces of the Yamaha YAS-207 soundbar, in order to build a fully automated AirPlay speaker.
In the last post I reversed the Bluetooth protocol from start to finish, documenting what I could.
In this post I document a path to a minimal Ruby-based client (and a soundbar fake).
If you just want the code, head to the appropriate github repo.
A client please…
Still here? Cool, I’ll continue…
Having a protocol description is fine, but having a client is better.
I experimented a little, hacked up the packet codec first:
YamahaPacketCodec class (click to expand)
# Author: Michal Jirku (wejn.org)
# License: GNU Affero General Public License v3.0
# Packet codec -- codes and decodes the Yamaha YAS-207 packets.
#
# @example Run streaming decoder
# ypc = YamahaPacketCodec.new { |r| p [:received_packet, r] }
# ypc.streaming_decode("ccaa0340784afb".scan(/../).map { |x| x.to_i(16).chr }.join)
# # ^^ outputs: [:received_packet, [64, 120, 74]]
# @example Encode packet
# YamahaPacketCodec.encode([3, 5]) # => "\xCC\xAA\x02\x03\x05\xF6"
class YamahaPacketCodec
# Initialize the codec.
#
# @param ingest_cb [Proc] callback is invoked every time a valid packet is received.
def initialize(&ingest_cb)
# State transitions:
# :desync in: 0xcc -> :sync_cc, _ -> :desync
# :sync_cc in: 0xaa -> :synced, 0xcc -> :sync_cc, _ -> :desync
# :synced in: 1byte -> [mark payload length] length > 0 ? :reading : :csum
# :reading in: 1byte -> [remember] collected bytes \lt length :reading || :csum
# :csum in: 1byte -> [verify checksum, process pkt] :desync
@state = :desync
@pload = []
@length = 0
@ingest_cb = ingest_cb
end
# Perform streaming decode of incoming data, calling the ingest_cb
# with valid packets as needed.
#
# @param data [String] incoming data
# @return [YamahaPacketCodec] self
def streaming_decode(data)
data.each_byte do |b|
case @state
when :desync
if b.ord == 0xcc
@state = :sync_cc
end
when :sync_cc
case b.ord
when 0xaa
@state = :synced
when 0xcc
@state = :sync_cc
else
@state = :desync
end
when :synced
@pload = []
@length = b.ord
if @length > 0
@state = :reading
else
@state = :csum
end
when :reading
@pload << b
if @pload.size == @length
@state = :csum
end
when :csum
dbug_out = proc do |msg|
if $VERBOSE || $DEBUG
STDERR.puts "YamahaPacketCodec#streaming_decode: #{msg}"
end
end
pload_hex = @pload.map { |x| "%02x" % x.ord }.join
if valid_csum?(b.ord)
dbug_out["Received valid: #{pload_hex}"]
@ingest_cb.call(@pload) if @ingest_cb
else
dbug_out["Received invalid csum (#{b}): #{pload_hex}"]
end
@pload = []
@length = 0
@state = :desync
end
self
end
self
end
# Compute checksum for payload.
#
# @param len [Integer] length of the payload
# @param pload [Array<Integer>, String] payload to checksum
# @return [Byte] one byte checksum (Integer in the range of 0..255)
def self.csum(len, pload)
-(len + pload.sum) & 0xff
end
# Is this a valid checksum for the payload we have?
#
# @param csum [Byte] checksum to verify against
def valid_csum?(csum)
self.class.csum(@length, @pload) == csum
end
private :valid_csum?
# Encode given packet.
#
# Can come either as fully formed (`ccaa020305f6`),
# as a simple hex encoded payload (`0305`),
# or as a byte array (`[3, 5]`).
#
# In the fully formed case the format isn't validated,
# so you can shoot yourself in the foot by sending invalid
# packet and de-syncing the receiver.
#
# @param packet [Array<Integer>, String] packet to encode, see above for format.
# @return [String] encoded packet
def self.encode(packet)
if packet.kind_of?(String)
if /^ccaa([0-9a-f]{2})+$/i =~ packet
packet.scan(/../).map {|x| x.to_i(16).chr }.join
elsif /^([0-9a-f]{2})+$/i =~ packet
pload = packet.scan(/../).map {|x| x.to_i(16) }
csum = csum(pload.size, pload)
[0xcc, 0xaa, pload.size, *pload, csum].map(&:chr).join
else
raise ArgumentError, "either array of numbers (bytes), or hexa string, please"
end
else
pload = Array(packet)
csum = csum(pload.size, pload)
[0xcc, 0xaa, pload.size, *pload, csum].map(&:chr).join
end
end
end
The example says it all, but I think it’s worth reiterating:
ypc = YamahaPacketCodec.new { |r| p [:received_packet, r] }
ypc.streaming_decode("ccaa0340784afb".scan(/../).map { |x| x.to_i(16).chr }.join)
# outputs: [:received_packet, [64, 120, 74]]
YamahaPacketCodec.encode([3, 5]) # => "\xCC\xAA\x02\x03\x05\xF6"
And because I like Ruby (and webservers), I hacked up a simple webserver1 that speaks the protocol used by YAS-207 over a rfcomm socket2.
simple YAS-207 remote control script (click to expand)
#!/usr/bin/env ruby
# Author: Michal Jirku (wejn.org)
# License: GNU Affero General Public License v3.0
begin
require 'serialport'
rescue LoadError
STDERR.puts "Missing serialport gem: #$!."
exit 1
end
begin
require_relative 'common'
rescue LoadError
STDERR.puts "Missing common lib: #$!."
exit 1
end
require 'thread'
require 'webrick'
require 'json'
# YAS-207 remote.
#
# Provides higher-level abstraction (stateful management) on top of the soundbar.
#
# Also implements interface used by `YamahaSerialInputWorker`.
class YamahaSoundbarRemote
# Init string sent to YAS-207
INIT_STRING = "0148545320436f6e74"
# Init-followup command sent to YAS-207 (that is sent after the response to init string)
INIT_FOLLOWUP = "020001"
# Commands accepted by the YAS-207 (that I know of)
COMMANDS = {
# power management
power_toggle: "4078cc",
power_on: "40787e",
power_off: "40787f",
# input management
set_input_hdmi: "40784a",
set_input_analog: "4078d1",
set_input_bluetooth: "407829",
set_input_tv: "4078df",
# surround management
set_3d_surround: "4078c9",
set_tvprogram: "407ef1",
set_stereo: "407850",
set_movie: "4078d9",
set_music: "4078da",
set_sports: "4078db",
set_game: "4078dc",
surround_toggle: "4078b4", # -- sets surround to `:movie` (or `:"3d"` if already `:movie`)
clearvoice_toggle: "40785c",
clearvoice_on: "407e80",
clearvoice_off: "407e82",
bass_ext_toggle: "40788b",
bass_ext_on: "40786e",
bass_ext_off: "40786f",
# volume management
subwoofer_up: "40784c",
subwoofer_down: "40784d",
mute_toggle: "40789c",
mute_on: "407ea2",
mute_off: "407ea3",
volume_up: "40781e",
volume_down: "40781f",
# extra -- IR -- don't use?
bluetooth_standby_toggle: "407834",
dimmer: "4078ba",
# status report (query, soundbar returns a message)
report_status: "0305"
}.freeze
# Mapping of input values to names
INPUT_NAMES = {
0x0 => :hdmi,
0xc => :analog,
0x5 => :bluetooth,
0x7 => :tv,
}.freeze
# Mapping of surround values to names
SURROUND_NAMES = {
0x0d => :"3d",
0x0a => :tv,
0x0100 => :stereo,
0x03 => :movie,
0x08 => :music,
0x09 => :sports,
0x0c => :game,
}.freeze
# How long to wait for sync before retrying.
SYNC_TIMEOUT = 15
# How often to refresh status.
STATUS_REFRESH = 30
def initialize
@device_state = {}
@queue = Queue.new
@state = :initial
end
attr_reader :device_state
# Handle packet received via serial.
#
# @param packet [Array<Integer>, :reset, :heartbeat] incoming packet
def handle_received(packet)
if packet == :reset
@state = :initial
@queue.clear # no use pushing anything when the comm broke
@reset_at = Time.now
enqueue(INIT_STRING)
elsif packet == :heartbeat
if @state == :synced
if @last_status_at + STATUS_REFRESH < Time.now
@last_status_at = Time.now
enqueue(COMMANDS[:report_status])
end
else
if @reset_at + SYNC_TIMEOUT < Time.now
STDERR.puts "! Couldn't sync, retrying by :reset."
handle_received(:reset)
end
end
else
# handle packet -- XXX: we might get stray packet regardless of status
case packet.first
when 0x04 # received device id (in response to init?)
if @state == :initial
@state = :init_followup
enqueue(INIT_FOLLOWUP)
else
STDERR.puts "! Received out of sequence did packet: #{packet.inspect}"
end
when 0x00 # response to init followup?
if @state == :init_followup
enqueue(COMMANDS[:set_input_hdmi])
enqueue(COMMANDS[:power_off])
enqueue(COMMANDS[:report_status])
@state = :synced
@last_status_at = Time.now
if packet != [0, 2, 0]
STDERR.puts "? Received unexpected init_followup packet: #{packet.inspect}"
end
else
puts "+ Received: #{packet.inspect}" # FIXME
end
when 0x05 # device status reply
params = parse_device_status(packet)
puts "+ DS: #{params.map { |k,v| "#{k}:#{v}" }.join(',')}"
@device_state = params
# FIXME: remember volume per input?
else
puts "+ Received: #{packet.inspect}" # FIXME
end
end
end
private def parse_device_status(pkt)
params = {}
params[:power] = !pkt[2].zero?
params[:input] = INPUT_NAMES[pkt[3]] || pkt[3]
params[:muted] = !pkt[4].zero?
params[:volume] = pkt[5]
params[:subwoofer] = pkt[6]
srd = (pkt[10] << 8) + pkt[11]
params[:surround] = SURROUND_NAMES[srd] || srd
params[:bass_ext] = !(pkt[12] & 0x20).zero?
params[:clearvoice] = !(pkt[12] & 0x4).zero?
params
end
private def enqueue(command)
cmd = YamahaPacketCodec.encode(command)
@queue.push([Time.now, cmd])
cmd
end
# Send raw command to device -- called by end users (if they speak raw).
#
# @param command [Array<Integer>, String] command understood by `YamahaPacketCodec.encode()`.
# @raise [RuntimeError] when device not ready
def send_raw(command)
if @state == :synced
enqueue(command)
else
raise RuntimeError, "device not ready"
end
end
# Send a command to device -- called by end users.
#
# @param command [Symbol, String] name of the command to send.
# @raise [RuntimeError] when device not ready
# @raise [ArgumentError] when the command is wrong
def send(command)
if @state == :synced
if c = COMMANDS[command.to_sym]
enqueue(c)
command.to_sym
else
raise ArgumentError, "unknown command: #{command}"
end
else
raise RuntimeError, "device not ready"
end
end
# Fetch next packet to be sent to device -- called by comm handler.
#
# @return [String, nil] packet that was enqueued, or `nil` when none
def pop
ts, payload = @queue.pop(true)
if $DEBUG || $VERBOSE
STDERR.puts "~ Dequeued #{payload.inspect} after #{"%.02f" % (Time.now - ts)}s."
end
payload
rescue ThreadError
nil
end
end
if __FILE__ == $0
STDOUT.sync = true
ysr = YamahaSoundbarRemote.new
threads = []
threads << Thread.new do
print "+ BT handler init...\n" # $10 to the first person explaining why not `puts`
YamahaSerialInputWorker.as_thread(ENV['CONTROL_DEVICE'] || '/dev/rfcomm0', ysr)
end
threads << Thread.new do
print "+ Webserver...\n"
s = WEBrick::HTTPServer.new({
:Port => 8000,
:BindAddress => "127.0.0.1",
:Logger => WEBrick::Log.new('/dev/null'),
:AccessLog => [ [$stdout, "> %h %U %b"] ],
:DoNotReverseLookup => true,
})
s.mount_proc("/send") do |req, res|
q = req.query
res['Content-Type'] = 'text/plain; charset=utf-8'
if q["data"]
out = []
for code in q["data"].split(/,/)
begin
sent = ysr.send_raw(code)
out << "sent #{sent.scan(/./m).map{|x| "%02x" % x.ord}.join}.\n"
rescue
out << "failed to send #{q["data"].inspect}: #{$!}.\n"
break
end
end
res.body = out.join
elsif q["commands"]
out = []
for command in q["commands"].split(/,/)
begin
sent = ysr.send(command)
out << "sent #{sent}.\n"
rescue
out << "failed to send #{q["command"].inspect}: #{$!}.\n"
break
end
end
res.body = out.join
else
res.body = "nope (missing params: either data or commands).\n"
end
end
s.mount_proc("/") do |req, res|
out = []
if req.query['json'] || req.accept.include?("application/json")
res['Content-Type'] = 'application/json; charset=utf-8'
out << JSON.pretty_generate(ysr.device_state)
else
res['Content-Type'] = 'text/html; charset=utf-8'
out << <<-EOF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>YAS-207 control</title>
<meta name="viewport" content="width=device-width">
</head>
<body>
EOF
out.last.strip!
out << "<h1>YAS-207 control</h1>"
out << "<pre>" + ysr.device_state.inspect + "</pre>"
out << "</body>\n</html>"
end
res.body = out.join("\n")
res
end
s.start
end
begin
threads.map(&:join)
rescue Interrupt
threads.map(&:kill)
end
end
And while this is a good start, it’s by no means a production code. Plus, the interface is rather spartan:
$ curl localhost:8000/send -d 'commands=power_on,set_input_tv,report_status'
sent power_on.
sent set_input_tv.
sent report_status.
$ curl localhost:8000/ -H 'Accept: application/json'; echo
{
"power": false,
"input": "hdmi",
"muted": false,
"volume": 10,
"subwoofer": 16,
"surround": "tv",
"bass_ext": true,
"clearvoice": false
}
Also, in case you wanted to run it yourself: First off, get the entire codebase3, but also, you might need a boot script like the following:
#!/bin/sh
DEV=C8:84:xx:xx:xx:xx
# set up bluetooth
bluetoothctl power on
rfcomm 2>&1 | grep -q "$DEV"
if [ $? -ne 0 ]; then
rfcomm bind 0 "$DEV" 1
fi
# force install "serialport" gem, if need be
# the exercise why `--local` is left as an exercise to the reader
ruby -rserialport -e 'exit 0' 2>/dev/null || \
gem install --local serialport-1.3.1-aarch64-linux-musl.gem
exec 2>&1
exec ruby ./control.rb
I’m using djb’s excellent daemontools package to turn this4 into a perma-running (supervised) process on an install of the delightfully minimal Alpine Linux distro (on a rpi4). But more on that in a later post.
Before I go, though, let’s talk about…
Soundbar Fake (for further development)
As I was failing through a better version of the control.rb
– and to solidify
my understanding of the soundbar – I also created a fake implementation of the
soundbar itself, that listens on a “serial port”5 and allows me to quickly
iterate over future designs.
It reuses both the codec and the serial worker to encode whatever my incomplete understanding of the Yamaha YAS-207 hardware is:
YamahaSoundbarFake (click to expand)
#!/usr/bin/env ruby
# Author: Michal Jirku (wejn.org)
# License: GNU Affero General Public License v3.0
begin
require 'serialport'
rescue LoadError
STDERR.puts "Missing serialport gem: #$!."
exit 1
end
begin
require_relative 'common'
rescue LoadError
STDERR.puts "Missing common lib: #$!."
exit 1
end
require 'thread'
require 'digest/sha1'
# Fake implementation of the YAS-207 soundbar.
#
# Intended to sit on the other side of a serial port
# and respond to commands (as if the client spoke to
# real hardware).
class YamahaSoundbarFake
def initialize
@queue = Queue.new
@power = true
@input = 0x5
@muted = false
@volume = 10
@subwoofer = 16
@surround = 0xa
@bassext = true
@clearvoice = false
end
# Handle packet received via serial.
#
# @param packet [Array<Integer>, :reset, :heartbeat] incoming packet
def handle_received(packet)
return if packet == :reset || packet == :heartbeat
case packet.first
when nil # empty packet
puts "? Empty packet. Huh?"
when 0x1 # client handshake
if packet == [1, 72, 84, 83, 32, 67, 111, 110, 116]
# if we're doing a handshake, we might as well pretend to be just init'd.
# meaning: power on, input BT
@power, @input = true, 0x5
#
enqueue("0400013219020a00")
end
when 0x2 # client post handshake followup
enqueue([0, 2, 0])
when 0x3 # get status
case packet[1]
when 0x5
enqueue([
0x5, 0,
@power ? 1 : 0,
@input,
@muted ? 1 : 0,
@volume,
@subwoofer,
0x20, 0x20, 0,
(@surround >> 8) & 0xff, @surround & 0xff,
(@bassext ? 0x20 : 0) + (@clearvoice ? 0x4 : 0)])
when 0x10
enqueue([0x10, @power ? 1 : 0])
when 0x11
enqueue([0x11, @input])
when 0x12
enqueue([0x12, @muted ? 1 : 0, @volume])
when 0x13
enqueue([0x13, @subwoofer])
when 0x15
enqueue([
0x15,
0,
(@surround >> 8) & 0xff,
@surround & 0xff,
(@bassext ? 0x20 : 0) + (@clearvoice ? 0x4 : 0)])
else
enqueue("000301")
end
when 0x40 # command
if @power
case (packet[1]<<8) + packet[2]
when 0x78cc # power_toggle
@power = !@power
when 0x787e # power_on
@power = true
when 0x787f # power_off
@power = false
@muted = false
when 0x784a # set_input_hdmi
@input = 0x0
when 0x78d1 # set_input_analog
@input = 0xc
when 0x7829 # set_input_bluetooth
@input = 0x5
when 0x78df # set_input_tv
@input = 0x7
when 0x78c9 # set_3d_surround
@surround = 0x0d
when 0x7ef1 # set_tvprogram
@surround = 0x0a
when 0x7850 # set_stereo
@surround = 0x0100
when 0x78d9 # set_movie
@surround = 0x03
when 0x78da # set_music
@surround = 0x08
when 0x78db # set_sports
@surround = 0x09
when 0x78dc # set_game
@surround = 0x0c
when 0x78b4 # surround_toggle
if @surround == 0x3
@surround = 0xd
else
@surround = 0x3
end
when 0x785c # clearvoice_toggle
@clearvoice = !@clearvoice
when 0x7e80 # clearvoice_on
@clearvoice = true
when 0x7e82 # clearvoice_off
@clearvoice = false
when 0x786e # bass_ext_toggle
@bassext = !@bassext
when 0x786e # bass_ext_on
@bassext = true
when 0x786f # bass_ext_off
@bassext = false
when 0x784c # subwoofer_up
@subwoofer += 4 if @subwoofer <= (0x20 - 4)
when 0x784d # subwoofer_down
@subwoofer -= 4 if @subwoofer >= (0 + 4)
when 0x789c # mute_toggle
@muted = !@muted
when 0x7ea2 # mute_on
@muted = true
when 0x7ea3 # mute_off
@muted = false
when 0x781e # volume_up
@volume += 1 if @volume < 0x32
@muted = false
when 0x781f # volume_down
@volume -= 1 if @volume > 0
@muted = false
else
puts "! Invalid command: #{packet.inspect}."
end
else
case (packet[1]<<8) + packet[2]
when 0x787e # power_on
@power = true
when 0x787f # power_off
@power = false
@muted = false
else
puts "! Ignored command (when powered off): #{packet.inspect}."
end
end
else
puts "! Invalid packet: #{packet.inspect}."
end
end
private def enqueue(command)
cmd = YamahaPacketCodec.encode(command)
@queue.push([Time.now, cmd])
cmd
end
# Fetch next packet to be sent to device -- called by comm handler.
#
# @return [String, nil] packet that was enqueued, or `nil` when none
def pop
ts, payload = @queue.pop(true)
if $DEBUG || $VERBOSE
STDERR.puts "~ Dequeued #{payload.inspect} after #{"%.02f" % (Time.now - ts)}s."
end
payload
rescue ThreadError
nil
end
end
if __FILE__ == $0
STDOUT.sync = true
ysf = YamahaSoundbarFake.new
threads = []
devices = [
'/tmp/ttyS0-' + Digest::SHA1.hexdigest(File.open('/dev/urandom').read(8)),
'/tmp/ttyS1-' + Digest::SHA1.hexdigest(File.open('/dev/urandom').read(8))]
threads << Thread.new do
print "+ Spawning devices via socat...\n"
system("socat",
"pty,raw,echo=0,link=#{devices.first}",
"pty,raw,echo=0,link=#{devices.last}")
system("kill", "-INT", Process.pid.to_s)
end
threads << Thread.new do
sleep 1 # AMAZING synchronization technique. Mama would be proud.
YamahaSerialInputWorker.as_thread(devices.last, ysf)
end
puts "@ Connect at: #{devices.first}"
begin
threads.map(&:join)
rescue Interrupt
threads.map(&:kill)
end
end
I’m using it for further development roughly as follows:
Terminal 1
$ ruby server.rb
@ Connect at: /tmp/ttyS0-5f0f48be653b558e4ec208c7b04d68ff92b1d77c
+ Spawning devices via socat...
[...hangs waiting for ^C here...]
Terminal 2
$ CONTROL_DEVICE=$(ls /tmp/ttyS0-*) ruby -v control.rb
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]
+ BT handler init...
+ Webserver...
~ Dequeued "\xCC\xAA\t\x01HTS ContS" after 0.00s.
YamahaSerialInputWorker: Sending: ccaa090148545320436f6e7453
YamahaPacketCodec#streaming_decode: Received valid: 0400013219020a00
YamahaSerialInputWorker: Incoming packet: 0400013219020a00
~ Dequeued "\xCC\xAA\x03\x02\x00\x01\xFA" after 0.00s.
YamahaSerialInputWorker: Sending: ccaa03020001fa
[...]
> 127.0.0.1 /send 111
~ Dequeued "\xCC\xAA\x03@x~\xC7" after 0.05s.
YamahaSerialInputWorker: Sending: ccaa0340787ec7
~ Dequeued "\xCC\xAA\x03@x\x1E'" after 0.05s.
YamahaSerialInputWorker: Sending: ccaa0340781e27
~ Dequeued "\xCC\xAA\x03@x\x1E'" after 0.05s.
YamahaSerialInputWorker: Sending: ccaa0340781e27
~ Dequeued "\xCC\xAA\x03@x\x1E'" after 0.05s.
YamahaSerialInputWorker: Sending: ccaa0340781e27
~ Dequeued "\xCC\xAA\x03@x\x1E'" after 0.05s.
YamahaSerialInputWorker: Sending: ccaa0340781e27
[...]
~ Dequeued "\xCC\xAA\x02\x03\x05\xF6" after 0.00s.
YamahaSerialInputWorker: Sending: ccaa020305f6
YamahaPacketCodec#streaming_decode: Received valid: 05000100001610202000000a20
YamahaSerialInputWorker: Incoming packet: 05000100001610202000000a20
+ DS: power:true,input:hdmi,muted:false,volume:22,subwoofer:16,surround:tv,[...]
Terminal 3
$ curl localhost:8000/send -d 'commands=power_on,volume_up,volume_up,volume_up,volume_up'
sent power_on.
sent volume_up.
sent volume_up.
sent volume_up.
sent volume_up.
Closing words
That’s it for a basic client. And a simple “fake” implementation of the soundbar.
In the next installment (under the yas 207 tag) I’ll explore shairport-sync setup (and its interface with the final soundbar control script).
-
WEBrick based, in case you’re wondering. ↩
-
Which is a cheat. Or maybe the simplest way to get it working. You be the judge. :-) But it works reliably. For a few days, anyway. ↩
-
I omitted the
YamahaSerialInputWorker
here. (as it’s not relevant) Plus, you’d be crazy copy-pasting the code from a webpage when you cangit clone
it. ↩ -
So the snippet would be your
run
file. ↩ -
A pty, technically. But you don’t really care as long as it works, right? :-) ↩