Google App Script スマートロック 自動化

【セサミ】自宅鍵のスマートロック化(4) GASで制御

はじめに

セサミ4は、Google Apps Scriptから制御することもできます。

セサミ4の通知機能とリモートからの制御機能は十分なのであまりGASを使った制御には必要性を感じないところです。

でも、普段オートロックなしで使っているので、たまに夜寝る前に鍵をかけ忘れてしまうことがあります。

セサミ4の機能には、時間が来たら鍵を閉める、時間が来たら鍵を開けるといった、時間をトリガーとした制御はありません。

そこで今回は、時間が来たら鍵を閉める処理をGASを使って実装しようと思います。

という事で、今日の課題はこちらです。

今回の課題

  • 夜になったら自動的に鍵を閉めるようにしたい。

セサミ4をGASで制御

必要な情報の準備

セサミID(sesami_id)

制御するセサミを特定するためにIDを取得します。

セサミ4のアプリを開き、設定画面中のUUIDをsesami_idとして使用します。

セサミ4 制御API (x-api-key)

下記サイトにログインして、API KEYをコピーします。

次の章で示すコードの「sesami_id」として使用します。

ちなみに、API documentは以下にあります。

秘密鍵(key_secret_hex)

セサミ4のアプリを開き、設定画面から「このセサミの鍵をシェアする」を選択し、「ゲスト」のQRコードを表示させます。

QRコードのデコードはアプリでも以下のサイトで実行してもOKです。

QRコードをデコードすると、以下のように表示されます。

ssm://UI?t=sk&sk=xxxxxxxxxxxxxxxxxxxxxxxxxx&l=2&n=xxxxxxxxxxxxxxxxxxxxx

このままだとURLエンコードされており、Base64で使われる文字である「+」が「%2F」、「/」が「%2B」と表現されているため、Base64でデコードできません。

QRコードの読み取り結果をコピーして、以下のサイトでURLデコードさせます。

次に、「sk=」の次から「&l=」の前までをコピーして、次の章で示すコードの「SK」の値に設定します。

ちなみに、Base64をデコードしたByteデータの2byte目から16byteを16進表示にしたデータが秘密鍵として使われます。

Base64でデコードして16進表示の確認をしたい場合は以下のサイトでデコードできます。

メモ

実は、ここまで事前準備としてセサミIDと秘密鍵の取得方法を説明したのですが、QRコードをデコードした中に、セサミIDと秘密鍵がはいっていることがわかりました。

なので、ここの全てのコードではQRコードをデコード(URLデコード後)したデータを設定値に与えて、セサミIDと秘密鍵を抽出するようにしています。

セサミの状態確認

API ドキュメントにセサミの状態を取得するためのスキーマは以下のように定義されています。

    GET: "https://app.candyhouse.co/api/sesame2/{sesame2_uuid}"
    Parameters:
        name = "x-api-key", location = "header",des="your api-key"
        name = "sesame2_uuid", location = "path",des="sesame UUID"

URLにセサミID(sesame2_uuid)を指定し、headerにx-api-keyでAPIキーを指定し、GETで呼び出せばセサミの状態が取得できます。

コードは以下のようになります。

const SESAME_ID = "<ここにセサミUUIDを指定してください>";   // セサミUUID

let getState = async () => {
    // option作成
    let opt = {
          method: 'get',
          headers: {'x-api-key': `<ここにAPI KEYを指定してください>`},
          contentType: "application/json"
    };
    let res = UrlFetchApp.fetch(`https://app.candyhouse.co/api/sesame2/${SESAME_ID}`, opt);
    Logger.log('code:' + res.getResponseCode());
    Logger.log('content:' + res.getContentText());
}

res.getContentText()を確認すると、以下のようにセサミの状態がJSON形式で得られます。

{
  "batteryPercentage":100,
  "batteryVoltage":6.116129032258065,
  "position":766,
  "CHSesame2Status":"unlocked",
  "timestamp":1643917480,
  "wm2State":true
}

解錠・施錠

