ESP32をActions on Google Homeの音楽プレイヤにする

M5Core2を「OK Google」から操作できる音楽プレイヤにします。
Google Smart Homeのデバイスタイプ「メディアリモコン」を実装します。
また、応答速度を速めるために、ローカルフルフィルメントに対応させます。

もろもろのソースコードは以下にあります。

poruruba/MusicSmartHome_Google

https://github.com/poruruba/MusicSmartHome_Google

全体的な流れを示します。
まずは、セットアップ時です。ユーザが、スマホのGoogleのHomeアプリから、デバイスのセットアップをするときのフローです。
ちなみに、後述する自作のスマートホームサーバの登録は済んでいる前提です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/231dd5b8-b669-04b9-33c9-dec6683db84a.png)

①セットアップ開始
　GoogleのHomeアプリから、デバイスのセットアップを開始します。

②ユーザ認証
　ユーザを認証します。OAuth2やOpenIDConnectで認識できるユーザであれば誰でもよいので、今回はLINEアカウント認証を使いました。

③SYNCインテント
　スマートホームサーバの登録時に指定したWebAPIに、SYNCインテントが飛んできますので、自身が管理する音楽再生デバイスの情報を返します。

④ローカルフルフィルメントアプリの登録
　スマートホームサーバ登録時に、ローカルフルフィルメントアプリを登録しておいたので、それがスマートホームデバイス（絵ではGoogle Nest Hub）に登録されます。

⑤セットアップ完了
　セットアップが完了し、以降Homeアプリから参照できるようになります。

⑥DiscoveryPacketのUDPパケット送受信
　セットアップ完了後は、Google Nest Hubは定期的に音楽再生デバイスが存在するかをDiscoveryPacketの送信と応答で確認します。

次は、セットアップ完了後の流れです。「OK Google、サウンドバーで次の曲を再生」などの操作です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/2b6b3122-619d-3737-1284-c6db0afdb457.png)

①OK Google
　ユーザは、Google Nest Hubに対して、発話します。

②発話
　発話の内容がActions on Googleに送信されます。

③QUERY/EXECUTEインテント
　Actions on Googleは発話の内容を解釈し、QUERYまたはEXECUTEインテントを自作のスマートホームサーバにWebAPI呼び出しします。

④QUERY/EXECUTEのUDPパケット
　自作のスマートホームサーバは、その内容をUDPで音楽再生デバイスに転送し、音楽再生デバイスはその内容に応じて音楽を再生し、UDPパケットで応答します。自作のスマートホームサーバはその応答をActions on Googleに戻します。

ReportStateは、音楽再生デバイスの状態が変わったときに、自作のスマートホームサーバを経由して、Google Homeに通知することで、スマホのHomeアプリから状態が変わったことを確認できるようになります。

上記は、DiscoveryPacketのUDPパケットでGoogle Nest Hubが音楽再生デバイスを発見するまでの流れです。発見した後は、Actions on Googleや自作のスマートホームサーバを介さずに、直接音楽再生デバイスを操作します。これがローカルフルフィルメントです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8cdd3082-e49e-b2ee-86e5-687f75b57a61.png)

①OK Google
　ユーザは、Google Nest Hubに対して、発話します。

④QUERY/EXECUTEのUDPパケット
　登録済みのローカルフルフィルメントアプリは、その内容をUDPで音楽再生デバイスに転送し、音楽再生デバイスはその内容に応じて音楽を再生し、UDPパケットで応答します。

# 自作のスマートホームサーバの登録

後述すると言っていた、自作のスマートホームサーバの登録の説明をします。
と言っても、以前に投稿した内容と同じなので、サマリだけ。

[ESP32をGoogle Smart Homeデバイスにする](https://qiita.com/poruruba/items/337722ef13cf44986b37)

デベロッパコンソールから、プロジェクトを作成します。

Actions on Google Developer Console
　http://console.actions.google.com/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/05e942e9-e7ff-abe0-ee98-b1fe553e83c5.png)

スマートホームを選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/eb50fc00-4dae-4b54-60d9-097b7d7fb2ad.png)

呼びやすい名前を入れます。例えば、「ESP32スマートホーム」とか。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/928752b5-9d99-9941-db4f-b9e53f2a9e96.png)

