こんにちは!
どうもikubakuです.初めてelchikaに投稿させていただきました.
春眠暁を覚えずといいますが,ゴールデンウィークが終わってもなお睡眠の崩壊に悩んでいる方,いらっしゃると思います.この記事ではobniz IoT コンテスト2021の一環で作成した自動起床+就寝報告システムについて書こうと思います.
何を作ったのか?
ベッドの上に私が乗っているか自動で検知して良い感じに起床・就寝報告をMastodonに自動で行うシステムをobniz Board 1Yで作りました.
デモ動画
材料リスト
続く内容で使った部品について言及するので先に材料リストを示します.
材料 | 数量 | リンク,手に入る場所など | 備考 |
---|---|---|---|
obniz Board 1Y | 1 | 電子部品店など | 無償提供でいただきました |
ベッド | 1 | 家にあるものでOK | マットレスを載せる部分が取り外せるもの |
ブレッドボード | 1 | 電子部品店など | |
ジャンパワイヤ | 適量 | 電子部品店など | 最低4本必要です |
ピンヘッダ(1x4) | 1 | 電子部品店など | |
50kgfハーフブリッジロードセル | 4 | https://www.amazon.co.jp/gp/product/B08PFXY4ZZ/ など | 4つ組み合わせて200kgfまで計量することができるものを使います |
ロードセル用ADコンバータモジュール | 1 | https://www.amazon.co.jp/gp/product/B08B875MLX/ など | HX711を使ったものを使います |
樹脂被覆導線 | 適量 | 電子部品店など | ベッドのサイズやロードセルについている導線の長さによって必要な長さが変化します |
熱収縮チューブ | 適量 | 電子部品店など | 導線の太さに合わせたものを用意します.絶縁粘着テープでも可 |
金属板 | 8 | https://www.amazon.co.jp/gp/product/B00AJHJEUG/ など | ロードセルやベッドの敷板の形状によってサイズを調整します |
板目表紙 | 1 | https://www.amazon.co.jp/gp/product/B0012NBW1K/ など | A4サイズ1枚で足りるはずです |
樹脂系接着剤 | 適量 | ホームセンター・100円ショップなど | 金属と他にものとの接着に対応しているものを使います |
フェルト生地 | 適量 | ホームセンター・100円ショップなど | 金属板よりも大きいサイズのものを用意します |
粘着テープ | 適量 | ホームセンター・100円ショップなど | |
両面テープ | 適量 | ホームセンター・100円ショップなど | 樹脂フィルム系のもの(スポンジになっていないもの) |
(任意)養生テープ | 適量 | ホームセンター・100円ショップなど | ベッドに両面テープを貼りたくない場合に使います |
その他にも各種工具が必要です.また,プログラムの動作環境をセルフホストする場合は実行用のマシンをVPSなどで用意する必要があります(この記事ではプログラムを自前で動かす方針で書いています).
仕組み
寝ている状態の検出
寝ている状態を検出する戦略は,ベッドの上に人が乗っていることを検知することにしました.一部のベッドは敷き布団を載せる部分がすのこになっていて取り外せます.したがってこの部分をスイッチにすることで寝ているか起きているかどうかを知ることができそうです.
ただ当然ベッドの上には人以外のものを載せることもあります.バネなどを使って一定の重量で反応するような仕組みにすれば人よりも軽いものが乗っている状態を除外することができますが,そのための機構を作ることが大変そう(特にバネを用意することが)ですし,寝ている間に寝床がふわふわして眠れなくなるかもしれません.
そこでこのシステムではロードセルを使って重量で分類することにしました.実際には下の図のようにベッド本体とすのこの部分の間に後述する重量センサユニットを挟む形にしました.家にあったベッドは上半分と下半分ですのこが分かれていたのでより重量のかかる上半分の下にセンサを仕込みました.
センサユニットの制作
重量検出のためにはロードセルを使いますがそのまま挟んでしまうといくつか問題が起こります.
まずロードセルの重量検知部分に必要なクリアランスが足りません.ロードセルは検知部分が重量で歪むことで重量を検知しているので重量が加わる反対側に空間が必要です.ここで使っているロードセルなら,導線が出ている側の反対側にスペースが必要です.
またロードセルの重量検知部分は少し尖っているのでそのまま重量をかけるとすのこが傷んでしまいます.少し傷むだけなら問題にならないですが何日も重量をかけているとロードセルがめり込んでしまいロードセルから得られるデータの性質が変わってしまうかもしれません.
そこでこれらの問題を解決できるようにロードセルを組み込んだ重量センサユニットを作成しました.構造は以下の図のようになっています.
まず板目表紙で紙製のスペーサを作ります.ロードセルと同じサイズに切り取って真ん中をくり抜いた部品を3つ作って重ねるとちょうどよいです(もちろんのり付けしましょう).
作ったスペーサをロードセルの導線が出ていない側(平べったい側)に接着したら金属板にスペーサ側を貼り付けます.このときロードセルを貼り付ける位置は自分のベッドの構造に合わせられるような場所にしましょう.
ここまでできたらロードセルの上に金属板を載せます.載せるだけでは外れてしまうので粘着テープや厚紙などで簡単なヒンジを作って2つの金属板をゆるく固定すると良いでしょう.
これでセンサの基本的な部分は完成です.あとは金属板の切断面や表面などでベッドに傷がつかないように,切断面に粘着テープを貼り,金属板表面にフェルトを貼り付けます.
以下の写真はセンサユニット作成時の作業風景と完成図です.製作時の参考にしてください.
このようなセンサユニットを計4つ作成します.
配線
次にこれらのセンサとロードセル用ADコンバータモジュール,obniz Boardを配線します.
材料リストのリンクにあるロードセルは仕様不明ですが,秋月電子通商で扱っている類似のもの( https://akizukidenshi.com/catalog/g/gP-13043/ )と同じピン配置であると仮定して配線しました(結果的に正しかったようです).
配線図
ロードセルの導線を継ぎ足す場合ははんだ付けした後に熱収縮チューブなどで絶縁すると良いでしょう.導線をブレッドボードにつなぐときはピンヘッダに導線をはんだ付けしてからつなぐと接続が安定します.
配線が終わったらobnizパーツライブラリのHX711( https://obniz.com/ja/sdk/parts/hx711/README.md )のサンプルプログラムを動作させてみて重量に合わせて異なる値が返ってくることを確認します.
ソフトウェアの実装
最後にソフトウェアを実装していきます.ソフトウェアの構成は次の図のようになっています.矢印の向きはAPIリクエストが投げられる方向を示しています.近くのテキストはそのAPIリクエストの内容です.
kokekokko-obnizはNestJSを使って実装したobnizの制御と重量データの配信を行うサーバプログラムです.すべてのソースコードのうち機能の主要な部分が実装されているものを以下に示します.
app.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import Obniz from 'obniz';
import { Measure } from './measure.interface';
@Injectable()
export class AppService {
private readonly logger = new Logger(AppService.name);
private latestMeasure: Measure = { weight: 0, datetime: 0, status: 'noData' };
private readonly obnizId: string;
private readonly accessToken: string;
constructor(private configService: ConfigService) {
this.obnizId = configService.get('OBNIZ_ID');
this.accessToken = configService.get('ACCESS_TOKEN');
}
@Cron('0 * * * * *')
async measure() {
this.logger.log('Begin measuring...');
const obniz = new Obniz(this.obnizId, {
access_token: this.accessToken,
auto_connect: false,
reset_obniz_on_ws_disconnection: false,
});
const isConnected = await obniz.connectWait({ timeout: 10 });
if (isConnected) {
const sensor = obniz.wired('hx711', {
gnd: 0,
dout: 1,
sck: 2,
vcc: 3,
});
sensor.powerUp();
const value = await sensor.getValueWait(10);
this.logger.log('grams: ' + value);
this.latestMeasure.weight = value;
this.latestMeasure.datetime = Date.now();
this.latestMeasure.status = 'ok';
sensor.powerDown();
await obniz.closeWait();
} else {
if (this.latestMeasure.status != 'noData') {
this.latestMeasure.status = 'ng';
}
}
}
getMeasure(): Measure {
return this.latestMeasure;
}
}
NestJSではあるAPIを提供する部分はserviceとして定義され,サーバの起動時に自動的にserviceのクラスがインスタンス化されます.上のコードではまずコンストラクタでobniz IDとアクセストークンを.envファイルから読み込んでいます.
measure関数は定期的に呼び出される関数で呼び出されるたびにobnizに接続しADコンバータモジュールを起動して重量データを取得しています.取得したデータと取得日時はlatestMeasureに一時的に保持されます.
getMeasure関数はサーバのルートURL(手元のマシンで立てたならば http://localhost/
)にルートされています.つまりkokekokko-obnizサーバにリクエストを送ることで最後に取得された重量データを取得することができます.
kokekokkodはkokekokko-obnizから取得したデータを蓄積し実際に起床・就寝判定を行って結果に応じてMastodonに自動投稿するデーモンプログラムです.
main.rs
use tokio::time;
use elefren::prelude::*;
use elefren::scopes::Write;
use elefren::helpers::toml;
use elefren::helpers::cli;
use isolang::Language;
mod obniz;
use obniz::ObnizResponse;
mod context;
use context::{Context, SensorStatusKind, EventKind, Event};
use crate::obniz::Status;
async fn get_data_from_obniz() -> Result<ObnizResponse, Box<dyn std::error::Error>> {
let res = reqwest::get("http://localhost:3000/")
.await?
.json::<ObnizResponse>()
.await?;
println!("{:?}", res);
Ok(res)
}
fn update_and_check_status(ctx: &mut Context, res: ObnizResponse) -> Option<Event> {
ctx.update_status(
res.get_datetime(),
if res.is_heavier_than(-7800000.0) {
SensorStatusKind::Sleeping
} else {
SensorStatusKind::Awake
}
);
ctx.read_change()
}
fn register() -> Result<Mastodon, Box<dyn std::error::Error>> {
println!("Enter the URL of the your Mastodon server (please begin with `http://`)");
let mut buffer = String::new();
std::io::stdin().read_line(&mut buffer)?;
let url = buffer.strip_suffix("\r\n")
.or(buffer.strip_suffix("\n"))
.unwrap_or(&buffer);
let registration = Registration::new(url)
.client_name("kokekokko")
.scopes(Scopes::write(Write::Statuses))
.build()?;
let mastodon = cli::authenticate(registration)?;
toml::to_file(&*mastodon, "mastodon-data.toml")?;
Ok(mastodon)
}
fn post_status(mastodon: &Mastodon, event: Event) -> Result<(), Box<dyn std::error::Error>> {
match event.kind {
EventKind::WakeUp => {
println!("WakeUp event");
mastodon.new_status(
StatusBuilder::new()
.status("おはよー")
.language(Language::Jpn)
.build()?
)?;
},
EventKind::StartSleeping => {
println!("StartSleeping event");
mastodon.new_status(
StatusBuilder::new()
.status("おやすみー")
.language(Language::Jpn)
.build()?
)?;
}
};
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut interval = time::interval(time::Duration::from_secs(90));
let mastodon = if let Ok(data) = toml::from_file("mastodon-data.toml") {
Mastodon::from(data)
} else {
register()?
};
let mut ctx = Context::default();
loop {
interval.tick().await;
let res = get_data_from_obniz().await?;
match res.status() {
Status::Ok => {
if let Some(event) = update_and_check_status(&mut ctx, res) {
post_status(&mastodon, event)?;
}
},
Status::Ng => println!("Error when communicating to the obniz application"),
Status::NoData => println!("No data is available. Skipping.")
}
}
}
大まかな流れとしては90秒毎に重量データを取得し,その値がしきい値よりも重い/軽い状態が続けばそれぞれ寝た/起きたと判定するようにしています.処理のみそとなる関数はupdate_and_check_statusです.まずサーバからのレスポンスを見て,データの取得日時と重量がしきい値よりも大きいかどうかを取得・計算しています.そしてその内容を使って判定器の状態を更新し,結果をread_change関数で取得しています.判定器の処理部分であるcontext.rsのコードを次に示します.
context.rs
use chrono::prelude::*;
use chrono::Duration;
const DURATION_THRESHOLD_MIN: i64 = 10;
#[derive(PartialEq, Clone)]
pub enum SensorStatusKind {
NoData,
Awake,
Sleeping,
}
#[derive(PartialEq)]
enum ContextState {
ChangeNotDetected,
ChangeDetected,
}
pub struct Context {
last_status_change_datetime: Option<DateTime<Utc>>,
last_update_datetime: Option<DateTime<Utc>>,
last_event_datetime: Option<DateTime<Utc>>,
last_sensor_event_kind: SensorStatusKind,
sensor_status_kind: SensorStatusKind,
state: ContextState,
}
pub enum EventKind {
WakeUp,
StartSleeping,
}
pub struct Event {
pub kind: EventKind,
pub datetime: DateTime<Utc>,
}
impl Context {
pub fn default() -> Self {
Context {
last_status_change_datetime: None,
last_update_datetime: None,
last_event_datetime: None,
last_sensor_event_kind: SensorStatusKind::NoData,
sensor_status_kind: SensorStatusKind::NoData,
state: ContextState::ChangeNotDetected,
}
}
pub fn update_status(&mut self, datetime: DateTime<Utc>, status_kind: SensorStatusKind) {
if status_kind != self.sensor_status_kind {
self.sensor_status_kind = status_kind;
self.last_status_change_datetime = Some(datetime);
self.last_update_datetime = Some(datetime);
} else {
self.last_update_datetime = Some(datetime);
}
if let Some(d) = self.get_duration() {
if self.last_event_datetime != self.last_status_change_datetime
&& d >= Duration::minutes(DURATION_THRESHOLD_MIN) {
self.commit_new_event();
}
}
}
pub fn read_change(&mut self) -> Option<Event> {
if self.state == ContextState::ChangeDetected {
self.state = ContextState::ChangeNotDetected;
let kind = match self.last_sensor_event_kind {
SensorStatusKind::NoData => panic!("BUG: Invalid context state: ChangeDetected but the sensor status is NoData"),
SensorStatusKind::Awake => EventKind::WakeUp,
SensorStatusKind::Sleeping => EventKind::StartSleeping,
};
Some(Event {
kind,
datetime: self.last_event_datetime
.expect("BUG: Invalid context state: ChangeDetected but no event datetime")
.clone(),
})
} else {
None
}
}
fn get_duration(&self) -> Option<Duration> {
let start = self.last_status_change_datetime?;
let end = self.last_update_datetime?;
Some(end - start)
}
fn commit_new_event(&mut self) {
self.last_event_datetime = Some(self.last_status_change_datetime.expect("BUG: Tried to commit new event without no last status change"));
self.last_sensor_event_kind = self.sensor_status_kind.clone();
self.state = ContextState::ChangeDetected;
}
}
Context構造体のメンバ変数について説明します.sensor_status_kindとlast_update_datetimeには最後に取得した起床・就寝判定と計測日時が格納されています(update_statusが呼ばれるたびに変わります).またlast_status_change_datetimeには最後に起床・就寝判定が変わったときのデータの取得時刻が格納されています.これらの変数にある情報は最終的な結果ではなく,本当に起床・就寝したと判定できるときだけ,それらの情報がlast_sensor_event_kindとlast_event_datetimeに書き込まれます.stateはChangeNotDetectedかChangeDetectedのどちらかが格納されます.判定結果が変化した場合はChangeDetectedになり.read_stateが新しい結果を返す状態になります.
実際に最終的な判定を行っているのはupdate_status関数内です.最初のif式で判定が変わったかどうかに合わせてメンバ変数を適宜書き換えています.次のif式で起床・就寝したかの判定を行っています.まずlast_event_datetimeとlast_status_change_datetimeが同じ日時ではないかチェックしています.これはすでに判定済みの変化が再度新たな変化として返されないようにしています.
次に最後の状態変化からたった時間をしきい値と比較しています.ここでは10分間以上状態が変わった上でさらなる変化がない場合は起床・就寝したと判定しています.
今回実装したコードの全体は以下のURLにあります.残っている部分はほとんどフレームワークとのインタフェース部分やネットワーク通信部分ですがぜひ見てみてください.
おわりに
クラウドサービスがついてくるマイコンボードを触るのは初めてでしたが割と簡単にソフトウェアがかけてよかったです.またobniz Boardの入出力ピンから電源供給できるようになっており,ピンアサインをプログラムから手軽に変更できることのパワフルさを感じました(マイコンボードに初めて触る方にとってはわかりやすい仕組みなのかもしれない?).
また今回のコンテスト内で実装した機能以外にもカレンダーと連携した目覚まし機能など,他の機能も今後足してみたいと思います.
投稿者の人気記事
-
ikubaku
さんが
2021/05/16
に
編集
をしました。
(メッセージ: 初版)
-
ikubaku
さんが
2021/05/16
に
編集
をしました。
(メッセージ: 動画が埋め込まれていないのを修正)
ログインしてコメントを投稿する