API ドキュメントにセサミの鍵を操作するためのスキーマは以下のように定義されています。

    POST: "https://app.candyhouse.co/api/sesame2/${sesame_id}/cmd"
    BODY: {
            cmd: cmd,
            history: base64_history,
            sign: sign
    }
    Parameters:
        name = "x-api-key", location = "header",des="your api-key"
        name = "sesame2_uuid", location = "path"
        name = "cmd", location = "body",des="開閉コマンドコード"
        name = "history", location = "body",des="履歴"
        name = "sign", location = "body",des="署名"
    cmd:
      toggle:88 / lock:82 / unlock:83

URLにセサミID(sesame2_uuid)を指定し、headerにx-api-keyでAPIキーを指定し、コマンド(鍵の開錠・施錠)、履歴に表示する文字、シグネチャをbodyとしてPOSTすれば鍵を解錠・施錠できます。

bodyの中身は以下の通りです。

cmd : 施錠:82、解錠:83、トグル:88
history:履歴に表示するための文字(半角21文字まで)
sign:シグネチャ(これを作るのがちょっとめんどい)

解錠するためのコードは以下のようになります。(cmdに82の解錠を指定しています)

セサミUUID、SK、API KEYについては、こちらで取得方法を確認してください。

const SESAME_ID = "<ここにセサミUUIDを指定してください>";   // セサミUUID
const SK = '<ここにQRコードの「sk=」の次から「&l=」の前までを設定してください>';

let lockKey = async () => {
    let cmd = 82;  //(toggle:88,lock:82,unlock:83)
    let base64_history = Utilities.base64Encode('Web API'); // 履歴に残す内容 

    // 秘密鍵取得
    decoded = Utilities.base64Decode(SK, Utilities.Charset.UTF_8);
    key_secret_hex = bytesToHex(decoded.slice(1,17));

    // body作成
    let sign = generateRandomTag(key_secret_hex);
    let body = {
          cmd: cmd,
          history: base64_history,
          sign: sign
    };

    // option作成
    let opt = {
          method: 'post',
          headers: {'x-api-key': `<ここにAPI KEYを指定してください>`},
          contentType: "application/json",
          payload: JSON.stringify(body)
    };

    // 鍵の制御
    let res = UrlFetchApp.fetch(`https://app.candyhouse.co/api/sesame2/${SESAME_ID}/cmd`, opt);
    Logger.log('code:' + res.getResponseCode());
    Logger.log('content:' + res.getContentText());
}

function generateRandomTag(secret) {
    // * key:key-secret_hex to data
    let key = CryptoJS.enc.Hex.parse(secret);

    // message
    // 1. timestamp  (SECONDS SINCE JAN 01 1970. (UTC))  // 1621854456905
    // 2. timestamp to uint32  (little endian)   //f888ab60
    // 3. remove most-significant byte    //0x88ab60
    const date = Math.floor(Date.now() / 1000);
    const dateDate = new DataView(new ArrayBuffer(4));
    dateDate.setUint32(0, date, true);
    const message = CryptoJS.enc.Hex.parse(dateDate.getUint32(0).toString(16).slice(2,8));

    return CryptoJS.CMAC(key, message).toString();
}

function bytesToHex(bytes) {
    for (var hex = [], i = 0; i < bytes.length; i++) {
        var current = bytes[i] < 0 ? bytes[i] + 256 : bytes[i];
        hex.push((current >>> 4).toString(16));
        hex.push((current & 0xF).toString(16));
    }
    return hex.join("");
}

QRコードをデコードして取得したデータをUtilities.base64Decode()を用いてBase64でデコードしてByte配列を取得し、2byte目から17byte目までの16byteを16進に変換しています。16進に変換するためにbyteToHex()関数を定義して使用しています。

signで指定するシグネチャを作るのが少し面倒なので、CryptoJSを使用します。

以下より「lib/cryptojs-aes.min.js」と「build/cmac.min.js」の二つをプロジェクトに追加します。

プロジェクトにスクリプトを追加し、ファイル名を変更してcryptojs-aes.min.jsとcmac.min.jsの内容をコピペします。

ここまで出来たら、lockKey()を実行することで鍵が閉まることを確認できると思います。

23時に鍵施錠

毎日23時に鍵を施錠するために、トリガー設定を行います。