左側のナビゲーションメニューからActionsを選択し、Fullfillment URLに自作のスマートホームサーバのURLを指定します。

　https://[立ち上げるNode.jsサーバのURL]/smarthome_google

Configure local home SDK (optional)は後で指定するので、とりあえずなにも設定しないで行きましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/039fe719-df33-885d-4655-26e24775bed4.png)

左側のナビゲーションメニューからAccount Linkingを選択し、今回はLINEサーバを指定します。

Client ID issued by your Actions on Google：LINEのChannel ID
Client secret：LINEのChannel secret
Authorization URL：https://access.line.me/oauth2/v2.1/authorize
Token URL：https://api.line.me/oauth2/v2.1/token
Configure your client (optional)：profile、openid、email等、お好みで

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f7db8760-ffa1-00e7-e43d-d3876f26307f.png)

LINEの情報は、以下のURLから。

LINE Developers

https://developers.line.biz/console/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/53fdadff-d3da-47a8-60b8-f42597a01102.png)

LINEのCallback URLに以下を追加します。

　https://oauth-redirect.googleusercontent.com/r/[GoogleHomeのプロジェクトID]

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f42e2d27-fb30-dee1-b1f1-96995aaefe62.png)

GoogleHomeのプロジェクト名は、Actions on Google Developer Consoleの右上のメニュー「Project settings」から確認できます。

HomeGraph APIを有効にします。

HomeGraph API

https://console.cloud.google.com/apis/library/homegraph.googleapis.com


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a511e7bd-06b8-3275-3586-ec539b19499c.png)

サービスアカウントのクレデンシャル情報を発行します。

https://console.cloud.google.com/apis/credentials

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/6b9e2c85-68d4-47f2-afde-3838838b38bd.png)

[GoogleHomeプロジェクト名]-XXXXXXXXXXXX.json ってな感じのクレデンシャルファイルを生成します。

# スマートホームサーバを立ち上げる

GitHubからZIPファイルをダウンロードします。

```
> unzip MusicSmartHome_Google-master.zip
> cd MusicSmartHome_Google-master/nodejs
> npm install
```

大事なのは以下のファイルです。

