前回の「Nest MiniでLocal Home SDKを使ってお家のIoTロボットを操作する 第2回」では、コードラボの「Smart Home Local Execution」をベースにクラウド経由での実行まで確認しました。
今回は、ローカル実行をできるようにしてみようと思います。

クラウドフルフィルメントを更新する

ローカル実行をサポートするには、otherDeviceIdsフィールドを追加する必要があります。「smarthome-local/app-start/functions」ディレクトリの 「index.js」に追加します。
「app.onSync」メソッドの 「// TODO: Add otherDeviceIds for local execution」の箇所に「otherDeviceIds」を追記します。

app.onSync((body) => {
  return {
      ...
        // TODO: Add otherDeviceIds for local execution
        otherDeviceIds: [{
          deviceId: 'deviceid123',
        }],
      ...
  };
});

下記コマンドを実行してデプロイします。

firebase deploy --only functions

Web UIを開いていたら、「Smart Home Codelab」の右側の更新アイコンを押下してください。

ローカル実行を構成する

ローカル実行アプリをFirebase Hostingに公開し、Google Homeデバイス(Nest Miniなど)がアクセスしてダウンロードできるようにします。

Actionsコンソールにアクセスし、「piccorobo-iot」プロジェクトを選択し、 「Test > On device testing」を選択します。
「Development server URL」に、「https://<project-id>.firebaseapp.com/local-home/index.html」を入力します。
これは、以降でローカルホームアプリを公開するURLになります。
「<project-id>」は自分のプロジェクトIDに置き換えてください。

入力したら、上部の「Save」ボタンを押下してください。

次にGoogle Homeデバイス(Nest Mini)が、ローカルスマートデバイス(ピッコロボIoT)を検出する方法を定義します。
「Develop > Actions」を選択します。

「Configure local home SDK」に下記項目を設定していきます。

フィールド説明設定値
UDP discovery address UDPブロードキャストアドレス 255.255.255.255
UDP discovery port out Google HomeデバイスがUDPブロードキャストを送信するポート 3311
UDP discovery port in Google Homeデバイスが応答のためにリッスンするポート 3312
UDP discovery packet UDPブロードキャストデータペイロード。(ここでは「HelloLocalHomeSDK」という文字列の16進エンコードデータとしている) 48656c6c6f4c6f63616c486f6d6553444b

フィールドは、ドロップダウンボックスの一覧から選択することができます。

全部入力すると、このようになります。
入力が終わったら、上部の「Save」ボタンを押下してください。

ローカル実行を実装する

「app-start/local」ディレクトリにある、 「index.ts」を編集していきます。
「app-start/local」ディレクトリは、インテントハンドラを持つローカル実行アプリプロジェクトになります。

Local Home SDKでは、Google Homeデバイス(Nest Mini)が、ローカルネットワーク上の未検証のデバイスを検出すると、IDENTIFYハンドラをトリガーします。
Google Homeデバイス(Nest Mini)にコマンドが送信されると、EXECUTEハンドラをトリガーします。

そのため、IDENTIFYハンドラとEXECUTEハンドラを 「index.ts」に実装します。
IDENTIFYハンドラは、identifyHandlerの「// TODO: Implement device identification」の箇所に、EXECUTEハンドラは、executeHandlerの「// TODO: Implement local execution」の箇所に実装します。

class LocalExecutionApp {

  constructor(private readonly app: App) { }

  identifyHandler(request: IntentFlow.IdentifyRequest):
      Promise<IntentFlow.IdentifyResponse> {
    // TODO: Implement device identification
  }

  executeHandler(request: IntentFlow.ExecuteRequest):
      Promise<IntentFlow.ExecuteResponse> {
    // TODO: Implement local execution
  }

  ...
}

2019/11/20にLocal Home SDKが更新されました。identifyHandlerのリクエストデータの構造が変わっています。
詳細はLocal Home SDKのCHANGELOG.mdに記載があります。
コードラボでは前のバージョンのままなので、「app-start/local」ディレクトリにある、「package.json」を修正しておきます。

{
  ...
  "devDependencies": {
    "@google/local-home-sdk": "^0.2.0",
    ...
  }
}

identifyHandlerの実装を以下のようにします。
verificationIdの値は、前回設定したotherDeviceIdsのいずれかが一致する必要があります。

identifyHandler(request: IntentFlow.IdentifyRequest):
    Promise<IntentFlow.IdentifyResponse> {
  console.log("IDENTIFY intent: " + JSON.stringify(request, null, 2));

  const scanData = request.inputs[0].payload.device.udpScanData;
  if (!scanData) {
    const err = new IntentFlow.HandlerError(request.requestId,
        'invalid_request', 'Invalid scan data');
    return Promise.reject(err);
  }

  // In this codelab, the scan data contains only local device id.
  const localDeviceId = Buffer.from(scanData.data, 'hex');

  const response: IntentFlow.IdentifyResponse = {
    intent: Intents.IDENTIFY,
    requestId: request.requestId,
    payload: {
      device: {
        id: 'washer',
        verificationId: localDeviceId.toString(),
      }
    }
  };
  console.log("IDENTIFY response: " + JSON.stringify(response, null, 2));

  return Promise.resolve(response);
}

executeHandlerの実装を以下のようにします。
ここでは、HTTP経由でローカルデバイス(ピッコロボIoT)に送信します。
※ SERVER_PORTは3388で設定しています。ローカルデバイスは3388ポートで待受ける事になります。