Google Apps Scriptのトリガー設定では、毎日1回実行させるタイマーを設定できるのですが、GUIを使用して「何時何分に起動する」という設定はできません。

一方、プログラムからは「何時何分に起動する」という指定ができるので、毎日23時に起動するトリガー設定のコードを書きます。

トリガー起動時間後も登録したトリガー情報は残るので、次の日のトリガーを設定する前に、当日のトリガーを削除します。

/*
 * 毎日定時実行トリガー設定(サンプル)
 * 
 * 1日1回定期的に実行させるためのサンプル
 * デフォルトではTimeZoneがAmerica/New_YorkのGMT-5なので、
 * そのままではトリガを設定した時刻が意図した時間に動作しない。
 * そのため、プロジェクトの設定からappsscript.jsonを表示させて、
 * TimeZoneをJSTに変更しなければならない。
 * 
 * 2022/01/08 Created by N.Sekiya
 */

var RUNDAILY_HOUR = 23    // 毎日実行するトリガーの時間設定
var RUNDAILY_MIN = 00     // 毎日実行するトリガーの分設定

function runDaily() {
  Logger.log('毎日定時トリガー起動');

  //-------------------------------------------
  // ここで施錠する関数を呼び出す
  //-------------------------------------------

  //-------------------------------------------
  // 起動したトリガー設定の削除と次回のトリガーを設定する
  //-------------------------------------------
  deleteTrigger();  // 設定されているトリガーを全て削除する
  setTrigger();     // 新しい時間でトリガーを再設定する
}

// トリガーを登録する
function setTrigger() {

  // 起動するトリガーの時間設定
  const triggerDay = new Date();
  Logger.log('本日:' + triggerDay);
  triggerDay.setDate(triggerDay.getDate() + 1); // 一日進める
  triggerDay.setHours(RUNDAILY_HOUR);   // 毎日実行するトリガーの時間設定
  triggerDay.setMinutes(RUNDAILY_MIN);  // 毎日実行するトリガーの分設定
  Logger.log('設定トリガ:' + triggerDay);

  // runDaily()を起動するトリガー登録
  ScriptApp.newTrigger("runDaily")
      .timeBased()
      .at(triggerDay)
      .create();
}

//プロジェクト内のすべてのトリガーを削除
function deleteTrigger() {
  
  //プロジェクトのすべてのトリガーを取得して削除する
  const trgs = ScriptApp.getProjectTriggers();
  for(const trg of trgs){
    ScriptApp.deleteTrigger(trg);  // 設定済みトリガー削除
  }
}

最初に1回上記コードのrunDaily()を起動すれば、毎日23時にトリガーにより起動するようになります。

処理の流れは以下の通りです。

  • 23時にトリガーによりrunDaily()が呼び出される
  • 鍵を施錠する
  • プロジェクトに設定されているトリガーをすべて削除する
  • 新たに次の日の23時に起動するためのトリガーを登録する

毎日1回トリガーにより起動する方法についての詳細は、以下の記事を参照ください。

全てのコード

QRには、QRコードをデコードして得られた「ssm://」から始まる全ての文字列を設定してください。この中に含まれる秘密鍵とセサミを識別するUUIDをanalyzeQR()関数で取得するようにしました。

const PRJ_NAME = 'CtrlSesame4'; // logOut用
const QR = 'ssm://UI?t=sk&sk=xxxxxxxxxxxxxxxxxxxxxxxx&l=x&n=xxxxxx'; // <QRコードのデコード結果に置き換えてください>
const API_KEY = '<ここにAPIキーを指定してください>';

var uuid;
var sec_key;

// 毎日23時に鍵を閉める処理
function lockOnTime(){
  lockKey();        // 鍵を閉める
  deleteTrigger();  // トリガーを全て消す
  setTrigger("lockKey", 23, 00);  // トリガー再設定(翌日の23:00にlockKey()が動くように設定する)
}

function doGet(e) {
  lockKey();
  return "";
}

