シスコビデオ会議システムのマクロ機能で自動運転してみた

# はじめに

シスコ有志による[Cisco Advent Calendar 2018](https://qiita.com/advent-calendar/2018/cisco)も24日目。クリスマスイブです。昨年の[シスコビデオ会議システムのマクロ機能で音楽プレイヤーを作ってみた](https://qiita.com/yiwagish/items/ebec851c09d0a621d29c)同様に今流行りのWebRTC だとかは全く関係のないビデオ会議端末ネタで再び切り込みます。

2017年版: https://qiita.com/advent-calendar/2017/cisco
2018年版: https://qiita.com/advent-calendar/2018/cisco

# 成果予定物と利用した素材

ビデオ会議端末が正常に動作しているかどうかを毎朝 8 時に勝手に発信して発信先が着信し、失敗したらその理由を Cisco Webex Teams ボットからメッセージ送信するマクロを検討します。

[Cisco Webex DX シリーズ](https://www.cisco.com/c/ja_jp/products/collaboration-endpoints/desktop-collaboration-experience-dx600-series/index.html)および[Cisco Webex Room シリーズ](https://www.cisco.com/c/ja_jp/products/collaboration-endpoints/webex-room-series/index.html) にはマクロ機能が搭載されています。シスコ製のビデオ会議製品で [CE 9.2.1](https://supportforums.cisco.com/t5/-/-/ta-p/3297816) 以降であれば、SX10 を除き今回のマクロを動作させることができます。
 
マクロ機能について詳しいことは[TelePresence EP : CE 9.2 マクロチュートリアル](https://supportforums.cisco.com/t5/-/-/ta-p/3300804) に記載しました。一言で言うと JavaScript で各種設定・状態管理をビデオ会議専用機で実装できるのがマクロ機能です。一般的には標準で足りていない設定の UI や動作を作成するのに利用されます。[マクロフレームワークディスカッション](https://community.cisco.com/t5/-/-/td-p/3310280)にいくつか過去の成果物を投稿しています。Github にさまざまな[サンプル](https://github.com/CiscoDevNet/roomdevices-macros-samples
)が投稿されています・ Cisco Webex にクラウド登録するモードでも10月のリリースからマクロを利用できるようになりました。

マクロ機能の元となった xAPI の全般については英語サイトになりますが [Cisco DevNet](https://developer.cisco.com/site/roomdevices/) をご覧ください。

# 事前準備

## Cisco Webex への端末登録 - Cisco Webex Control Hub

Cisco Webex に端末を登録します。既に登録されている場合やオンプレミス利用の場合は飛ばしてください。

Cisco Webex Control Hub にログインし、デバイスから新しいデバイスをクリックします。

![1.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/7d102316-e68a-7c2f-8c9c-263056bdf21a.png)

利用者に紐づけるか会議室などの場所に紐づけるか聞かれるのでここでは場所を選びます。
![2.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/a4165f89-4ff8-0f77-c258-f511005c0ae0.png)
既存の場所か新規の場所か聞かれるのでここでは新規の場所を選び、わかりやすい場所の名前を入力します。日本語も入力可能ですが、端末からの検索性や電子メールアドレス形式で生成されるビデオアドレスを考えると最初の文字列は英数字が望ましいです。

![4.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/813a4c51-87c7-651d-49a8-0f727075f1c6.png)


その他の Cisco Webex デバイスを選択します。
![5.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/0099a111-2a1b-1849-f8f0-8297488fbc71.png)

無料通話を選択します。
![6.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/f6229c4b-0f05-bbcc-f6ad-1e986f53a3e3.png)

Activation Code が発行されます。インターネットに接続したビデオ会議端末に入力することにより、利用可能になります。

![7.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/88e609b7-3c91-0978-cc4e-6a014bfcef49.png)

## Cisco Webex への端末登録 - Cisco Webex DX80

Cisco Webex DX80 を Cisco Webex に登録します。

Change Language を選択します。
![dx80-1.png](https://qiita-image-store.s3.amazonaws.com/0/216119/d3472b52-e41e-233e-089c-e31c23b08270.png)

日本語を選択します。
![dx80-2.png](https://qiita-image-store.s3.amazonaws.com/0/216119/dda62dc5-d6de-d313-0e3d-90e5c694f0a6.png)
開始を選択します。
![dx80-3.png](https://qiita-image-store.s3.amazonaws.com/0/216119/bc962a1a-70c0-035d-e960-f6846768de8c.png)
Cisco Webex を選択します。
![dx80-4.png](https://qiita-image-store.s3.amazonaws.com/0/216119/2e0d0be8-057b-be6f-87f3-4f004b09fac2.png)

先ほど Cisco Webex Control Hub から入手した Activation Code を入力します。
![dx80-5.png](https://qiita-image-store.s3.amazonaws.com/0/216119/02f3d4b1-f7ec-1bed-e070-9d53609acdc2.png)
そのほか端末独自のチュートリアルに従い、セットアップを完了すると利用できるようになります。
![dx80-6.png](https://qiita-image-store.s3.amazonaws.com/0/216119/c2944ff5-fcd3-45bd-ba0c-5b1e9485139d.png)

## Cisco Webex 登録時マクロ有効化の方法

Cisco Webex Control Hub からデバイスを選択し、登録した端末を選びます。高度な設定を起動する、を選びます。

![8.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/8b3400fc-8b78-bdea-c9dc-ad86a9bd4136.png)


端末本体の Web 管理画面に移動します。Control Hub 経由ではなく、端末に直接ログインできるように、ローカルの管理者を作成します。 Security > Users を選択します。

![9.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/58a9c93f-7944-6124-eb9e-2d0bfb8a896b.png)

ここでは localadmin というユーザを作成しました。Passphrase がパスワードとなります。
![10.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/b47df08e-6112-e824-cf78-8ce7b023b2ab.png)


# 発信・着信マクロの作成

## 時限マクロ Auto Wakeup を見てみる

Integration > Macro からマクロエディタを起動します。
![11.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/d098ebee-de48-ff03-d451-4ab5548576f5.png)

今回は、次元で動作させたいので Example から参考になりそうなマクロ Auto Wakeup を Load Example で読み込みます。これをベースに編集していきます。
![12.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/f807bf33-f0e9-c4c3-0dc0-2562979930d6.png)

ベースとなるマクロです。 setInterval を使って 1分おきに時刻をチェックし、月-金の8時から3分になる前までであれば、スタンバイから復帰しています。このスタンバイから復帰する動作を発信する動作に変えていきます。

```Javascript
/**
 * Wakes the system up from standby at 8.00 in the morning
 * on weekdays
 */

// library for communicating with video system
const xapi = require('xapi');

// how often to check time
const intervalSec = 60;

// Standard javascript built-ins such as date and timers are included
function checkTime() {
  const now = new Date();
  const weekday = now.getDay() > 0 && now.getDay() < 6;
  const wakeupNow = now.getHours() === 8 && now.getMinutes() < 2 && weekday;

  if (wakeupNow) xapi.command('standby deactivate');
}

setInterval(checkTime, intervalSec * 1000);
```





## 発信マクロ

発信と切断の関数を追加します。

```Javascript
function disconnect() {
   console.log('Disconnect');
  xapi.command('call disconnect');
}
function dial(number) {
  console.log('dial', number);
  xapi.command('dial', { Number: number });
}

```


ベースとなるマクロに発信処理を加えます。現在、通話中であれば切断し、そうでなければ発信するため、 xapi.status.get('Call')でチェックを入れています。const number = '';　に宛先のビデオアドレスを入力します。


```Javascript
/**
 * Auto-Call the system at 8.00 in the morning
 * on weekdays
 */


// library for communicating with video system
const xapi = require('xapi');

// how often to check time
const intervalSec = 60;

// Dial Destination
const number = '';

// Standard javascript built-ins such as date and timers are included
function checkTime() {
  const now = new Date();
  const weekday = now.getDay() > 0 && now.getDay() < 6;
  const wakeupNow = now.getHours() === 8 && now.getMinutes() < 3 && weekday;

  if (wakeupNow) {
      xapi.status.get('Call')
      // Calls already exist. Disconnect
        .then((call) => {
            console.log(callid);
          if (call[0].Status === 'Connected') {
              disconnect();
          }
         
          else if(call[0].Status != 'Connecting') {
            dial(number);
          }
        })
        .catch(() => {
          console.log('Dial');
          dial(number);
        });
  }
}


function disconnect() {
   console.log('Disconnect');
  xapi.command('call disconnect');
}


function dial(number) {
  console.log('dial', number);
  xapi.command('dial', { Number: number });
}
setInterval(checkTime, intervalSec * 1000);
```

時間を編集し、動作するかどうか確認します。マクロの有効化を忘れないようにします。


## 着信マクロの準備

着信側も自動応答にしたかったので同じ時間帯でシステム名が上記の自動発信端末と同じであれば自動着信するマクロです。こちらのマクロは着信側に入力します。


```Javascript
/**
 * Once you are in a call with a specific time & system:
 * Auto-Answer and mute video system.
 */
const xapi = require('xapi');

/* Use your own */
// Webex 登録では発信元SIP URL が通話のたびに変わるため、発信元システム名（ディスプレイ名）を入力

const SystemName = '';

function listenToCalls() {
  const now = new Date();
  const weekday = now.getDay() > 0 && now.getDay() < 6;
  const wakeupNow = now.getHours() === 8 && now.getMinutes() < 4 && weekday;

    xapi.event.on('IncomingCallIndication', () => {
        if (wakeupNow) {
      xapi.status.get('Call')
        .then((call) => {
          console.log(call[0].Status);
          if (call[0].DisplayName.includes(SystemName)) {
            xapi.command('Call Accept');
          }
        });
    }
    });
  
}

listenToCalls();
```


Cisco Webex 登録ではステータスから取得可能な SIP URL が発信元にならないため、システム名を利用してチェックしています。このあたりの動作は、SSH などを使ってコマンドラインでログインし、コマンドから見ていくと判別可能です。
CallbackNumberやRemoteNumberがクラウド登録時にビデオアドレスでないことが分かり、場所の名前がDisplayNameとして使われてることが分かります。

```
xstatus call
*s Call 267 AnswerState: Answered
*s Call 267 CallType: Video
*s Call 267 CallbackNumber: "spark:4929b4ed-31c1-4f34-ad85-7643686c0126"
*s Call 267 DeviceType: Endpoint
*s Call 267 Direction: Incoming
*s Call 267 DisplayName: "TKY-TMT-DX80"
*s Call 267 Duration: 183
*s Call 267 Encryption Type: "AES-128"
*s Call 267 PlacedOnHold: False
*s Call 267 Protocol: Spark
*s Call 267 ReceiveCallRate: 6000
*s Call 267 RemoteNumber: "4b7e28f8-06c5-11e9-affe-024207867246"
*s Call 267 Status: Connected
*s Call 267 TransmitCallRate: 6000
** end
```

"TKY-TMT-DX80"だとわかったので、ここではSystem Name に格納します。

```
const SystemName = 'TKY-TMT-DX80';
```


# 切断理由が知りたい！

## コンソールログに表示する
このままでは、通話が成功したかどうか、また成功したとしてもパケットロスなどがどれくらいあったかわかりません。
まず所定の時間内で発信に失敗したときの切断理由をコンソールに表示します。発信相手のチェックと、通話時間が0 を発信失敗としてログに表示します。なお、function checkTime() と時間の処理が重複していますので改善の余地ありです。


```Javascript
function listenToCalls() {
  xapi.event.on('CallDisconnect', (call) => {
    const now = new Date();
    const weekday = now.getDay() > 0 && now.getDay() < 6;
    const wakeupNow = now.getHours() === 0 && now.getMinutes() < 59 && weekday;

    if (wakeupNow) {
      if (call.RequestedURI === number && call.Duration === '0') {
        var failinfo = "Call was failed:" + "CauseValue: " + call.CauseValue + 'CauseType: ' + call.CauseType + 'CauseString: '+ call.CauseString;
        console.log(failinfo);
      }
    }
  });
}
listenToCalls();
```

![13.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/2f52d3fa-c439-08a6-9632-fb8decf68155.png)

このログはコンソールのほか、 Cisco Webex DX80 にあるログファイルから見ることができます。ただこれだとチェックが面倒なのでさらに外の世界に投げられるように検討します。

## 切断理由をHTTP POST する
最新の Room OS では HTTP で送信が可能です。

```
xconfiguration HttpClient Mode: On
** end
OK
xcommand HttpClient Post
AllowInsecureHTTPS:  Header:              Url:
```

このHTTP POST を HTTPS サーバに投げるためには ルート証明書をアップロードする必要があります。パブリック認証局であっても同様となります。今回、 [Cisco Webex Teams](https://www.cisco.com/c/ja_jp/solutions/collaboration/webex-teams.html#~about) の API を利用したいため [Go Daddy のレポジトリ](https://certs.godaddy.com/repository/)から GoDaddy Class 2 Certification Authority Root Certificate - G2 gdroot-g2.crt をダウンロードします。

Cisco Webex DX80 のWeb 管理画面から Security > Certificate Authorities　からアップロードします。

![dx80-7.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/8b283d91-8d72-e6b0-784e-d58484d80f6a.png)

証明書を反映させるため、Maintenance > Restart > Restart Device で再起動します。

![dx80-8.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/147180b2-3b4f-932d-a7ae-3695e3402f23.png)

　
## Cisco Webex Teams Bot 作成

http://developer.webex.com/ にログインします。アカウントがない場合は Sign Up から作成します。
![14.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/06905f8a-bd20-c71c-63a1-2c50cad924ad.png)

アイコンから My Cisco Webex Teams Apps を選択します。

![15.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/cd00000d-be5b-9d73-884a-28a891be7372.png)




Create a New App から Create A Bot を選択します。

![16.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/e4c885cb-e350-77c6-d739-c1cd8633dc18.png)

必要項目を入力し、Add Bot をクリックして Bot を作成します。

![17.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/76bbae70-7c0a-6afc-b52b-68dda0f2cb5d.png)

Bot's Access Token をコピーし、保存します。この Token は同じものを再発行できません。非常に重要な情報ですので他に漏れないように大切に扱ってください。

![18.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/f42f351b-e80d-ad75-775a-3c26027c3c6a.png)

Cisco Webex Teams クライアント上で自分のアカウントにBot User Name を加えます。または、新規スペースを作ってそこに追加します。

![19.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/b148ea55-5bcb-0aec-1df0-c96f2956a843.png)

この部屋のIDを知る必要があるため、developer.cisco.com の Documentation > Rest API > API References > Rooms > [List Rooms](https://developer.webex.com/docs/api/v1/rooms/list-rooms) を選びます。Use personal access tokenをオフにし、Token に先ほどの Bot の Token を入力して Runします。これにより、参加してる部屋（スペース）の一覧とIDが取得可能です。

![20.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/ac2225e8-7bf7-161e-ed7d-c25ada93c201.png)

Responses に部屋の一覧が表示されます。

![21.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/3f133141-6728-59f7-72a3-8e397c9d976f.png)

情報を出力したい部屋の"id"をコピーします。

## Cisco Webex Teams にコマンドラインからポスト

Bot's Access Token と　Room Idが正しく動作するかどうか、コマンドで確認します。 URL まで入力し終わったら改行、Body 部である {"roomId": "RoomID","text": "aa"} が入力し終わったら ピリオド"."を入力して改行すると HTTP POST が実施されます。マルチライン入力時の実装となります。
BOT_ACCESSTOKENとRoomIdはそれぞれ先ほど入手した値に置き換えます。 Cisco Webex Teams Messages API の [Create](https://developer.webex.com/docs/api/v1/messages/create-a-message) を利用しています。

```
xcommand HttpClient Post Header: "Authorization: Bearer BOT_ACCESSTOKEN" Header: "Content-Type: application/json"  url: https://api.ciscospark.com/v1/messages
{"roomId": "RoomId","text": "aa"}
.
OK
*r HttpClientPostResult (status=OK):
*r HttpClientPostResult StatusCode: 200
** end
```

## Cisco Webex Teams にマクロからポスト
この処理をマクロに置き換えた例になります。


```Javascript

var payload; 
const room = "RoomId";
var text ="1";
const url = 'https://api.ciscospark.com/v1/messages';
const token = "Bot_ACCESSTOKENf";
  
xapi.config.set('HttpClient Mode', 'On');

function postTeamsMessage(room, text,url) {
  var payload = { 
    "roomId":room,
    "text":text
  };
  var auth = 'Authorization: Bearer ' + token;
  xapi.command('HttpClient Post', 
  {
    Header: 
    ['Content-Type: application/json', 
auth],
    Url: url
  },
  JSON.stringify(payload))
  .then((result) => {
    console.log('HTTP Post was success:' + result.StatusCode);
  }).catch((err) => {
    console.log('HTTP Post was failed:' + err.message);
  });
}

```

切断理由を POST するように処理をfunction listenToCalls() に加えます。

```Javascript
function listenToCalls() {
  xapi.event.on('CallDisconnect', (call) => {
    const now = new Date();
    const weekday = now.getDay() > 0 && now.getDay() < 6;
    const wakeupNow = now.getHours() === 8 && now.getMinutes() < 3 && weekday;

    if (wakeupNow) {
      if (call.RequestedURI === number && call.Duration === '0') {
        var failinfo = "Call was failed:" + " CauseValue: " + call.CauseValue + ' CauseType: ' + call.CauseType + ' CauseString: '+ call.CauseString;
        console.log(failinfo);
        postTeamsMessage(room, failinfo,url);
      }
    }
  });
}
```

Cisco Webex Teams に切断理由が出るようになりました。

![22.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/b5857c41-fae8-e420-65bf-dc6a21254d20.png)



# まとめ

ここまでの処理をまとめた例になります。

```Javascript
/**
 * Auto-Call the system at 8.00 in the morning
 * on weekdays
 */


// library for communicating with video system
const xapi = require('xapi');

// how often to check time
const intervalSec = 60;

// Dial Destination
const number = '';

// Webex Teams
var payload; 
const room = "";
var text ="1";
const url = 'https://api.ciscospark.com/v1/messages';
const token = "";

// Standard javascript built-ins such as date and timers are included
function checkTime() {
  const now = new Date();
  const weekday = now.getDay() > 0 && now.getDay() < 6;
  const wakeupNow = now.getHours() === 8 && now.getMinutes() < 3 && weekday;

  if (wakeupNow) {
      xapi.status.get('Call')
      // Calls already exist. Disconnect
        .then((call) => {
          if (call[0].Status === 'Connected') {
              disconnect();
          }
         
          else if(call[0].Status != 'Connecting') {
            dial(number);
          }
        })
        .catch(() => {
          dial(number);
        });
  }
}


function disconnect() {
   console.log('Disconnect');
  xapi.command('call disconnect');
}


function dial(number) {
  console.log('dial');
  xapi.command('dial', { Number: number });
}


function listenToCalls() {
  xapi.event.on('CallDisconnect', (call) => {
    const now = new Date();
    const weekday = now.getDay() > 0 && now.getDay() < 6;
    const wakeupNow = now.getHours() === 8 && now.getMinutes() < 3 && weekday;

    if (wakeupNow) {
      if (call.RequestedURI === number && call.Duration === '0') {
        var failinfo = "Call was failed:" + " CauseValue: " + call.CauseValue + ' CauseType: ' + call.CauseType + ' CauseString: '+ call.CauseString;
        console.log(failinfo);
        postTeamsMessage(room, failinfo, url);
      }
    }
  });
}
  
function postTeamsMessage(room, text,url) {
  var payload = { 
    "roomId":room,
    "text":text
  };
  var auth = 'Authorization: Bearer ' + token;
  xapi.command('HttpClient Post', 
  {
    Header: 
    ['Content-Type: application/json', 
auth],
    Url: url
  },
  JSON.stringify(payload))
  .then((result) => {
    console.log('HTTP Post was success:' + result.StatusCode);
  }).catch((err) => {
    console.log('HTTP Post was failed:' + err.message);
  });
}

xapi.config.set('HttpClient Mode', 'On');
setInterval(checkTime, intervalSec * 1000);
listenToCalls();
```


# おまけ

あまりうまい処理ではないのですが、接続できた後の通話ステータスをとる処理を加えたバージョンになります。ジッター値やパケットロスなどを取得可能です。 取れてしまう値が多いので、直接 Cisco Webex Teams に送るのではなく、どこか別サーバでロギングしてから何か問題がある時だけ Cisco Webex Teams に投げたほうがよさそうですね。音声、メインビデオ、資料共有の上りと下り分表示されます。


```Javascript
function checkTime() {
  const now = new Date();
  const weekday = now.getDay() > 0 && now.getDay() < 6;
  const wakeupNow = now.getHours() === 8 && now.getMinutes() < 3 && weekday;

  if (wakeupNow) {
      xapi.status.get('Call')
      // Calls already exist. Disconnect
        .then((call) => {
          var callid = call[0].id;
          if (call[0].Status === 'Connected') {
                 var command = 'MediaChannels Call ' + callid + ' Channel';
                 xapi.status.get(command)
                    .then((mediaId) => {
                    console.log(mediaId);
                    postTeamsMessage(room, JSON.stringify(mediaId),url);
                });
              disconnect();
          }
          else if(call[0].Status != 'Connecting') {
            dial(number);
          }
        })
        .catch(() => {
          dial(number);
        });
  }
}

```


![23.PNG](https://qiita-image-store.s3.amazonaws.com/0/216119/82f56c6f-d3af-f474-244f-976a17ac4573.png)

今日はここまで。2018年お疲れさまでした。