LoginSignup
8
3

More than 1 year has passed since last update.

BLEビーコンとソラコムAPIでつい押してみたくなる出退勤記録ボタンを作る

Last updated at Posted at 2021-05-30

初めに

業務開始・終了を記録するときに、うっかり忘れてしまうことないでしょうか?
私はあります。パソコンを起動するとすぐに仕事のことが気になって。。
そこで、業務開始・終了、昼食開始・終了を記録したら、”少しだけ”メリットをもらえる、つい押してみたくなるボタンを作りました。

  • 業務開始:業務開始時刻と今日の天気予報を教えてくれる。(+励ましてくれる)
  • 昼食開始:休憩開始時刻と今日のニュースを教えてくれる。(+いたわってくれる)
  • 昼食終了:業務再開の時刻を教えてくれる。(+励ましてくれる)
  • 業務終了:業務終了時刻と明日の天気予報を教えてくれる。(+いたわってくれる)

工夫した、新規性のある部分は以下かと思います。

  • BLEビーコンボタンを使用して、ボタンのアクションごとに業務開始・終了 etc.を記録。
  • 天気予報を入手するための位置情報として、ソラコムの接続先基地局の位置情報を利用。

システム構成

システム構成を以下に示します。
ラズパイはビーコンの信号を受信したら、ソラコムの基地局情報や、OpenWeatheMapの天気情報、NHKのニュース情報を入手して、読み上げてくれます。
同時に、業務開始・終了、昼食開始・終了の情報をクラウドにアップします。今回は暫定で、ソラコムのメタデータサービスが持っているタグの領域に情報を書き込んでいます。出力先をAWS etc.にしてプログラムを組めば、本当の業務システムでの記録も可能と思います。
結果、ラズパイがBLEビーコンのゲートウェイになっている(とも言えなくもない)構成です。
システム構成図

使用環境

ハードウェア

ソフトウェア

  • OS: Raspberry Pi OS
  • Node.js (v8.11.4)
  • Python3 (v3.7.3)

事前準備

アカウントの取得

OpenWeatheMapのAPI Key

天気予報の情報は、OpenWeatheMapから取得します。
情報取得にはユーザー登録が必要ですので、下記を参考に、ユーザー登録とAPI Keyの入手を行ってください。
無料天気予報APIのOpenWeatheMapを使ってみる

ソラコムアカウントの取得

ソラコムのSIMを持っていればもうアカウントは持っていると思いますが、参考にするならこのページ

ハードウェアのセットアップ

ドングルのセットアップ

こちらのページをもとに、setup_air.shの実行とネットワークへの接続を行ってください。
SORACOM Onyx LTE USB ドングルをセットアップする

スピーカー接続

スピーカーの端子をラズパイの3.5mmジャックに刺して音が再生されるようにしてください。
BUFFALOのスピーカーの場合は、さらに電源供給のために、USBに接続します。

ネットワークのセットアップ

メタデータサービスの設定

  1. ソラコムのコンソールにログインする
  2. ラズパイで使用しているSIMを選択 → 詳細 ソラコム画面1
  3. SIM詳細の画面で、グループをクリック。既存のグループを割り当てるか、「新しいグループを作成」でグループを作る。 ソラコム画面2
  4. 「タグ」のタグを選択。「+」ボタンを押して、タグの編集画面を開く。名前"working"、値"0"を入力して、保存。もう一度、「+」ボタンを押してタグの編集画面を開いて、名前"lunch"、値"0"を入力して、保存。「閉じる」ボタンで、SIM詳細画面を閉じる。 ソラコム画面3 ソラコム画面4
  5. SIMの画面でグループ名を選択して、SIMグループ画面を開く。SORACOM Air for Cellular設定→ メタデータサービス設定を"on"、読み取り専用のチェックを"off"。下のほうにスクロールして、「保存」ボタンを押す。 ソラコム画面4 ソラコム画面5

ソフトウェアのセットアップ

今回は、ビーコン受信にNode.jsのbleacon、Webとの通信にPython3を使います。
とくに、bleaconは特定バージョンのNode.jsしか動かないので、注意してください。

node.jsと関連パッケージのインストール

ラズパイのホームディレクトリで実行

$sudo apt-get update
$sudo apt-get install -y libbluetooth-dev

libbluetoothをインストールしたときに、ラズパイのBLEが停止することがあります。その時は再起動してください。

node.jsのv8.11.4のインストールと関連パッケージのインストール

$sudo apt-get install -y nodejs npm
(実行後、apt-get updateしろとのメッセージが表示された場合は、
 sudo apt-get updateして、再度、sudo apt-get install -y nodejs npmを実行)
