Netatmoのデータ更新が途絶えたときにメールで通知したい

# 背景
自宅ではNetatmoを使って環境の測定を行っており、この測定値を使っていくつかの機器を制御しています。これまで次のような原因でNetatmoのデータ更新が停止している状況を経験しました。上から順に頻度の高さを示しています。

1. ルータのフリーズ
    - これまでは2ヶ月に1度程度のフリーズでしたが、最近の猛暑のためか、1ヶ月以内で数回のフリーズがありました。
1. Netatmo本体のフリーズ
    - これまでは年に数回程度でしたが、最近の猛暑のためか、1ヶ月で数回のフリーズがありました。
1. Netatmoの外部モジュールの電池切れ
    - 2年に1度程度

Netatmoのサーバ上のデータ更新が途絶えてしまうと、このデータを利用している各機器の動作にも影響してしまいます。これまでは対応頻度が低かったこともあり、定期的に再起動させることでスムースに運用できていました。しかしながら、最近の猛暑のためか、ルータやNetatmo本体のフリーズによりデータ更新が途絶えたことに気付くまでに時間を要してしまう問題が頻繁に発生してしまいました。このため、急遽、データの更新状況を監視し、更新が途絶えた際にメールで通知するようなスクリプトを作成しました。作成したスクリプトの特徴は次の通りです。

- データ更新状況の監視。設定した時間内にデータの更新が無ければメールで通知する。Netatmoに接続されている全てのデバイスを検索します。
- 外部モジュールの電池残量の監視。指定した残量になればメールで通知する。