```js:/nodejs\api\controllers\smarthome_google/index.js
'use strict';

const HELPER_BASE = process.env.HELPER_BASE || "/opt/";
const Response = require(HELPER_BASE + 'response');

const UDP_DEVICE_ADDRESS = '192.168.1.255';
const UDP_DEVICE_PORT = 3311;
const UDP_RECV_PORT = 3312;
const UDP_REPORTSTATE_PORT = 3314;
const JWT_FILE_PATH = './keys/[クレデンシャルファイル名]';

const jwt_decode = require('jwt-decode');
const {smarthome} = require('actions-on-google');

const jwt = require(JWT_FILE_PATH);
const app = smarthome({
  jwt: jwt
});

const dgram = require('dgram');
const UdpComRes = require('./UdpComRes');
var udpcomres = new UdpComRes(UDP_RECV_PORT, true);

var requestId = 0;

var agentUserId = process.env.DEFAULT_USER_ID || "user01";

const onsync = require('./onsync.json');

app.onSync((body, headers) => {
  console.info('onSync');
  console.log('onSync body', body);
  console.log(headers);

//  var decoded = jwt_decode(headers.authorization);
//  console.log(decoded);

  var result = {
    requestId: body.requestId,
    payload: {
      agentUserId: agentUserId,
      devices: []
    }
  };

  onsync.forEach((item) =>{
    result.payload.devices.push(item);
  });
  console.log(JSON.stringify(result, null, '\t'));

  console.log("onSync result", result);
  return result;
});

app.onQuery(async (body, headers) => {
  console.info('onQuery');
  console.log('onQuery body', body);

//  var decoded = jwt_decode(headers.authorization);
//  console.log(decoded);

  const {requestId} = body;
  const payload = {
    devices: {}
  };

  for( var i = 0 ; i < body.inputs.length ; i++ ){
    if( body.inputs[i].intent == 'action.devices.QUERY' ){
      for( var j = 0 ; j < body.inputs[i].payload.devices.length ; j++ ){
        var device = body.inputs[i].payload.devices[j];
        var params = {
          intent: 'action.devices.QUERY',
          device_id: device.id
        };
        try{
          var message = await udpcomres.transceive(params, UDP_DEVICE_PORT, UDP_DEVICE_ADDRESS);
          payload.devices[device.id] = message.states;
          payload.devices[device.id].status = "SUCCESS";
        }catch(error){
          console.error(error);
        }
      }
    }
  }

  var result = {
    requestId: requestId,
    payload: payload,
  };

  console.log("onQuery result", JSON.stringify(result));
  return result;
});

app.onExecute(async (body, headers) => {
  console.info('onExecute');
  console.log('onExecute body', JSON.stringify(body));

//  var decoded = jwt_decode(headers.authorization);
//  console.log(decoded);
  
  const {requestId} = body;

  // Execution results are grouped by status
  var ret = {
    requestId: requestId,
    payload: {
      commands: [],
    },
  };
  for( var i = 0 ; i < body.inputs.length ; i++ ){
    if( body.inputs[i].intent == "action.devices.EXECUTE" ){
      for( var j = 0 ; j < body.inputs[i].payload.commands.length ; j++ ){
        var result = {
          ids:[],
          status: 'SUCCESS',
        };
        ret.payload.commands.push(result);
        var devices = body.inputs[i].payload.commands[j].devices;
        var execution = body.inputs[i].payload.commands[j].execution;
        for( var k = 0 ; k < execution.length ; k++ ){
          for( var l = 0 ; l < devices.length ; l++ ){
            var params = {
              intent: 'action.devices.EXECUTE',
              device_id: devices[l].id,
              command: execution[k].command,
              params: execution[k].params 
            };
            try{
              var message = await udpcomres.transceive(params, UDP_DEVICE_PORT, UDP_DEVICE_ADDRESS);
              result.ids.push(devices[l].id);
              result.states = message.states;

              await reportState(devices[l].id, message.states);
            }catch(error){
              console.error(error);
            }
          }
        }
      }
    }
  }

  console.log("onExecute result", JSON.stringify(ret));
  return ret;
});

app.onDisconnect((body, headers) => {
  console.info('onDisconnect');
  console.log('body', body);

//  var decoded = jwt_decode(headers.authorization);
//  console.log(decoded);

  // Return empty response
  return {};
});

exports.fulfillment = app;

async function reportState(id, states){
  var state = {
    requestId: String(++requestId),
    agentUserId: agentUserId,
    payload: {
      devices: {
        states:{
          [id]: states
        }
      }
    }
  };

  console.log("reportstate", JSON.stringify(state));
  await app.reportState(state);

  return state;
} 

const udpSocket = dgram.createSocket('udp4');

udpSocket.on('listening', () => {
  const address = udpSocket.address();
  console.log('UDP udpSocket listening on ' + address.address + ":" + address.port);
});
udpSocket.on('error', (err) => {
  console.error(`UDP Server error: ${err.message}`);
});

udpSocket.on('message', async (message, remote) => {
	var body = JSON.parse(message);
  console.log(JSON.stringify(body));
  var res = await reportState(body.payload.device_id, body.payload.states);
  console.log(res);
});

udpSocket.bind(UDP_REPORTSTATE_PORT);
```

以下の部分を書き換えます。

const UDP_DEVICE_ADDRESS = '[音楽再生デバイスを含むブロードキャストIPアドレス]';
const UDP_DEVICE_PORT = [音楽再生デバイスのUDPポート番号];
const UDP_RECV_PORT = [スマートホームサーバが待ち受けるUDPポート番号];
const UDP_REPORTSTATE_PORT = [スマートホームサーバがReportStateを待ち受けるUDPポート番号];
const JWT_FILE_PATH = './keys/[クレデンシャルファイル名]';

また、必要に応じて、以下を書き換えます。SYNCインテントに対して返答する内容です。

