YAMAHAのAVアンプ RX-S601 を操作してみる

# 概要

YAMAHAのAVアンプ [RX-S601](https://jp.yamaha.com/products/audio_visual/av_receivers_amps/rx-s601/index.html) をAndroidの自作アプリから操作してみたので、その辺りの経緯について書いています。

# 背景

YAMAHAのAVアンプは、携帯端末から操作できるモバイルアプリが提供されています。

[AV CONTROLLER](https://play.google.com/store/apps/details?id=com.yamaha.av.avcontroller&hl=ja)

以前から個人でYAMAHAのAVアンプRX-S601を使用していたので、このアプリで操作していました。ただ、携帯端末の音楽をアンプのスピーカーから流そうと思った時に、入力ソースをBluetoothに切り替える必要があり、その都度アプリを開いて操作するのが面倒でした。
そこで、自分で入力ソースを切り替える仕組みが作れないかと思い、Androidアプリ上で実装してみた次第です。

# 通信内容の調査

とりあえず、APIの仕様がどこかに無いかと思ったのですが、特に公開はされていないようでした。GitHubにYAMAHAのAVアンプを操作するソースがいくつかあったのですが、自分の機種で使えるのか分からず、実際に通信の中身を見た方が早そうだったので、ツールで通信をキャプチャすることにしました。

今回は、[tPacketCapture](https://play.google.com/store/apps/details?id=jp.co.taosoftware.android.packetcapture)というアプリでキャプチャしました。保存されたデータは[Wireshark](https://www.wireshark.org/download.html)で直接見る事ができます。以下は実際のサンプル画像です。(AVアンプのIPアドレスは192.168.0.68に設定されています)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78503/9d81d918-30a8-632a-7ed3-46450c4918dd.png)


# Bluetoothの切替え操作

## 通信内容の確認

YAMAHAのアプリでBluetooth切替えした時のパケットをキャプチャしてみたところ、次のような通信をしていました(必要そうな箇所を抜粋)。

URL: http://[AVアンプのアドレス]/YamahaRemoteControl/ctrl
メソッド: POST

リクエストヘッダ(一部抜粋): 

  * CONTENT-TYPE: text/xml; charset="utf-8"
  * User-Agent: AV Controller/5.50 (Android)

リクエストデータ(整形済み):

```xml
<?xml version="1.0" encoding="utf-8" ?>
<YAMAHA_AV cmd="PUT">
    <Main_Zone>
        <Input>
            <Input_Sel>Bluetooth</Input_Sel>
        </Input>
    </Main_Zone>
</YAMAHA_AV>
```

レスポンスデータ (整形済み)

```xml
<YAMAHA_AV rsp="PUT" RC="0">
    <Main_Zone>
        <Input>
            <Input_Sel></Input_Sel>
        </Input>
    </Main_Zone>
</YAMAHA_AV>
```

HTTPSではなくHTTPなので、生データが見えました。パッと見たところ、リクエストデータのInput_Selの値が、切替える入力ソースを示しているようです。レスポンスデータは特に必要なデータは無さそうです。

上記の通信をCurlで実行してみます。

```bash
> curl -X POST -d '<YAMAHA_AV cmd="PUT"><Main_Zone><Input><Input_Sel>Bluetooth</Input_Sel></Input></Main_Zone></YAMAHA_AV>' 192.168.0.68/YamahaRemoteControl/ctrl
```
AVアンプの入力ソースがBluetoothに切り替わることが確認出来ました。ヘッダ情報は無くても動くようです。

# 現在の入力ソースを見る

次に現在の入力ソースを取得してみます。アプリを開いてデバイスを選択したとき、入力ソースが画面に表示されるので、このタイミングで状態を取得していると思われます。この時の通信内容を見ると、それらしい物がありました。

リクエストデータ(整形済み):

```xml
<?xml version="1.0" encoding="utf-8" ?>
<YAMAHA_AV cmd="GET">
    <Main_Zone>
        <Basic_Status>GetParam</Basic_Status>
    </Main_Zone>
</YAMAHA_AV>
```

レスポンスデータ (整形済み)

```xml
<YAMAHA_AV rsp="GET" RC="0">
    <Main_Zone>
        <Basic_Status>
            <Power_Control>
                <Power>On</Power>
                <Sleep>Off</Sleep>
            </Power_Control>
            <Volume>
                <Lvl>
                    <Val>-270</Val>
                    <Exp>1</Exp>
                    <Unit>dB</Unit>
                </Lvl>
                <Mute>Off</Mute>
                <Subwoofer_Trim>
                    <Val>30</Val>
                    <Exp>1</Exp>
                    <Unit>dB</Unit>
                </Subwoofer_Trim>
                <Scale>dB</Scale>
            </Volume>
            <Input>
                <Input_Sel>HDMI1</Input_Sel>
                <Input_Sel_Item_Info>
                    <Param>HDMI1</Param>
                    <RW>RW</RW>
                    <Title>HDMI1</Title>
                    <Icon>
                        <On>/YamahaRemoteControl/Icons/icon004.png</On>
                        <Off></Off>
                    </Icon>
                    <Src_Name></Src_Name>
                    <Src_Number>1</Src_Number>
                </Input_Sel_Item_Info>
            </Input>
            <Surround>
                <Program_Sel>
                    <Current>
                        <Straight>On</Straight>
                        <Enhancer>On</Enhancer>
                        <Sound_Program>Hall in Munich</Sound_Program>
                    </Current>
                </Program_Sel>
                <_3D_Cinema_DSP>Off</_3D_Cinema_DSP>
            </Surround>
            <Party_Info>Off</Party_Info>
            <Sound_Video>
                <Tone>
                    <Bass>
                        <Val>0</Val>
                        <Exp>1</Exp>
                        <Unit>dB</Unit>
                    </Bass>
                    <Treble>
                        <Val>0</Val>
                        <Exp>1</Exp>
                        <Unit>dB</Unit>
                    </Treble>
                </Tone>
                <Direct>
                    <Mode>Off</Mode>
                </Direct>
                <HDMI>
                    <Standby_Through_Info>On</Standby_Through_Info>
                    <Output>
                        <OUT_1>On</OUT_1>
                    </Output>
                </HDMI>
                <Extra_Bass>Off</Extra_Bass>
                <Adaptive_DRC>Auto</Adaptive_DRC>
            </Sound_Video>
        </Basic_Status>
    </Main_Zone>
</YAMAHA_AV>
```

ここで各種パラメータを一斉に取得しているようです。XMLの /YAMAHA_AV/Main_Zone/Basic_Status/Input/Input_Sel にある値が、現在の入力ソースを指し示しているようです。実際、入力ソースを切り替えてみると、ここの値が変化します。

上記をCurlで試すと以下のコマンドになります。実行すると、レスポンスが返ってくることが確認できます。

```xml
curl -X POST -d '<?xml version="1.0" encoding="utf-8" ?><YAMAHA_AV cmd="GET"> <Main_Zone><Basic_Status>GetParam</Basic_Status></Main_Zone></YAMAHA_AV>' 192.168.0.68/YamahaRemoteControl/ctrl
```

# 通信の実装

Androidアプリで、上記通信を行ってみます。例によって、OkHTTPとRetrofitの組合せで行いました。APIがJSONではなくXMLですので、XMLのコンバータがあった方が良さそうです。その辺りについては以前、[AndroidからXMLデータ形式のWeb APIを使う](https://qiita.com/wa2c/items/8b615387296711e544a8)に詳細を書いているので省略します。
とりあえず、リクエストのXMLをTikXMLのデータモデルを書くとこんな感じでしょうか。(面倒くさいので、汎用性を求めないならXMLを直書きした方が早い気がしますが。)

またHTTP通信なので、targetSdkVersionが28以上の場合、android:usesCleartextTraffic="true"を設定するか、network_security_config.xmlを設定するなどして、HTTPの通信を許可する必要があります。

```kotlin

@Xml(name = "YAMAHA_AV")
data class CtrlReq(
    @Attribute(name = "cmd")
    val cmd: String,
    @Element(name = "Main_Zone")
    val mainZone: MainZoneReq
)

@Xml
data class MainZoneReq(
    @Element(name = "Input")
    val input: InputReq? = null,
    @PropertyElement(name = "Basic_Status")
    val basicStatus: String? = null
)

@Xml
data class InputReq(
    /**
     * Select input ( HDMIx, BLUETOOTH, ... )
     */
    @PropertyElement(name = "Input_Sel")
    val inputSel: String? = null
)
```

レスポンスのデータモデルは長くなるので省略します。
インターフェースはこんな感じ。

```kotlin
interface YamahaRemoteControlApi {

    @POST("ctrl")
    suspend fun postCtrl(@Body ctrlReq: CtrlReq): CtrlRes

}
```

これにより、アプリからYAMAHAのAVアンプをコントロールすることが出来ました。

# YAMAHAのAVアンプを探す

YAMAHAのアプリでは、起動時にネットワーク内のAVアンプを探しに行っているようでした。これと同じ事ができないかと思い、YAMAHAのアプリ起動時の通信内容を見てみたところ、SSDPプロトコルのブロードキャスト通信が見つかりました。SSDPとは、UPnPで対応機器を探すためのプロトコルです([参考](http://e-words.jp/w/SSDP.html))。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78503/8f1c0377-e6ab-f820-37ad-9459dc92d5e0.png)
調べて見ると、実装自体はさほど難しくないですが、面倒なので外部のライブラリを利用します。今回利用したのはssdp-clientというライブラリになります。サイズが小さく、目的の機能を最小限の実装で実現できそうだったからです。

[resourcepool/ssdp\-client: A simple asynchronous Java SSDP \(Simple Service Discovery Protocol\) Client](https://github.com/resourcepool/ssdp-client)

実装としては以下のような感じになりました。

```kotlin
        val client: SsdpClient = SsdpClient.create()
        val networkStorageDevice = SsdpRequest.builder()
            .serviceType("urn:schemas-upnp-org:device:MediaRenderer:1")
            .build()
        client.discoverServices(networkStorageDevice, object : DiscoveryListener {
            override fun onFailed(e: Exception) {
            }

            override fun onServiceDiscovered(service: SsdpService) {
                val names = service.originalResponse.headers["X-MODELNAME"]?.split(":") ?: return
                val name = names.getOrNull(2) ?: names.getOrNull(0) ?: return
                val address = service.remoteIp.hostAddress
                // 取得した情報で何らかの処理を行う
                // ワーカースレッドなので、UI操作を行う場合は注意
            }

            override fun onServiceAnnouncement(announcement: SsdpServiceAnnouncement) {
            }
        })
```

YAMAHAの機器探索に使用するためのサービスタイプは"urn:schemas-upnp-org:device:MediaRenderer:1"となり、これはキャプチャしたパケットを見ると分かります。(なお、これはDLNAで用いられる汎用的なサービスタイプの模様なので、厳密にやるなら受信したデータに何らかのフィルタリングをかける必要はあるかと思います。)
機器が見つかると、onServiceDiscoveredで渡されるserviceパラメータでサービス情報が受け取れます。複数機器がある場合は、その回数分受信されるはずです。受信したデータを見ると、service.remoteIp.hostAddressに機器のIPアドレスが、service.originalResponse.headers["X-MODELNAME"]に機器名があるようです(少し加工する必要あり)。

こんな感じで、YAMAHAのAVアンプの情報を取得することができました。

# その他

## 2種類の通信

通信ログを追っていくと、YAMAHAのAVアンプに2種類の通信が存在するらしいことが分かります。
一つは、上記で挙げたXMLによる通信。URLは[AVアンプのアドレス]/YamahaRemoteControl で始まります。
もう一つは、JSONによる通信。URLは[AVアンプのアドレス]/YamahaExtendedControl/v1 で始まります。名前からして、XMLの方は昔からあるベーシックな命令で各機種共通、JSONの方は機種毎に差異のある拡張命令かな、という気がします。詳しいことは分からないですが、恐らく互換性を考慮したやや複雑な通信になっている模様です。

## 作ったアプリ

今回の調査を踏まえ、自分が作ったAndroidアプリは[こちら](https://github.com/wa2c/medoly-avcontrol-plugin)ですが、普通のアプリではないのでソースの参考程度に。

# 最後に

そんな感じで、YAMAHAのAVアンプのコントロールを行ってみました。今回使用した機器はRX-S601ですが、通信内容的に他の機器でも共通のような気がします。思ったより単純なプロトコルだったので、単一の機能だけを実現させるだけなら、実装はそれほど難しくなかったです。
今回はたまたま持っていたYAMAHAの機器でやってみましたが、他のメーカーの通信がどんな感じなのかは少し気になるところです。

<!--
## メタ情報

[AVアンプのアドレス]/YamahaRemoteControl/desc.xmlにアクセスすると、アンプ機能の情報と思しきデータが取れます。
-->