Switchbot人感センサで座りっぱなし防止アラートを作る
この記事は Node-RED Advent Calendar 2021 の 4 日目の記事です。
Node-RED のカレンダー | Advent Calendar 2021 - Qiita
リモートワークで一日中座って作業すると生活習慣病が気になります。そこで、Node-RED で座りっぱなしを検知して運動を促すシステムを作ってみました。
通知が来たらスクワットやストレッチをしています。
準備
動きの検知は SwitchBot 人感センサ(Motion Sensor)を使います。
SwitchBot 人感センサー
これを机から立ち上がっていないと検知されない位置に調整して設置しました。
Node.js で実装してたのですが、Node-RED Only のパターンでも実装してみました。
ともに以下の環境変数が設定してある前提で書いています。
export SWITCHBOT_TOKEN=""
export MONITOR_DEVICE_ID=""
export SLACK_URL=""
OpenWonderLabs/SwitchBotAPI: SwitchBot API Documents
Slack での Incoming Webhook の利用 | Slack
Node.js だけで実装する版
成果物リポジトリ
elzup/switchbot-suwarippa-alert: Detect no motion time by using Switch Bot Motion Sensor
SwitchBot API を叩く部分
/devices
API で使う人感センサのデバイス ID を取得しておきます。
getMoveDetected
で動体検知をします。
SwitchBot API は現在(2021-11) 過去履歴をリストで取得できないので polling する必要があります。
Request limit
The amount of API calls per day is limited to 10000 times. Going over that limit will return "Unauthorized."
API 制限は 1 日に 10000 回なので、この 1 台に API をすべて使う場合 polling 頻度は 1 分に 6 回です。
import { getEnv } from '@elzup/kit/lib/getEnv'
import got from 'got'
const authorization = getEnv('SWITCHBOT_TOKEN')
const sensorId = getEnv('MONITOR_DEVICE_ID')
if (!authorization) {
throw new Error('env not setup: SWITCHBOT_TOKEN')
}
export type MotionDeviceLog = {
statusCode: string
body: {
deviceId: string
deviceType: 'Motion Sensor'
hubDeviceId: string
moveDetected: boolean
brightness: 'bright'
}
message: 'scucess'
}
export type DeviceLog = MotionDeviceLog
export type Response = { body: { deviceList: unknown[] } }
const cli = got.extend({
prefixUrl: 'https://api.switch-bot.com/v1.0',
headers: { authorization },
responseType: 'json',
})
export const getDevices = () => cli.get('devices').json<Response>()
export const getDevice = (deviceId: string) =>
cli.get(`devices/${deviceId}/status`).json<DeviceLog>()
export async function getMoveDetected() {
const log = await getDevice(sensorId)
return log.body.moveDetected
}
Slack 通知部分
Slack Incoming Webhook で通知を送ります。
import { makeSlackParams } from '@elzup/kit/lib/slack'
import { getEnv } from '@elzup/kit/lib/getEnv'
import axios from 'axios'
const SLACK_URL = getEnv('SLACK_URL')
export function postSlack() {
axios.request(
makeSlackParams(SLACK_URL, {
text: '動いて!',
icon_emoji: ':chair:',
username: '座りっぱ検知',
})
)
}
プロセス部分
check を getMoveDetected、 notice を postSlack として実装します。
10 秒おきに実行します。
reducer は後述します。
import { reducer, State } from './reducer'
import { postSlack } from './slack'
import { getMoveDetected } from './switchbot'
const sleep = (msec: number) =>
new Promise((resolve) => setTimeout(resolve, msec))
function notice(now: number) {
postSlack()
// sendSwarippa(now)
}
const check = getMoveDetected
const initialState: State = {
lastMoved: 0,
noticed: false,
}
async function main() {
let state = initialState
while (true) {
const detected = await check()
const now = +new Date()
const { state: newState, doNotice } = reducer(state, detected, now)
if (doNotice) notice(now)
state = newState
await sleep(10 * 1000)
}
}
main()
export default {}
ロジック部分
30 分動きがなかったら通知するように作ります。
"最後に通知した時刻", "最後の検知後通知済みか" の 2 つを State に保持しています。
doNotice で通知するかを返します。
const SUSPEND_DETECT_TIME = 30 * 60 * 1000
export type State = {
lastMoved: number
noticed: boolean
}
const isSuspend = (
now: number,
lastMoved: number,
suspendDetectTime: number,
detect: boolean
): boolean => {
if (detect) return false
const suspendTime = now - lastMoved
return suspendTime >= suspendDetectTime
}
// if not detected some time (SUSPEND_DETECT_TIME), once do notice
export const reducer = (
{ noticed, lastMoved }: State,
detected: boolean,
now: number
): { state: State; doNotice: boolean } => {
if (detected) {
return { state: { lastMoved: now, noticed: false }, doNotice: false }
}
const suspend = isSuspend(now, lastMoved, SUSPEND_DETECT_TIME, detected)
const doNotice = !noticed && suspend
return { state: { noticed: doNotice || noticed, lastMoved }, doNotice }
}
Node-RED だけで実装する版
設定全体図
ロジック部分
check notice Function Node の中身です。
前半(reducer の TypeScript を JavaScript に変換しただけです)
const SUSPEND_DETECT_TIME = 30 * 60 * 1000
const isSuspend = (now, lastMoved, suspendDetectTime, detect) => {
if (detect) return false
const suspendTime = now - lastMoved
return suspendTime >= suspendDetectTime
}
// if not detected some time (SUSPEND_DETECT_TIME), once do notice
const reducer = ({ noticed, lastMoved }, detected, now) => {
if (detected) {
return { state: { lastMoved: now, noticed }, doNotice: false }
}
const suspend = isSuspend(now, lastMoved, SUSPEND_DETECT_TIME, detected)
const doNotice = !noticed && suspend
return { state: { noticed: doNotice || noticed, lastMoved }, doNotice }
}
const initialState = {
lastMoved: 0,
noticed: true,
}
後半: プロセス部分の while ループの中を置き換えてます。
const state = flow.get('suwarippa-state') || initialState
const detected = msg.payload
const now = +new Date()
const { state: newState, doNotice } = reducer(state, detected, now)
flow.set('suwarippa-state', newState)
msg.payload = Number(doNotice)
msg.debug = { newState, doNotice }
return msg
Node-RED での環境変数
Node-RED での環境変数の取り出し方はこちらです。
環境変数を利用する : Node-RED 日本ユーザ会
URL などのプロパティの動的生成にはテンプレートを使います。環境変数は文字列結合で使えませんでした。
リクエスト先 URL にテンプレートを使用 : Node-RED
定期実行
n 秒おきに実行するトリガーノードを作成できます。
9 時から 21 時までのみ実行するなどスケジュール設定も簡単にできるようになってました。
ですが、その場合単位が「分」になってしまうので今回は使えませんでした。惜しいです。
リクエストの構築について
request ノード だけでは HTTP Header などの設定ができません。
基本的に request ノード の手前で funcsion ノードや change ノードを使ってリクエスト msg を構築します。
Node-RED で実装した感想
Node-RED のメリットとデメリットをいくつかまとめてみました。
メリット
- Input 先 output 先 ノードの変更(つけ外し、複数に拡張など)が容易
- 停止・再開が容易
- ログが見られる
- 現在の Store を (コンテキストデータタブで) 確認できる
- 定期実行のサービス化が容易
デメリット(まだ勉強中)
- コーディング環境が厳しい
これを機に個人用 Node-RED サーバーを立てたので活用していきたいです。
Discussion