$sudo npm install n -g
$sudo n 8.11.4
(新しいshellを開けとのメッセージが出た場合は新しいshellを起動)
$sudo npm install bleacon -unsafe-perm
$sudo npm install sleep -unsafe-perm

pythonの関連パッケージのインストール

$sudo pip3 install feedparser

作業用ディレクトリの作成

今回の作業用に"workrecorder"というフォルダを作ります。
実行ファイルなどはここに置くようにします。

$mkdir /home/pi/workrecorder
$cd /home/pi/workrecorder

BLEのUUID、major番号の調査

今回使用するBLEビーコンボタンは、iBeacon規格のアドバタイズ信号を発信します。
アクションとして、

  • 1クリック
  • 2クリック
  • 長押し

三つの動作が区別できて、それぞれ同一UUID+異なるmajor番号を発信します。
あらかじめ各アクションのUUID、major番号を調べておいて、後のプログラムに使用します。
調査用の下記のプログラムを作成、保存してください。

checkUUID_MajorMinor.js
var Bleacon = require("bleacon");
function ExitProcess(){
    Bleacon.stopScanning();
    process.exit();
}
Bleacon.startScanning();
Bleacon.on("discover",function(bleacon) {
    console.dir(bleacon);
    ExitProcess();
});

ファイルを置いたフォルダで以下のコマンドを実行

$sudo node checkUUID_MajorMinor.js

ここでビーコンボタンを押下すると、受信した信号の情報が表示されます。
私の場合は、1クリック:1、2クリック:12289、長押し:4097のmajor番号が発信されていました。

{ uuid: 'XXXXXXXXXXXXXXXXX',  ← 製品によって一意。控えておく
  major: 1, ← アクションによって一意
  minor: 140,
  measuredPower: -59,
  rssi: -58,
  accuracy: 0.9387786164580845,
  proximity: 'near' }

読み上げソフトのインストール

読み上げソフトはAquesTalkを使用します。
セットアップの仕方や、天気予報・ニュース入手~読み上げまでのスクリプトはほとんど下記のサイトの情報を使用させていただきました。ありがとうございます!!

  1. 以下のサイトからラズパイ用のAquesTalkのパッケージをダウンロード、解凍する。
    本稿執筆時点の最新版は、aquestalkpi-20201010.tgz
    AquesTalk Pi

  2. 解凍されたフォルダ(aquestalkpi)をそのまま、workrecoderフォルダ直下に置く。

  3. 以下のシェルスクリプト"atalk-hp.sh"を作り、workrecoredrフォルダ直下に置き、実行権限をつける。元の技術ブログ記載のシェルスクリプトに対して、音声をヘッドホン出力するところだけ、変更しています。