```json: nodejs\api\controllers\smarthome_google/onsync.json
[
  {
    "id": "soundbar",
    "type": "action.devices.types.REMOTECONTROL",
    "traits": [
      "action.devices.traits.OnOff",
      "action.devices.traits.MediaState",
      "action.devices.traits.TransportControl",
      "action.devices.traits.Volume"
    ],
    "name": {
      "name": "サウンドバー"
    },
    "deviceInfo": {
      "manufacturer": "MyHome Devices"
    },
    "willReportState": true,
    "attributes": {
      "transportControlSupportedCommands":[
        "RESUME",
        "PAUSE",
        "NEXT",
        "STOP"
      ],
      "volumeMaxLevel": 100,
      "levelStepSize": 5,
      "volumeCanMuteAndUnmute": true,
      "supportActivityState": true,
      "supportPlaybackState": true
    },
    "otherDeviceIds": [{
      "deviceId": "deviceid123"
    }]
  }
]
```

〇HTTPSで立ち上げられるようにする

certフォルダを作成しSSL証明書を格納するか、ngrokを仲介させます。

WebAPIを待ち受けるポート番号は、.envファイルを作成してそこに明記します。

PORT=50080
SPORT=50443

Node.jsサーバを立ち上げます。

```
> node app.js
```

# ESP32を音楽再生デバイスにする

ソースコードです。

