掃除ロボットを不在時に稼働する part2

part1あります。


arp-scanで家族の不在を検知して掃除ロボットをスタートさせる方式はうまくいかなかった。iPhoneがスリープするとarp-scanに応答しないことがあり、その場合不在とみなして在宅なのに掃除をスタートさせてしまう。


iPhoneを使う方法でしか家族の不在を検知出来ない気がするので、プランBとしてiPhoneのオートメーション、ショートカットの機能のGPSで出発・帰宅を検知する方式を試すことにした。

もし出発・帰宅どちらもGPSをトリガーに発火したときに自宅のWi-Fiを掴んでいたら、ここから先の道のりはわりと容易い気がする。調べてみると最小で指定した地点から100mまでしか指定できなかった。つまり自宅から100m離れたら発火する。

さすがに100m離れたら自宅Wi-Fiはもう掴んでいないので、localhostではなくて外部に公開する方式でサーバーをたてることにした。

外部に公開する方法を調べると

  1. 固定のグローバルIPアドレスを使うのは有料(しかもちょっと高い)
  2. 動的なIPアドレスでも使えるDDNSというサービスあり
  3. しかし自宅の光回線がIPv6オンリーの契約でポート開放できない
  4. IPv6対応のDDNSサービスは限られている?、多分

そんな人のためにCloudflare Tunnelというサービスがあるらしい。初耳。サーバーに手持ちのRaspberry Piを使いたいので、このサービスを使うことにした。


ステップまとめ

  1. iPhoneのオートメーションのGPSで自宅を離れたことを検知
  2. iPhoneのショートカットでwebhookを叩く
  3. Cloudflare Tunnelで自宅で待ち受けるRaspberry Piに到達
  4. Raspberry Piは他の家族の在宅状況をチェック
  5. 全員不在の場合SwitchBotのAPIをコール
  6. 掃除ロボットが掃除スタート

ステップ多め。せっかく掃除ロボットを買ったから不在時には稼働させたいだけなんだけど。


webhook

まず、サーバーのコードを書く。Node.js express。

index.js

require('dotenv').config();

const express = require('express');
const fs = require('fs');
const app = express();
const helmet = require('helmet');
const axios = require('axios');
const rateLimit = require('express-rate-limit');
const morgan = require('morgan');
const { log } = require('console');
const port = 3000;

//----------
//ミドルウェア
//----------
app.use(express.json());
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
}
));
// Cloudflare Tunnel reverse proxy の後ろで動くため
app.set('trust proxy', 1); // 1 = 最初のプロキシを信用

//アクセスログ
const accessLogStream = fs.createWriteStream('./access.log', { flags: 'a' });
app.use(morgan('combined', { stream: accessLogStream }));

//レート制限
app.use(rateLimit({windowMs: 60 * 1000, max: 5}));

//認証
app.use((req, res, next) => {
const token = req.get('Authorization');
if(token !== process.env.WEBHOOK_SECRET){
fs.appendFileSync(
'error.log',
`${new Date().toISOString()} ${req.ip} ${req.method} ${req.url} Forbidden\n`
);
return res.status(403).json({error: "Forbidden"});
}
next();
});

//----------
//状態管理
//----------
let status = {
iPhone_1: {home: true, lastUpdate: new Date()},
iPhone_2: {home: true, lastUpdate: new Date()}
};

const TOKEN = process.env.SWITCHBOT_TOKEN;
const DEVICE_ID = process.env.SWITCHBOT_DEVICE_ID;

//----------
//SwitchBot API
//----------
async function getVacuumStatus() {
try {
const res = await axios.get(
`https://api.switch-bot.com/v1.1/devices/${DEVICE_ID}/status`,
{
headers: {
Authorization: TOKEN,
'Content-Type': 'application/json',
},
}
);
return res.data.body.workingStatus;
} catch (err) {
console.error('getStatus error:', err.message);
return null;
}
}

async function startVacuum() {
try {
const res = await axios.post(
`https://api.switch-bot.com/v1.1/devices/${DEVICE_ID}/commands`,
{
command: 'start',
parameter: 'default',
commandType: 'command',
},
{
headers: {
Authorization: TOKEN,
'Content-Type': 'application/json',
},
}
);
return res.data;
} catch (err) {
fs.appendFileSync(
'error.log',
`${new Date().toISOString()} startVacuum error: ${err.message}\n`
);
return null;
}
}