atalk-hp.sh
#!/bin/bash
aquestalkpi=/home/pi/workrecorder/aquestalkpi/AquesTalkPi
var=`$aquestalkpi "$@" | base64; echo ":${PIPESTATUS[0]}"`
ret=(${var##*:})
data=${var%:*}
if [ $ret -eq 0 ]; then
    echo $data | base64 --decode --ignore-garbage | aplay -Dhw:Headphones -q
else
    echo $data | base64 --decode --ignore-garbage
    exit $ret
fi

実行権限の付与

$ chmod a+x atalk-hp.sh

プログラム

ソフトウェア構成

今回の使用するソフトウェアとその関係図です。
昔作っていたコードなどを集めてきたので複雑です(適材適所ともいう)。
これらのファイルはすべて、作業ディレクトリ(/home/pi/workrecorder)においてください。
ソフトウェア構成図

beacon_start.sh

ビーコン受信プログラム(beacon_discover.js)をキックします。
beacon_discover.jsは一度信号を受信したら終了するスクリプトなので、終了したらこのシェルで再度キックします。
実行権限もつけておきます。

beacon_start.sh
#!/bin/sh
WORKDIR=/home/pi/workrecorder
echo start ibeacon
while true
do
    sudo node $WORKDIR/beacon_discover.js
    sleep 2
    echo restart ibeacon
done

実行権限の付与

$ chmod a+x beacon_start.sh

beacon_discover.js

node.jsのライブラリ"bleacon"を使ってiBeacon信号を受信し、受信したMajor番号にしたがって、異なる引数でpythonスクリプトを起動するプログラムです。
こちらのスクリプトに、先に調べておいたビーコンボタンのUUID, Major番号(3種類)を記載してください。

beacon_discover.js
var Bleacon = require("bleacon");
var sleep   = require("sleep");
var exec    = require('child_process').exec,
      child;

var UUID    = 'XXXXXXXXXXXXXXXXXXXXXX'; <-ここに調査したビーコンのUUIDを記載
var Major1  = 0x0001; 1クリックのMajor番号を記載
var Major2  = 0x3001; 2クリックのMajor番号を記載
var Major3  = 0x1001; 長押しのMajor番号を記載

var Timeoutperiod = 1000*60*60*1;

function ExitProcess(){
    Bleacon.stopScanning();
    process.exit();
}

process.on('uncaughtException',function(err){
    console.log(err);
    ExitProcess();
});

setTimeout(function(){
    console.log('timeout');
    ExitProcess();
},Timeoutperiod);

Bleacon.startScanning(UUID);
Bleacon.on("discover",function(bleacon) {
    console.dir(bleacon);
    if(bleacon.major == Major1){
        child = exec('python3 working_recorder.py 0');
        sleep.sleep(60)
    }else if(bleacon.major == Major2) {
        child = exec('python3 working_recorder.py 2');
        sleep.sleep(60)
    }else{
        child = exec('python3 working_recorder.py 1');
        sleep.sleep(120)
    }
    ExitProcess();
});

working_recorder.py

引数にしたがって、業務開始、昼食開始・終了、業務業務終了の処理を行います。
ソラコムのAPIとOpenWeatheMapのAPIを使用するため、

・ソラコムのアカウント、パスワード
・前掲の、OpenWeatheMapのAPIキー

をスクリプト内に記載してください。

working_recorder.py
#!/usr/bin/env python
import sys
import shlex
import subprocess
from datetime import datetime
import requests
import feedparser
import json

import deg_speed
import celllib

#login for soracom
EMAIL = 'XXXXXXXXXXXXX' ソラコムのAPIを使用するためのアカウント名を記入
PASS = 'XXXXXXXXXXXX' ソラコムのAPIを使用するためのパスワードを記入

#API key for OpenWeatheMap
API_KEY = "XXXXXXXXXXXXXX"  OpenWeatheMapのAPIキーを記入

CMD_SAY = "/home/pi/workrecorder/atalk-hp.sh -b -s 90"
CMD_SAY2 = "/home/pi/workrecorder/atalk-hp.sh"
da = datetime.now()

def main():
    if sys.argv[1] == "0":
        start_work()
    elif sys.argv[1] == "1":
        check_lunch()
    else:
        end_work()
    return

def start_work():
    res = celllib.putTagValue("working", "1")
    res = celllib.putTagValue("lunch"  , "0")
    api  = celllib.getApiKey(EMAIL, PASS)
    cell = celllib.getCellInfo()
    pos  = celllib.getPosition(api, cell)
    say_weather_today(pos)
    return

def end_work():
    res = celllib.putTagValue( "working", "0")
    res = celllib.putTagValue("lunch"  , "0")
    api  = celllib.getApiKey(EMAIL, PASS)
    cell = celllib.getCellInfo()
    pos  = celllib.getPosition(api, cell)
    say_weather_tommorrow(pos)
    return

def say_weather_today(pos):
    url = "https://api.openweathermap.org/data/2.5/onecall?lat=%s&lon=%s&appid=%s&units=metric&lang=ja&exclude=hourly" % (pos["lat"], pos["lon"], API_KEY)
    print(url)
    weather_text = u'%sは%sの風、風力%s。%s。天気は%s。降水確率は、%s%です。'
    now_text = u'現在の気温は%s度、湿度%s%で体感温度は、%s度です。'
    temperature_text = u'%sの予想最高気温は、%s度。予想最低気温は、%s度、湿度は、%s%です。'
    try:
        obj = requests.get(url).json()
        c = obj["current"]
        d = obj["daily"]
        #today
        Nt = d[0]["temp"]
        pop = round(d[0]["pop"] * 100)
        wdn = c["wind_deg"]
        wdn = deg_speed.wdeg(wdn)
        wsn = c["wind_speed"]
        (wsn,wsna) = deg_speed.wspeed(wsn)
        today_w = c["weather"][0]["description"]
        today_w_txt = weather_text % (u"今日", wdn, wsn, wsna, today_w, pop)
        today_t_txt = temperature_text % (u"今日", round(Nt["max"], 1), round(Nt["min"], 1), d[0]["humidity"])
        today_n_txt = now_text % (round(c["temp"], 1), c["humidity"], round(c["feels_like"], 1))
        #say
        opening_str = "業務開始時間は、%s時%s分です。今日も、がんばりましょう。" % (da.hour, da.minute)
        weather_str = opening_str + "今日の天気です。" + today_w_txt + ' ' + today_n_txt + ' ' + today_t_txt
        weather_str = weather_str.replace("-", "マイナス")
        text = '''%s '%s' ''' % (CMD_SAY, weather_str)
        print(text)
        proc = subprocess.Popen(shlex.split(text))
        proc.communicate()
    except:
        return False
    return True

def say_weather_tommorrow(pos):
    url = "https://api.openweathermap.org/data/2.5/onecall?lat=%s&lon=%s&appid=%s&units=metric&lang=ja&exclude=hourly" % (pos["lat"], pos["lon"], API_KEY)
    print(url)
    weather_text = u'%sは%sの風、風力%s。%s。天気は%s。降水確率は、%s%です。'
    temperature_text = u'%sの予想最高気温は、%s度。予想最低気温は、%s度、湿度は、%s%です。'
    try:
        obj = requests.get(url).json()
        d = obj["daily"]
        #tommorow
        Nt = d[1]["temp"]
        pop = round(d[1]["pop"] * 100)
        wdt = d[1]["wind_deg"]
        wdt = deg_speed.wdeg(wdt)
        wst = d[1]["wind_speed"]
        (wst,wsna) = deg_speed.wspeed(wst)
        tommorow_w = d[1]["weather"][0]["description"]
        tommorow_w_txt = weather_text % (u"明日", wdt, wst, wsna, tommorow_w, pop)
        tommorow_t_txt = temperature_text % (u"明日", round(Nt["max"], 1), round(Nt["min"], 1), d[1]["humidity"])
        #say
        opening_str = "業務終了時間は、%s時%s分です。今日も、おつかれさまでした。" % (da.hour, da.minute)
        weather_str = opening_str + "明日の天気です。" + tommorow_w_txt + ' ' + tommorow_t_txt
        weather_str = weather_str.replace("-", "マイナス")
        text = '''%s '%s' ''' % (CMD_SAY, weather_str)
        print(text)
        proc = subprocess.Popen(shlex.split(text))
        proc.communicate()
    except:
        return False
    return True

def check_lunch():
    lunch = celllib.getTagValue("lunch")
    if lunch == 0:
        res = celllib.putTagValue("working", "1")
        res = celllib.putTagValue("lunch"  , "1")
        say_news()
    else:
        res = celllib.putTagValue("working", "1")
        res = celllib.putTagValue("lunch"  , "0")
        say_endlunch()
    return

def say_news():
    RSS_URL = 'https://www.nhk.or.jp/rss/news/cat0.xml'
    d = feedparser.parse(RSS_URL)
    for i, entry in enumerate(d.entries):
        newstime = entry.published_parsed
        newstime = datetime(newstime[0],newstime[1],newstime[2],newstime[3],newstime[4],newstime[5])
        if i == 0:
            opening_str = "%s時%s分です。昼休みです。おつかれさまです。" % (da.hour, da.minute)
            text = opening_str + "ニュースです。" + entry.summary
        else:
            text = "次のニュースです。" + entry.summary
        text = CMD_SAY2 + ' '+ text
        if da.timestamp() - newstime.timestamp() <= 172800:
            print(text)
            proc = subprocess.Popen(shlex.split(text))
            proc.communicate()
    return

def say_endlunch():
    opening_str = "%s時%s分です。昼休み終了です。残りもがんばりましょう。" % (da.hour, da.minute)
    text = CMD_SAY + ' ' + opening_str
    print(text)
    proc = subprocess.Popen(shlex.split(text))
    proc.communicate()
    return

### Execute
if __name__ == "__main__":
    main()

celllib.py

ソラコムのAPIにアクセスする関数群です。

celllib.py
#!/usr/bin/env python
import requests

#get API key and token
def getApiKey(email, password):
    headers = {
        'accept': 'application/json',
        'Content-Type': 'application/json',
    }
    data = '{ "email": "' + email + '", "password": "' + password + '" }'
    url = 'https://api.soracom.io/v1/auth'
    try:
        res = requests.post(url, headers=headers, data=data)
        res.raise_for_status()
        return res.json()
    except:
        return None

#get cell ID (use metadata service)
def getCellInfo():
    url = 'http://metadata.soracom.io/v1/subscriber.sessionStatus.cell'
    try:
        res = requests.get(url)
        res.raise_for_status()
        return res.json()
    except:
        return None

#get lat and lot
def getPosition(api, cell):
    if api is None or cell is None:
        return None
    headers = {
        'accept': 'application/json',
        'X-Soracom-API-Key': api['apiKey'],
        'X-Soracom-Token': api['token'],
    }
    params = (
        ('mcc' , cell['mcc']),
        ('mnc' , cell['mnc']),
        ('tac' , cell['tac']),
        ('ecid', cell['eci']),
    )
    url = 'https://api.soracom.io/v1/cell_locations'
    try:
        res = requests.get(url, headers=headers, params=params)
        res.raise_for_status()
        return res.json()
    except:
        return None

#get tags (use metadata service)
def getTagValue(name):
    url = 'http://metadata.soracom.io/v1/subscriber.tags.' + name
    try:
        res = requests.get(url)
        res.raise_for_status()
        return res.json()
    except:
        return None

#put Value to Tags
def putTagValue(name, value):
    if name is None or value is None:
        return None
    headers = {
        'Content-Type': 'application/json',
    }
    data = '[ { "tagName": "' + name + '", "tagValue": "' + value + '" }]'
    url = 'http://metadata.soracom.io/v1/subscriber/tags'
    try:
        res = requests.put(url, headers=headers, data=data)
        res.raise_for_status()
        return res.json()
    except:
        return None

deg_speed.py

OpenWeatherMapの風の情報を日本語に置き換えるプログラムモジュールです。
下記のサイトに掲載されているプログラムををそのまま使わせていただいていますので、下記のサイトより引用してください。
Raspberry Piに現在時刻、天気予報、ニュースを喋らせる

プログラム実行

beacon_start.shを実行してください。ラズパイのBLE受信が始まります。

業務開始(ボタン1クリック)

ビーコンボタンを1クリックすると、業務開始時間、励まし、今日の天気予報の順にスピーカーから出力されます。
同時に、ソラコムのコンソールのタグ情報が、"working"="1"に書き換わります。

昼休み開始(ボタン長押し その1)

ビーコンボタンを長押しすると、現在時間、いたわり、今日のニュース一覧の順にスピーカーから出力されます。
同時に、ソラコムのコンソールのタグ情報が、”lunch”="1"に書き換わります。

昼休み終了(ボタン長押し その2)

再度ビーコンボタンを長押しすると、現在時間を伝えた後、昼からも仕事を頑張るよう、励ましてくれます。
同時に、ソラコムのコンソールのタグ情報が、”lunch”="0"に書き換わります。

業務終了(ボタン2クリック)

ビーコンボタンを2クリックすると、業務終了時間、いたわり、明日の天気予報の順にスピーカーから出力されます。
同時に、ソラコムのコンソールのタグ情報が、"working"="0"に書き換わります。

プログラムの説明(デバイスの位置情報の取得)

OpenWeatheMapは、取得する天気予報の地点として、都市のID、Zipコード、緯度・経度などの指定が可能です。
よくあるプログラム例ではこの値をあらかじめ調べておいてから、データ取得を行っています。
今回は、SORACOMのAPIを使用して、デバイスが接続している基地局の緯度・経度の情報を自動的に入手しました。
基地局の位置情報取得API
上記ブログに書かれているように、この緯度・経度は必ずしも厳密な値ではありませんが、OpenWeatheMapで必要なのは都市のサイズの位置情報ですので、これで充分だと思います。
手順としては、下記の二段階で情報を取得します。
・メタデータサービスの情報から、基地局IDほかの値の入手。

#get cell ID (use metadata service)
def getCellInfo():
    url = 'http://metadata.soracom.io/v1/subscriber.sessionStatus.cell'
    try:
        return requests.get(url).json()
    except:
        return None

・SORACOM API (cell_locations) で基地局の緯度・経度の情報入手。
引数のcellが先ほど取得した基地局IDほかの情報。apiは別途ほかの関数(getApiKey)で取得した、API-keyとTokenの情報です。

#get lat and lot
def getPosition(api, cell):
    if api is None or cell is None:
        return None

    headers = {
        'accept': 'application/json',
        'X-Soracom-API-Key': api['apiKey'],
        'X-Soracom-Token': api['token'],
    }
    params = (
        ('mcc' , cell['mcc']),
        ('mnc' , cell['mnc']),
        ('tac' , cell['tac']),
        ('ecid', cell['eci']),
    )
    url = 'https://api.soracom.io/v1/cell_locations'
    try:
        return requests.get(url, headers=headers, params=params).json()
    except:
        return None

今後の改善案

次は以下の情報を参考にして、稼働情報をslackに反映するようにしたいです。
SORACOM レシピ:IoTで在席状況の自動更新

→ 2021/7/18にリリースしました!! 詳しくはこちら
BLEビーコンとソラコムAPIでつい押してみたくなる出退勤記録ボタンを作る(Slack通知機能追加版)

参考リンク

  • beacon受信

 部屋に入るとファミマの入店音が鳴るアプリ
 Raspberry PiでiBeaconを受信する

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3