掃除ロボットを不在時に稼働する part2
part1あります。
arp-scanで家族の不在を検知して掃除ロボットをスタートさせる方式はうまくいかなかった。iPhoneがスリープするとarp-scanに応答しないことがあり、その場合不在とみなして在宅なのに掃除をスタートさせてしまう。
iPhoneを使う方法でしか家族の不在を検知出来ない気がするので、プランBとしてiPhoneのオートメーション、ショートカットの機能のGPSで出発・帰宅を検知する方式を試すことにした。
もし出発・帰宅どちらもGPSをトリガーに発火したときに自宅のWi-Fiを掴んでいたら、ここから先の道のりはわりと容易い気がする。調べてみると最小で指定した地点から100mまでしか指定できなかった。つまり自宅から100m離れたら発火する。
さすがに100m離れたら自宅Wi-Fiはもう掴んでいないので、localhostではなくて外部に公開する方式でサーバーをたてることにした。
外部に公開する方法を調べると
- 固定のグローバルIPアドレスを使うのは有料(しかもちょっと高い)
- 動的なIPアドレスでも使えるDDNSというサービスあり
- しかし自宅の光回線がIPv6オンリーの契約でポート開放できない
- IPv6対応のDDNSサービスは限られている?、多分
そんな人のためにCloudflare Tunnelというサービスがあるらしい。初耳。サーバーに手持ちのRaspberry Piを使いたいので、このサービスを使うことにした。
ステップまとめ
- iPhoneのオートメーションのGPSで自宅を離れたことを検知
- iPhoneのショートカットでwebhookを叩く
- Cloudflare Tunnelで自宅で待ち受けるRaspberry Piに到達
- Raspberry Piは他の家族の在宅状況をチェック
- 全員不在の場合SwitchBotのAPIをコール
- 掃除ロボットが掃除スタート
ステップ多め。せっかく掃除ロボットを買ったから不在時には稼働させたいだけなんだけど。
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,
}
));
app.set('trust proxy', 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;
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;
}
}
app.post('/webhook', async(req,res) => {
const {user, event} = req.body;
if(!status[user]){
return res.status(400).json({error: "unknown user"});
}
status[user].home = event === 'home';
status[user].lastUpdate = new Date();
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
バージョンを確認
Tunnelにログイン。
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/