//----------
//webhook endpoint
//----------
app.post('/webhook', async(req,res) => {
const {user, event} = req.body; // user: "iPhone_1", "iPhone_2", event: "home", "away"
if(!status[user]){
return res.status(400).json({error: "unknown user"});
}

status[user].home = event === 'home';
status[user].lastUpdate = new Date();

//log書き込み
fs.appendFileSync(
'events.log',
`${status[user].lastUpdate} - ${user} is ${event}\n`
);
console.log(`${user} → ${event}`);
res.json({ ok: true, status });

// ===== 時間制御 =====
const now = new Date();
const hour = now.getHours();
if (hour < 8 || hour >= 16) {
console.log("⏸️ 実行時間外なので掃除機処理はスキップ");
fs.appendFileSync(
'events.log',
`${new Date().toISOString()} - Skipped vacuum (outside allowed hours)\n`
);
return;
}

// ===== 在宅判定 =====
let isHome = status.iPhone_1.home || status.iPhone_2.home;
if(!isHome){
const vacuumStatus = await getVacuumStatus();
if(vacuumStatus && vacuumStatus.toLowerCase() === 'cleaning'){
console.log('vacuum is already running, skip start.');
fs.appendFileSync(
'events.log',
`${new Date().toISOString()} - Vacuum already running, skip start\n`
);
}else{
console.log('starting vacuum...');
await startVacuum();
}
}
});

//----------
//エラーハンドラ
//----------
app.use((err, req, res, next) => {
fs.appendFileSync(
'error.log',
`${new Date().toISOString()} ${req.ip} ${req.method} ${req.url} ${err.message}\n`
);
res.status(500).json({ error: 'Internal Server Error' });
});
//----------
app.listen(port, () => {
console.log(`Webhook server running on http://0.0.0.0:${port}`);
});

ちょっと長いので折り畳めるといいんだけど。

.env

SWITCHBOT_TOKEN=YOUR_TOKEN
SWITCHBOT_DEVICE_ID=YOUR_DEVIDCEID
WEBHOOK_SECRET=認証用PASSWORD

localhostでテストする。


Cloudflare

Cloudflareのアカウントを持っていなかったので、アカウント作成から。

アカウント作成するとドメインの登録を促されるので自分のドメインを登録する。

ドメイン登録するとDNSレコードを自動でコピーしてくれるんだけど、自分の場合はだいぶ古いレコードをコピーしてきていたので、結局手動でDNSレコードを登録した。

そしてネームサーバーを切り替える。自分の場合はお名前.comでドメインを買ったのでお名前.comのネームサーバー設定画面でCloudflareのネームサーバーを設定する。


Cloudflare Tunnel

Cloudflareダッシュボード > Access > Zero Trustを起動する

チーム名を登録する。このチーム名がサブドメインになるらしい。teamname.example.comみたいな感じ。

フリープランを選択しても支払い方法の入力が必要で、このとき請求先住所が日本語だとエラーが出てしまう。そしてエラーの理由は教えてはくれない。


Raspberry Piにcloudflaredをインストールする。

curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-armhf.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
sudo apt-get install -f -y 

バージョンを確認

cloudflared --version

Tunnelにログイン。

cloudflared tunnel login

SSHなので認証のブラウザは開かないので、URLをコピーして自分でブラウザで開き認証。

認証するとここに認証ファイル、pemとjsonが作られた。

/home/username/.cloudflared/

tunnelを作る

cloudflared tunnel create raspberry-tunnel

DNSとtunnelの紐づけ

cloudflared tunnel route dns raspberry-tunnel sub.example.net

Tunnelの設定ファイルを書く

 ~/.cloudflared/config.yml

tunnel: example_id
credentials-file: /home/username/.cloudflared/example_id.json
ingress:
  - hostname: sub.example.net
    service: http://192.168.0.2:3000
  - service: http_status:404

認証後作られるjsonファイルを指定する。tunnelのidもjson名の長いUUID風のもの。service:にはRaspberry PiのローカルIPアドレスを。IPアドレスは事前に固定しておく。


Tunnelをテストのために起動する。

先にRaspberry Piでサーバーを起動する。SSHごしなのでnohup。

nohup node index.js > server.log 2>&1 &

Tunnelの起動。

cloudflared tunnel run raspberry-tunnel

https://sub.example.netにアクセスしてテスト。

このままではSSHが切断されるとTunnelも閉じてしまうので常駐化する。常駐化にあたりsudo clouflaredするとrootを見に行くとかで失敗するので、各ファイルをコピーしておく。

sudo mkdir -p /etc/cloudflared
sudo cp /home/username/.cloudflared/config.yml /etc/cloudflared/
sudo cp /home/username/.cloudflared/example_id.json /etc/cloudflared/
sudo chown root:root /etc/cloudflared/*

そして常駐化のコマンド。

sudo cloudflared service install

OS起動時に自動で立ち上がり、バックグラウンドで常駐し、落ちたら systemd が再起動してくれる。

晴れて外部から自宅のRaspberry Piに接続できるようになった。


iPhoneの設定

最後に各人のiPhoneにショートカットとオートメーションの設定。

先にショートカットを作成する。出発用と帰宅用、それぞれ以下のように設定する。

ショートカット > URLの内容を取得

URL: https://sub.example.net

方法: POST

Authrization: WEBHOOK_SECRET

本文: user: iPhone_1 or iPhone_2, event: home or away


次にオートメーションで↑のショートカットを使う。

オートメーション > 到着 or 出発

場所: 自宅

時間: 任意の時刻

確認: すぐに実行

次へ > 先程作成したショートカットを指定する。


これであとはお出かけすると掃除ロボットが動く。

しばらく様子見。


Cloudflare: https://www.cloudflare.com/