executeHandler(request: IntentFlow.ExecuteRequest):
    Promise<IntentFlow.ExecuteResponse> {
  console.log("EXECUTE intent: " + JSON.stringify(request, null, 2));

  const command = request.inputs[0].payload.commands[0];
  const execution = command.execution[0];
  const response = new Execute.Response.Builder()
    .setRequestId(request.requestId);

  const promises: Array<Promise<void>> = command.devices.map((device) => {
    console.log("Handling EXECUTE intent for device: " + JSON.stringify(device));

    // Convert execution params to a string for the local device
    const params = execution.params as IWasherParams;
    const payload = this.getDataForCommand(execution.command, params);

    // Create a command to send over the local network
    const radioCommand = new DataFlow.HttpRequestData();
    radioCommand.requestId = request.requestId;
    radioCommand.deviceId = device.id;
    radioCommand.data = JSON.stringify(payload);
    radioCommand.dataType = 'application/json';
    radioCommand.port = SERVER_PORT;
    radioCommand.method = Constants.HttpOperation.POST;
    radioCommand.isSecure = false;

    console.log("Sending request to the smart home device:", payload);

    return this.app.getDeviceManager()
      .send(radioCommand)
      .then(() => {
        const state = {online: true};
        response.setSuccessState(device.id, Object.assign(state, params));
        console.log(`Command successfully sent to ${device.id}`);
      })
      .catch((e: IntentFlow.HandlerError) => {
        e.errorCode = e.errorCode || 'invalid_request';
        response.setErrorState(device.id, e.errorCode);
        console.error('An error occurred sending the command', e.errorCode);
      });
  });

  return Promise.all(promises)
    .then(() => {
      return response.build();
    })
    .catch((e) => {
      const err = new IntentFlow.HandlerError(request.requestId,
          'invalid_request', e.message);
      return Promise.reject(err);
    });
}

localディレクトリに移動し、npmで依存ライブラリを取得し、コンパイルをします。

cd local
npm install
npm run build

次のファイルが、「public/local-home」ディレクトリに配置されます。

  • bundle.js
    コンパイル済みのローカルアプリと依存関係を含むJavaScriptで出力されたファイル。
  • index.html
    デバイスのテスト用にアプリを提供するために使用されるローカルホスティングページ。Actionsコンソールの「Test > On device testing」で設定したURLにホストされる。

下記コマンドを実行し、Firebase Hostingにデプロイします。

firebase deploy --only hosting

仮想スマートウォッシャーを起動する

「smarthome-local/virtual-device」ディレクトリ以下にある、テスト用に用意されている仮想スマートウォッシャーを起動します。
※ 最終的にはピッコロボIoTがこれに置き換わるようにします。

「virtual-device」ディレクトリに移動し、依存ライブラリを取得します。

cd virtual-device
npm install

実行時に下記パラメータを指定します。

パラメータ
deviceIddeviceid123
discoveryPortOut3311
discoveryPacketHelloLocalHomeSDK
projectId 自分のプロジェクトID

下記コマンドを実行します。
「<project-id>」は自分のプロジェクトIDに置き換えてください。

 npm start -- \
  --deviceId=deviceid123 --projectId=<project-id> \
  --discoveryPortOut=3311 --discoveryPacket=HelloLocalHomeSDK 

ローカル実行されているか確認する

Google Homeデバイス(Nest Mini)がローカルネットワーク経由で仮想デバイス(仮想スマートウォッシャー)にコマンド実行できているか確認します。

まずはGoogle Homeデバイス(Nest Mini)を再起動します。
このステップにより、デバイスはHTMLのURLと、Actionsコンソールに配置したスキャン構成を取得できます。

Chromeブラウザで新しいタブを開き、「chrome://inspect」をアドレスフィールドに入力してChromeインスペクターを起動します。
デバイスのリストが表示され(少し時間がかかります)、使用しているGoogle Homeデバイス(Nest Mini)の名前の下に、アプリのURLが表示されていると思います。

アプリのURLの下にある「inspect」をクリックして、Chrome DevToolsを起動します。
「コンソール」タブを選択し、TypeScriptアプリによって出力された、IDENTIFYインテントの内容が表示されることを確認します。

Google Homeデバイス(Nest Mini)へ下記のように話し、音声コマンドを介してコマンドを仮想デバイス(仮想スマートウォッシャー)に送信します。
話しかける時は、「OK、Google。ウォッシャーをオンにして」といった感じで実行できます。
※ 「ウォッシャー」じゃなく「洗濯機」でも操作できます。

  • ウォッシャーをオンにして
  • ウォッシャーをスタートして
  • ウォッシャーを一時停止
  • ウォッシャーを再開して
  • ウォッシャーの動作を止めて
  • ウォッシャーをオフにして

TypeScriptアプリによって出力された、EXECUTEインテントの内容が表示されることを確認します。

仮想デバイス(仮想スマートウォッシャー)の方では、以下のような出力がされていることを確認します。
※ ついでに、Web UI側も同期していることが確認できると思います。

info: {
  "on": true
}
info: ***** The washer is STOPPED *****
info: Report State successful
info: {
  "isRunning": true
}
info: ***** The washer is RUNNING *****
info: Report State successful
info: {
  "isRunning": false
}
info: ***** The washer is STOPPED *****
info: Report State successful
info: {
  "on": false
}
info: ***** The washer is OFF *****

これでローカル実行ができるようになりました。
次回は仮想デバイス(仮想スマートウォッシャー)を、ピッコロボIoTに置き換えるように実装していこうと思います!