同様の問題でお困りの他のNetatmoユーザの方々がおられると、この記事がお役に立てればと思い、公開させていただきました。スクリプトのリポジトリは[こちら](https://github.com/tanaikech/Notifying-with-email-when-Netatmo-was-down)です。

# スクリプトの使い方
最初にNetatmoのAPIを使うための手続きを行い、次にスクリプトを自動で定期的に実行できる状態にします。

## 1. Netatmo側の設定

1. NetatmoのAPIを使うために[https://dev.netatmo.com/](https://dev.netatmo.com/)でアプリ登録を行います。URLへアクセスしてから"CREATE YOUR APP"ボタンをクリックしてください。クリックすると、ログイン画面が現れますので、ログインしてください。
1. Name, Description, Data Protection Officer name, Data Protection Officer emailを入力し、"I accept Netatmo APIs Terms and Conditions"のチェックボックスへチェックしてからSaveボタンを押してください。
1. 設定を保存した後、ページの少し下方にあるClient id, Client secretをコピーします。

これでNetatmo側の設定は終了です。

## 2. スクリプト（Google Apps Script）側の設定
- Googleへログイン後、[script.google.com](https://script.google.com/)へ移動し、左側にある「新規スクリプト」をクリックすると新規スタンドアロンプロジェクトとしてスクリプトエディタが起動しますので、タイトル（ファイル名）を入力し、下記スクリプトをコピーペーストして保存します。
- スクリプトを実行する前に``run()``の``account``へご自身の値を入力してください。
    - clientId, clientSecretは、Netatmoのアプリを登録した際にコピーした値です。
    - userName, passwordは、Netatmoへログインする際に使用するアカウントのものです。
    - diffTimeは、スクリプトが実行された時刻からdiffTime（秒）前の間にデータが更新されていなければその機器は停止しているとみなされます。私は900秒の設定で使用しています。Netatmoのデータは5分毎にサーバへアップロードされますので、もしも最短で確認する場合は5分かと思われます。
    - batteryPercentは、バッテリーの残量がこの値以下になったときにメールで通知します。私は、10に設定しています。
    - mailはメール通知するための宛先です。
- 上記の準備後、一度手動で``run()``を実行して動作を確認します。最初に実行した一度だけ、スクリプトで使用するスコープの承認を行う必要がありますのでこれを許可してください。
    - スクリプトが実行されると、Netatmoの状態やアカウントに問題がなければ、"All devices of Netatmo are working fine."とログに表示されます。
- スクリプトが実行することを確認した後、時間トリガー設定で``run()``をトリガー起動するための設定を行います。これにより、定期的にNetatmoのデータ更新状況を監視させることができます。トリガー設定の方法は次の通りです。
    1. スクリプトエディタで「編集」 -> 「現在のプロジェクトトリガー」を開きます。
    1. 「今すぐ追加するにはここをクリックしてください。」をクリックします。
    1. 実行は「run」を選択します。
    1. イベントは、「時間主導型」、「時タイマーあるいは分タイマー」を選択して、チェックする間隔の時間を入力し、保存ボタンを押します。
        - 私はdiffTimeに合わせて15分間隔で監視させています。

これで準備は完了です。あとはGoogleをログアウトしても問題ありません。設定した時間間隔でrun()が実行され、Netatmoのデータ更新状況が監視されます。

### スクリプト
~~~javascript
function checkNetatmo(account) {
  var dateStr = function(d) {return Utilities.formatDate(new Date(d * 1000), Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm:ss')};
  var nowTime = Math.floor(new Date().getTime() / 1000);
  var prop = PropertiesService.getScriptProperties();
  var accessToken = getAccessToken(0, prop, nowTime, account);
  var url = "https://api.netatmo.com/api/getstationsdata?access_token=" + accessToken;
  var res = UrlFetchApp.fetch(url, {muteHttpExceptions: true});
  if (res.getResponseCode() == 200) {
    res = JSON.parse(res.getContentText());
    if (!("body" in res)) error(3);
    var results = res.body.devices.reduce(function(devices, e) {
      if (account.diffTime < nowTime - e.dashboard_data.time_utc) devices.push({id: e._id, lastTime: dateStr(e.dashboard_data.time_utc)});
      var modules = e.modules.reduce(function(m, f) {
        var temp = {id: f._id};
        if (account.diffTime < nowTime - f.dashboard_data.time_utc) {
          temp.lastTime = dateStr(f.dashboard_data.time_utc);
        }
        if (account.batteryPercent > f.battery_percent) {
          temp.batteryPercent = f.battery_percent;
        }
        if (temp.lastTime || temp.batteryPercent) m.push(temp);
        return m;
      }, []);
      if (modules.length > 0) Array.prototype.push.apply(devices, modules);
      return devices;
    }, []);
    if (results.length > 0) {
      var body = results.reduce(function(s, e) {
        return s + "- Device ID is " + e.id + "\r\n" +
          (e.lastTime ? "Update from this device is stopping. Last update is " + e.lastTime + ".\r\n" : "") +
          (e.batteryPercent ? "Battery is low. Current battery is " + e.batteryPercent + " %.\r\n" : "") +
          "\r\n";
      }, "Notification: Netatmo's condition. Detected devices are below.\r\n\r\n");
      MailApp.sendEmail({to: account.mail, subject: "Notification: Netatmo's condition", body: body});
    } else {
      Logger.log("All devices of Netatmo are working fine.");
    }
  }
}

function getAccessToken(c, prop, nowTime, account, refreshToken) {
  var token = prop.getProperties();
  var params = {method: "post", muteHttpExceptions: true, payload: {"client_id": account.clientId, "client_secret": account.clientSecret}};
  if (!token.refreshToken) {
    if (!account.clientId || !account.clientSecret || !account.userName || !account.password) error(1);
    params.payload.grant_type = "password";
    params.payload.scope = "read_station";
    params.payload.username = account.userName;
    params.payload.password = account.password;
  } else if (token.expire < nowTime) {
    if (!account.clientId || !account.clientSecret) error(2);
    params.payload.grant_type = "refresh_token";
    params.payload.refresh_token = token.refreshToken;
  } else {
    return encodeURIComponent(token.accessToken);
  }
  var res = UrlFetchApp.fetch("https://api.netatmo.com/oauth2/token", params);
  if (res.getResponseCode() == 200) {
    res = res.getContentText();
  } else {
    if (c == 0) {
      c += 1;
      delete token.refreshToken;
      getAccessToken(c, prop, nowTime, account, refreshToken);
    } else {
      error(4);
    }
  }
  res = JSON.parse(res);
  prop.setProperties({
    refreshToken: res.refresh_token,
    accessToken: res.access_token,
    expire: (nowTime + res.expires_in - 300),
  });
  return encodeURIComponent(res.access_token);
}

function error(e) {
  var m = "";
  switch (e) {
    case 1:
      m = "Parameters for retrieving access token is insufficient.";
      break;
    case 2:
      m = "Parameters for retrieving access token using refresh token is insufficient.";
      break;
    case 3:
      m = "Data couldn't be retrieved.";
      break;
    case 4:
      m = "Netatmo's server is down.";
      break;
  }
  throw new Error(m);
}


// スクリプトを実行する前に下記を設定してください。
function run() {
  var account = {
    clientId: "#####", // Netatmoのアプリを登録した際にコピーした値
    clientSecret: "#####", // Netatmoのアプリを登録した際にコピーした値
    userName: "#####", // Netatmoへログインする際に使用するアカウントのもの
    password: "#####", // Netatmoへログインする際に使用するアカウントのもの
    diffTime: 900, // スクリプトが実行された時刻からdiffTime（秒）前の間にデータが更新されていなければその機器は停止しているとみなされる
    batteryPercent: 10, // バッテリーの残量がこの値以下になったときにメールで通知
    mail: "#####", // メール通知するための宛先
  };
  checkNetatmo(account);
}
~~~