let lockKey = async () => {
  let cmd = 82;  //(toggle:88,lock:82,unlock:83)
  let base64_history = Utilities.base64Encode('WebAPI'); // 履歴に残す内容 

  // UUID及び秘密鍵取得
  analyzeQRCode(QR);
  logOut(PRJ_NAME, 'uuid=' + uuid);
  logOut(PRJ_NAME, 'sec_key=' + sec_key);

  // body作成
  let sign = generateCmacSign(sec_key);
  let body = {
        cmd: cmd,
        history: base64_history,
        sign: sign
  };

  // option作成
  let opt = {
        method: 'POST',
        headers: {'x-api-key': API_KEY},
        muteHttpExceptions: true,
        payload: JSON.stringify(body)
  };
  let res = UrlFetchApp.fetch(`https://app.candyhouse.co/api/sesame2/${uuid}/cmd`, opt);
}

let getState = async () => {
  analyzeQRCode(QR);

  // option作成
  let opt = {
        method: 'GET',
        headers: {'x-api-key': API_KEY},
        muteHttpExceptions: true,
        contentType: "application/json"
  };
  let res = UrlFetchApp.fetch(`https://app.candyhouse.co/api/sesame2/${uuid}`, opt);
  Logger.log(PRJ_NAME, 'code:' + res.getResponseCode() + ' content:' + res.getContentText());
}

function generateCmacSign(secret) {
    // message
    // 1. timestamp  (SECONDS SINCE JAN 01 1970. (UTC))  // 1621854456905
    // 2. timestamp to uint32  (little endian)   //f888ab60
    // 3. remove most-significant byte    //0x88ab60
    const date = Math.floor(Date.now() / 1000);
    const dateDate = new DataView(new ArrayBuffer(4));
    dateDate.setUint32(0, date, true);
    const msg = CryptoJS.enc.Hex.parse(dateDate.getUint32(0).toString(16).slice(2, 8));

    // * key:key-secret_hex to data
    const key = CryptoJS.enc.Hex.parse(secret);

    return CryptoJS.CMAC(key, msg).toString();
}

// QRコードから秘密鍵とセサミUUIDを取得する
function analyzeQRCode(qr){
  const prm = qr.slice(qr.indexOf('?')+1).split('&');
  prm.forEach(function(p){
    if(p.indexOf('sk=') == 0){
      const sk = p.slice(3);
      const dec = Utilities.base64Decode(sk, Utilities.Charset.UTF_8);
      uuid = bytesToHex(dec.slice(83, 87)) + '-' 
      + bytesToHex(dec.slice(87, 89)) + '-' + bytesToHex(dec.slice(89, 91)) + '-' 
      + bytesToHex(dec.slice(91, 93)) + '-' + bytesToHex(dec.slice(93, 99));
      sec_key = bytesToHex(dec.slice(1,17));
    }
  });
}

function bytesToHex(bytes) {
    for (var hex = [], i = 0; i < bytes.length; i++) {
        var current = bytes[i] < 0 ? bytes[i] + 256 : bytes[i];
        hex.push((current >>> 4).toString(16));
        hex.push((current & 0xF).toString(16));
    }
    return hex.join("");
}

// トリガー再登録
const setTrigger = (func, h, m) => {

  // homeLockCloseを翌日の23:00に起動するトリガーを設定
  const triggerDay = new Date();
  triggerDay.setDate(triggerDay.getDate() + 1); // 一日進める
  triggerDay.setHours(h);   //23時
  triggerDay.setMinutes(m); //00分 

  ScriptApp.newTrigger(func)
      .timeBased()
      .at(triggerDay)
      .create();
}

//全トリガー削除
const deleteTrigger = () => {
  
  //プロジェクトのすべてのトリガーを取得して削除する
  const triggers = ScriptApp.getProjectTriggers();
  for(const trigger of triggers){
    ScriptApp.deleteTrigger(trigger);  //トリガーの削除
  }
}

動作確認

一度、lockOnTime()関数を実行すると、翌日の23:00に鍵を閉める関数lockKey()が登録されます。

その後は、毎日23:00に鍵を自動的に閉めますので、ほったらかしでOKです。

まとめ

いかがでしたでしょうか。

23時になると自動的に鍵が閉まるようになります。時間はお好みによって変えてください。

これで、鍵を閉めたことを心配することなく、安心して寝られますね。

お試しください。

では、今日も良い一日を。

-Google App Script, スマートロック, 自動化
-, , , ,