```cpp:martHomeDevice\src\main.cpp
#include <Arduino.h>
#include <M5Core2.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <ArduinoJson.h>
#include <SD.h>
#include <driver/i2s.h>
#include <AudioGeneratorMP3.h>
#include <AudioOutputI2S.h>
#include <AudioFileSourceID3.h>
#include <AudioFileSourceSD.h>

#define DEFAULT_AUDIO_GAIN  10.0
#define MUTE_AUDIO_GAIN     2.0

#define LISTEN_PORT 3311
#define REPORT_HOST "[Node.jsサーバのURL]"
#define REPORT_PORT 3314

#define AUDIO_BASE_FOLDER  "/Music"

#define DISCOVERY_PACKET  "HelloLocalHomeSDK"
#define DEFAULT_DEVICE_ID "soundbar"
#define LOCAL_DEVICE_ID   "deviceid123"

#define WIFI_SSID NULL // WiFiアクセスポイントのSSID
#define WIFI_PASSWORD NULL // WiFIアクセスポイントのパスワード
#define WIFI_TIMEOUT  10000
#define SERIAL_TIMEOUT1  10000
#define SERIAL_TIMEOUT2  20000

#define JSONDOC_BUFFER_SIZE 2048
StaticJsonDocument<JSONDOC_BUFFER_SIZE> jsonDoc;

static WiFiUDP udp;

static AudioOutputI2S *out = NULL;
static AudioGeneratorMP3 *mp3 = NULL;
static AudioFileSourceSD *file_sd = NULL;
static AudioFileSourceID3 *id3 = NULL;
static float g_audio_gain = DEFAULT_AUDIO_GAIN;
static bool g_audio_muted = false;
static int g_audio_index = -1;

typedef enum{
  STANDBY,
  STOPPED,
  PLAYING,
  PAUSED
} AUDIO_STATE;
static AUDIO_STATE g_audio_state = STANDBY;

static long m5_initialize(void);
long udpSend(IPAddress host, uint16_t port);
long udpOnOffReport(bool onoff, const char *p_device_id);
long processUdpPacket(const char *p_message);

long audio_initialize(void)
{
  out = new AudioOutputI2S(I2S_NUM_0, AudioOutputI2S::EXTERNAL_I2S);
  out->SetOutputModeMono(true);
  out->SetPinout(12, 0, 2);
  out->SetGain(g_audio_gain / 100.0);

  return 0;
}

long audio_stopMp3(void)
{
  if( mp3 != NULL )
    mp3->stop();
  if( id3 != NULL ){
    id3->RegisterMetadataCB(NULL, NULL);
    id3->close();
  }
  if( file_sd != NULL )
    file_sd->close();

  if( mp3 != NULL ){
    delete mp3;
    mp3 = NULL;
  }
  if( id3 != NULL ){
    delete id3;
    id3 = NULL;
  }
  if( file_sd != NULL ){
    delete file_sd;
    file_sd = NULL;
  }

  g_audio_state = STOPPED;

  return 0;
}

long audio_onoff(bool onoff)
{
  if( !onoff ){
    if( g_audio_state != STANDBY ){
      audio_stopMp3();
      g_audio_state = STANDBY;
      g_audio_muted = false;
    }
  }else{
    if( g_audio_state == STANDBY )
      g_audio_state = STOPPED;
  }

  return 0;
}

long audio_updateGain(void){
  if( g_audio_gain < 0 ) g_audio_gain = 0;
  if( g_audio_gain > 100 ) g_audio_gain = 100;

  if( g_audio_state == PAUSED ){
    out->SetGain(0 / 100.0);
    out->flush();
  }else if( g_audio_muted ){
    out->SetGain(MUTE_AUDIO_GAIN / 100.0);
  }else{
    out->SetGain(g_audio_gain / 100.0);
  }

  return 0;
}

void audio_MDCallback(void *cbData, const char *type, bool isUnicode, const char *string)
{
  (void)cbData;
  if (string[0] == '\0') { return; }
  if (strcmp(type, "eof") == 0){
    Serial.println("ID3: eof");
    return;
  }
  Serial.printf("ID3: %s: %s\n", type, string);
}

long audio_playMp3(const char *p_fname, bool reset_gain = false)
{
  audio_stopMp3();

  file_sd = new AudioFileSourceSD(p_fname);
  if( !file_sd->isOpen() ){
    delete file_sd;
    file_sd = NULL;
    return -1;
  }

  audio_onoff(true);
  id3 = new AudioFileSourceID3(file_sd);
  id3->RegisterMetadataCB(audio_MDCallback, (void*)"ID3TAG");
  mp3 = new AudioGeneratorMP3();
  g_audio_state = PLAYING;
  mp3->begin(file_sd, out);

  return 0;
}

long audio_playNextMp3(bool next){
  File base = SD.open(AUDIO_BASE_FOLDER);
  if( !base )
    return -1;

  long ret;
  File file;
  int num_of_sound = 0;
  for( int i = 0 ; file = base.openNextFile(); i++ ){
    num_of_sound++;
  }

  if( !next ){
    g_audio_index--;
    if( g_audio_index < 0 ) g_audio_index = num_of_sound -1;
  }else{
    g_audio_index++;
    if( g_audio_index >= num_of_sound ) g_audio_index = 0;
  }

  base.rewindDirectory();
  for( int i = 0 ; file = base.openNextFile(); i++ ){
    if( g_audio_index == i ){
      ret = audio_playMp3(file.name());
      file.close();
      base.close();
      return ret;
    }
    file.close();
  }

  return -1;
}

void audio_loop(void)
{
  if( mp3 != NULL ){
    if (mp3->isRunning()) {
      if( g_audio_state == PLAYING ){
        if (!mp3->loop()){
          audio_playNextMp3(true);
//          mp3->stop();
//          g_audio_state = STOPPED;
        }
      }
    }
  }
}

void setup() {
  // put your setup code here, to run once:

  long ret = m5_initialize();
  if( ret != 0 )
    Serial.println("m5_initialize error");

  udp.begin(LISTEN_PORT);

  ret = audio_initialize();
  if( ret != 0 )
    Serial.println("audio_initialize error");

  Serial.println("setup finished");
}

void loop() {
  // put your main code here, to run repeatedly:
  audio_loop();
  M5.update();

  while(true){
    int packetSize = udp.parsePacket();
    if( packetSize > 0 ){
      char *p_buffer = (char*)malloc(packetSize + 1);
      if( p_buffer == NULL )
        break;
      
      int len = udp.read(p_buffer, packetSize);
      if( len <= 0 ){
        free(p_buffer);
        break;
      }
      p_buffer[len] = '\0';

      Serial.println(p_buffer);
      long ret = processUdpPacket(p_buffer);
      free(p_buffer);
      if( ret != 0 )
        Serial.println("processUdpPacket error"); 
    }
    break;
  }

  if( M5.BtnA.wasPressed() ){
    if( g_audio_state == PAUSED ){
      g_audio_state = PLAYING;
      audio_updateGain();
    }else{
      if( g_audio_state == STANDBY )
        udpOnOffReport(true, DEFAULT_DEVICE_ID);
      audio_playNextMp3(false);
    }
  }else if( M5.BtnB.wasPressed() ){
    if( g_audio_state == PAUSED ){
      g_audio_state = PLAYING;
      audio_updateGain();
    }else if( g_audio_state == PLAYING ){
      g_audio_state = PAUSED;
      audio_updateGain();
    }else{
      if( g_audio_state == STANDBY )
        udpOnOffReport(true, DEFAULT_DEVICE_ID);
      audio_playNextMp3(true);
    }
  }else if( M5.BtnC.wasPressed() ){
    if( g_audio_state == PAUSED ){
      g_audio_state = PLAYING;
      audio_updateGain();
    }else{
      if( g_audio_state == STANDBY )
        udpOnOffReport(true, DEFAULT_DEVICE_ID);
      audio_playNextMp3(true);
    }
  }

  delay(1);
}

static long wifi_connect(const char *ssid, const char *password, unsigned long timeout)
{
  Serial.println("");
  Serial.print("WiFi Connenting");

  if( ssid == NULL && password == NULL )
    WiFi.begin();
  else
    WiFi.begin(ssid, password);
  unsigned long past = 0;
  while (WiFi.status() != WL_CONNECTED){
    Serial.print(".");
    delay(500);
    past += 500;
    if( past > timeout ){
      Serial.println("\nCan't Connect");
      return -1;
    }
  }
  Serial.print("\nConnected : IP=");
  Serial.print(WiFi.localIP());
  Serial.print(" Mac=");
  Serial.println(WiFi.macAddress());

  return 0;
}

static long m5_initialize(void)
{
  M5.begin(true, true, true, true);
  M5.Axp.SetSpkEnable(true);

//  Serial.begin(115200);
  Serial.println("[initializing]");

  long ret = wifi_connect(WIFI_SSID, WIFI_PASSWORD, WIFI_TIMEOUT);
  if( ret != 0 ){
    char ssid[32 + 1] = {'\0'};
    Serial.print("\ninput SSID:");
    Serial.setTimeout(SERIAL_TIMEOUT1);
    ret = Serial.readBytesUntil('\r', ssid, sizeof(ssid) - 1);
    if( ret <= 0 )
      return -1;

    delay(10);
    Serial.read();
    char password[32 + 1] = {'\0'};
    Serial.print("\ninput PASSWORD:");
    Serial.setTimeout(SERIAL_TIMEOUT2);
    ret = Serial.readBytesUntil('\r', password, sizeof(password) - 1);
    if( ret <= 0 )
      return -1;

    delay(10);
    Serial.read();
    Serial.printf("\nSSID=%s PASSWORD=", ssid);
    for( int i = 0 ; i < strlen(password); i++ )
      Serial.print("*");
    Serial.println("");
    ret = wifi_connect(ssid, password, WIFI_TIMEOUT);
    if( ret != 0 )
      return ret;
  }

  return 0;
}

long processUdpPacket(const char *p_message)
{
  if( strcmp(p_message, DISCOVERY_PACKET ) == 0 ){
      jsonDoc.clear();
      jsonDoc["device_id"] = DEFAULT_DEVICE_ID;
      jsonDoc["local_device_id"] = LOCAL_DEVICE_ID;

      long ret = udpSend(udp.remoteIP(), udp.remotePort());
      if( ret != 0 )
        return ret;
  }else if( strcmp(p_message, "CloseCompanion" ) == 0 ){
    // do nothing
  }else{
    DeserializationError err = deserializeJson(jsonDoc, p_message);
    if (err) {
      Serial.print(F("deserializeJson() failed with code "));
      Serial.println(err.f_str());
      return -1;
    }

    const char *intent = jsonDoc["payload"]["intent"];
    Serial.println(intent);
    const uint32_t msgId = jsonDoc["msgId"];
    if( strcmp( intent, "action.devices.QUERY") == 0 ){
      const char *p_device_id = jsonDoc["payload"]["device_id"];
      Serial.println(p_device_id);
      String device_id(p_device_id);

      if( strcmp(p_device_id, DEFAULT_DEVICE_ID) == 0 ){
        jsonDoc.clear();
        jsonDoc["msgId"] = msgId;
        jsonDoc["payload"]["device_id"] = device_id.c_str();
        jsonDoc["payload"]["states"]["on"] = (g_audio_state != STANDBY);
        jsonDoc["payload"]["states"]["activityState"] = (g_audio_state == STANDBY) ? "STANDBY" : "ACTIVE";
        jsonDoc["payload"]["states"]["playbackState"] = (g_audio_state == PAUSED) ? "PAUSED" : ((g_audio_state == PLAYING) ? "PLAYING" : "STOPPED");
        jsonDoc["payload"]["states"]["isMuted"] = g_audio_muted;
        jsonDoc["payload"]["states"]["currentVolume"] = (int)g_audio_gain;
        jsonDoc["payload"]["states"]["online"] = true;

        long ret = udpSend(udp.remoteIP(), udp.remotePort());
        if( ret != 0 )
          return ret;
      }else{
        Serial.printf("Unknown device_id: %s\n", p_device_id);
      }
    }else if( strcmp( intent, "action.devices.EXECUTE") == 0 ){
      const char *p_device_id = jsonDoc["payload"]["device_id"];
      Serial.println(p_device_id);
      String device_id(p_device_id);

      const char *p_command = jsonDoc["payload"]["command"];
      Serial.println(p_command);

      if( strcmp(p_device_id, DEFAULT_DEVICE_ID) == 0 ){
        if( strcmp(p_command, "action.devices.commands.OnOff") == 0 ){
          bool onoff = jsonDoc["payload"]["params"]["on"];
          audio_onoff(onoff);
          if( onoff )
            audio_playNextMp3(true);

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["on"] = onoff;
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
          ret = udpOnOffReport(onoff, device_id.c_str());
          if( ret != 0 )
            return ret;
        }else
        if( strcmp(p_command, "action.devices.commands.volumeRelative") == 0 ){
          int relativeSteps = jsonDoc["payload"]["params"]["relativeSteps"];
          g_audio_gain += relativeSteps;
          g_audio_muted = false;
          audio_updateGain();

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["currentVolume"] = (int)g_audio_gain;
          jsonDoc["payload"]["states"]["isMuted"] = g_audio_muted;
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
        }else
        if( strcmp(p_command, "action.devices.commands.setVolume") == 0 ){
          int volumeLevel = jsonDoc["payload"]["params"]["volumeLevel"];
          g_audio_gain = volumeLevel;
          g_audio_muted = false;
          audio_updateGain();

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["currentVolume"] = (int)g_audio_gain;
          jsonDoc["payload"]["states"]["isMuted"] = g_audio_muted;
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
        }else
        if( strcmp(p_command, "action.devices.commands.mute") == 0 ){
          bool mute = jsonDoc["payload"]["params"]["mute"];
          g_audio_muted = mute;
          audio_updateGain();

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["currentVolume"] = (int)g_audio_gain;
          jsonDoc["payload"]["states"]["isMuted"] = g_audio_muted;
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
        }else
        if( strcmp(p_command, "action.devices.commands.mediaResume") == 0 ){
          if( g_audio_state == PAUSED ){
            g_audio_state = PLAYING;
            audio_updateGain();
          }else{
            audio_playNextMp3(true);
          }

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
        }else
        if( strcmp(p_command, "action.devices.commands.mediaNext") == 0 ){
          if( g_audio_state == PAUSED ){
            g_audio_state = PLAYING;
            audio_updateGain();
          }
          audio_playNextMp3(true);

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
        }else
        if( strcmp(p_command, "action.devices.commands.mediaPrevious") == 0 ){
          if( g_audio_state == PAUSED ){
            g_audio_state = PLAYING;
            audio_updateGain();
          }
          audio_playNextMp3(false);

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
        }else
        if( strcmp(p_command, "action.devices.commands.mediaPause") == 0 ){
          if( g_audio_state == PLAYING ){
            g_audio_state = PAUSED;
            audio_updateGain();
          }

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
        }else
        if( strcmp(p_command, "action.devices.commands.mediaStop") == 0 ){
          audio_stopMp3();

          jsonDoc.clear();
          jsonDoc["msgId"] = msgId;
          jsonDoc["payload"]["device_id"] = device_id.c_str();
          jsonDoc["payload"]["states"]["online"] = true;
          long ret = udpSend(udp.remoteIP(), udp.remotePort());
          if( ret != 0 )
            return ret;
        }
      }else{
        Serial.printf("Unknown device_id: %s\n", p_device_id);
      }
    }else{
      Serial.printf("Unknown Intent: %s\n", intent);
    }
  }

  return 0;
}

long udpSend(IPAddress ipaddress, uint16_t port)
{
  int len = measureJson(jsonDoc);
  char *p_buffer = (char*)malloc(len + 1);
  if( p_buffer == NULL )
    return -1;
  int wroteLen = serializeJson(jsonDoc, p_buffer, len);
  p_buffer[wroteLen] = '\0';
  Serial.printf("response: %s\n\n", p_buffer);

  udp.beginPacket(ipaddress, port);
  udp.write((const uint8_t*)p_buffer, wroteLen);
  udp.endPacket();

  free(p_buffer);

  return 0;
}

long udpOnOffReport(bool onoff, const char *p_device_id)
{
  jsonDoc.clear();
  jsonDoc["msgId"] = 0;
  jsonDoc["payload"]["device_id"] = p_device_id;
  jsonDoc["payload"]["states"]["on"] = onoff;

  int len = measureJson(jsonDoc);
  char *p_buffer = (char*)malloc(len + 1);
  if( p_buffer == NULL )
    return -1;
  int wroteLen = serializeJson(jsonDoc, p_buffer, len);
  p_buffer[wroteLen] = '\0';
  Serial.printf("report: %s\n\n", p_buffer);

  udp.beginPacket(REPORT_HOST, REPORT_PORT);
  udp.write((const uint8_t*)p_buffer, wroteLen);
  udp.endPacket();

  free(p_buffer);

  return 0;
}
```

