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).

  1. WEBrick based, in case you’re wondering.

  2. 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.

  3. I omitted the YamahaSerialInputWorker here. (as it’s not relevant) Plus, you’d be crazy copy-pasting the code from a webpage when you can git clone it.

  4. So the snippet would be your run file.

  5. A pty, technically. But you don’t really care as long as it works, right? :-)