以下の部分を書き換えます。

#define LISTEN_PORT [待ち受けるUDPポート番号]
#define REPORT_HOST "[Node.jsサーバのURL]"
#define REPORT_PORT [Node.jsサーバが待ち受けるUDPポート番号]

さらに、以下も書き換えます。

#define DISCOVERY_PACKET  "HelloLocalHomeSDK"
#define DEFAULT_DEVICE_ID "soundbar"
#define LOCAL_DEVICE_ID   "deviceid123"

DISCOVERY_PACKETの内容は自由ですが、後述する値と同じにする必要があります。
DEFAULT_DEVICE_IDとLOCAL_DEVICE_IDの内容は自由ですが、onsync.jsのそれぞれid、deviceIdに指定した値と同じにする必要があります。

また、M5Core2に差し込むmicroSDカードの/Musicフォルダに再生したい複数のMP3ファイルを書き込んでおきます。

これで完成です。
Homeアプリから、デバイス登録しましょう。[test] ESP32スマートホーム というのが出てきているはずです。

# ローカルフルフィルメントに対応する

以下のように実行します。

```
cd localfulfillment
npm install
```

以下の部分を書き換えます。

const UDP_DEVICE_PORT = [音楽再生デバイスのmain.cppに指定した待ち受けるUDPポート番号];

以下を実行します。

```
npm run build
```

そうすると、publicフォルダに、index.htmlとbundle.jsが出来上がっています。
作成したbundle.jsを以下の「Upload Java script files」ボタンから登録します。Node.jsとJavascriptの2つを登録するのですが、両方同じものを登録しました。また、「Support local query」にチェックを入れます。

また、「+ New scan config」ボタンをクリックして、以下の情報を入力します。

Broadcast address：音楽再生デバイスを含むブロードキャストIPアドレス
Discovery packet：音楽再生デバイスのmain.cppに指定したDiscovery Packet
Listen port： 自由。例えば、スマートホームサーバのindex.jsに指定した待ち受けるUDPポート番号と同じ値
Broadcast port：音楽再生デバイスのmain.cppに指定した待ち受けるUDPポート番号

 

これで完成です。
再度、[test] ESP32スマートホームを削除して、再登録します。念のため、Google Nest Hubを再起動するとよいかと思います。
そうすると、特に使う側から見て違いは分からないですが、自作のスマートホームサーバを介さずに、Google Nest Hubと音楽再生デバイスが直接通信して音楽再生をコントロールするようになります。（Google Nest Hubが音楽制裁デバイスを認識するのに、少し時間がかかります）

# 終わりに

かなり駆け足で投稿を書きました。
ポート番号の指定などややこしいです。不明な場合や抜け漏れは加筆するのでお知らせください。

以上