こんにちは、ユニファの赤沼です。みなさんのオフィスではセキュリティはどうされてますでしょうか?ユニファではエントランスから会議室エリアもしくは執務室へ入る場合にはICカードによるドアロックを設置しています。ただ、会議室エリアと執務室を隔てる扉は、いずれのエリアもセキュリティ通過後であるため特に何もしていないのですが、開きっぱなしになっているとお客様が会議室エリアを通られる際に執務室の中も見えてしまうことになり、あまりよろしくありません。そこで今回はドアセンサーを自作して、ひとまずは開きっぱなしになっていることを検知できるようにしてみたいと思います。
構成
全体の構成としては今回はひとまずシンプルに、インターネットへのアクセスは行わず、Raspberry Pi を Central、 ドアセンサーを Peripheral として BLE で接続し、一定時間以上ドアが開きっぱなしの状態だったら Raspberry Pi にログを出力してみます。
ドアセンサーの構成としては、今回はホールセンサーを使用し、ドア側に貼り付けた磁石と壁側に設置したホールセンサーの距離が離れた場合をドアが開いているとみなします。ホールセンサーとは磁石や電流が発する磁界を電気信号に変えるセンサーで、下記サイトに詳しい解説が掲載されていました。
www.fujiele.co.jp
今回使用したホールセンサーは下記製品で、ユニポーラのスイッチタイプでS極を近づけると出力がLowになります。
www.aitendo.com
今回はBLEモジュール搭載の開発ボードとして BLE Nano を使用します。ホールセンサーをブレッドボードで BLE Nano と下記の図のように接続します。左上の黒い三本足のパーツがホールセンサーです。LED は動作確認用で、ドアが開いているときは赤く点灯させます。また、今回のホールセンサーは動作電圧が4.5V〜24Vで、 BLE Nano の出力の 3.3V では電圧が不足するため、単三電池4本を電源としてホールセンサーと BLE Nano に電力を供給します。
BLE Nano のファームウェア実装
BLE Nano は mbed OS に対応しているので、今回は mbed でファームウェアを実装します。ただ mbed OS 5 には対応していないので、 mbed OS 2 での実装になります。開発環境の構築やプロジェクトの作成については今回は割愛させていただきます。
まずホールセンサーの値を扱うための BLE のサービスを下記のように hall_service.h として実装します。今回はとりあえずの社内利用だけなので、 Characteristic としてはホールセンサーの値だけで、 UUID も適当にしてしまっています。
#ifndef __HALL_SERVICE_H__
#define __HALL_SERVICE_H__
class HallService {
public:
const static uint16_t HALL_SERVICE_UUID = 0xA000;
const static uint16_t HALL_VALUE_CHARACTERISTIC_UUID = 0xA001;
HallService(BLE &_ble, uint16_t initialValueForHallCharacteristic) :
ble(_ble), hallValue(HALL_VALUE_CHARACTERISTIC_UUID, &initialValueForHallCharacteristic, GattCharacteristic::BLE_GATT_CHAR_PROPERTIES_NOTIFY)
{
GattCharacteristic *charTable[] = {&hallValue};
GattService hallService(HALL_SERVICE_UUID, charTable, sizeof(charTable) / sizeof(GattCharacteristic *));
ble.gattServer().addService(hallService);
}
void updateHallValue(unsigned short newValue) {
ble.gattServer().write(hallValue.getValueHandle(), (uint8_t *)&newValue, sizeof(bool));
}
private:
BLE &ble;
ReadOnlyGattCharacteristic<uint16_t> hallValue;
};
#endif
そして main.cpp を下記のように実装します。
#include "mbed.h"
#include "ble/BLE.h"
#include "hall_service.h"
Serial pc(USBTX, USBRX);
DigitalOut led(P0_4, 0);
DigitalIn hallSensor(P0_5, PullUp);
BLE ble;
Ticker ticker;
const static char DEVICE_NAME[] = "DoorSensor";
static const uint16_t uuid16_list[] = {HallService::HALL_SERVICE_UUID};
static volatile bool triggerSensorPolling = false;
unsigned short hallValue;
HallService *hallServicePtr;
void disconnectionCallback(const Gap::DisconnectionCallbackParams_t *params)
{
BLE::Instance().gap().startAdvertising();
}
void periodicCallback(void)
{
if(hallValue != hallSensor.read()) {
hallValue = hallSensor.read();
led = hallValue;
triggerSensorPolling = true;
}
}
void onBleInitError(BLE &ble, ble_error_t error)
{
}
void bleInitComplete(BLE::InitializationCompleteCallbackContext *params)
{
BLE& ble = params->ble;
ble_error_t error = params->error;
if (error != BLE_ERROR_NONE) {
onBleInitError(ble, error);
return;
}
if (ble.getInstanceID() != BLE::DEFAULT_INSTANCE) {
return;
}
ble.gap().setDeviceName((const uint8_t *) DEVICE_NAME);
ble.gap().onDisconnection(disconnectionCallback);
uint16_t initialValueForHallCharacteristic = 0;
hallServicePtr = new HallService(ble, initialValueForHallCharacteristic);
ble.gap().accumulateAdvertisingPayload(GapAdvertisingData::BREDR_NOT_SUPPORTED | GapAdvertisingData::LE_GENERAL_DISCOVERABLE);
ble.gap().accumulateAdvertisingPayload(GapAdvertisingData::COMPLETE_LIST_16BIT_SERVICE_IDS, (uint8_t *)uuid16_list, sizeof(uuid16_list));
ble.gap().accumulateAdvertisingPayload(GapAdvertisingData::COMPLETE_LOCAL_NAME, (uint8_t *)DEVICE_NAME, sizeof(DEVICE_NAME));
ble.gap().setAdvertisingType(GapAdvertisingParams::ADV_CONNECTABLE_UNDIRECTED);
ble.gap().setAdvertisingInterval(1000);
ble.gap().startAdvertising();
}
int main(void)
{
pc.printf("Starting door_sensor...\r\n");
ticker.attach(periodicCallback, 1);
ble.init(bleInitComplete);
while (ble.hasInitialized() == false) { }
while(true) {
if (triggerSensorPolling && ble.getGapState().connected) {
triggerSensorPolling = false;
pc.printf("%u\r\n", hallValue);
hallServicePtr->updateHallValue(hallValue);
} else {
ble.waitForEvent();
}
}
}
詳細な説明は割愛しますが、毎秒ホールセンサーの値を読み取り、値が変わっていたら BLE の Characteristic が持つ値を更新し、LEDの点灯状態も更新しています。ここまでの内容でビルドして BLE Nano に書き込むとひとまず Peripheral 側としては動作するようになり、下記動画のようにドアの開閉でLEDが点灯/消灯します。
Raspberry Pi 側のアプリ実装
では次に Central 側になる Raspberry Pi のアプリケーション実装です。ユニファのサービスではサーバ側は Ruby で書かれているということもあり、Ruby で実装してみます。 まずは BLE デバイスをある程度汎用的に扱えるように以前に実装していた BLE クラスがあるのでこれを使用します。 BlueZ による BLE サービスへのアクセスをラップしたもので、BLE デバイスへアクセスするときに共通的に使用する処理を実装してあります。
require 'bundler/setup'
require 'dbus'
class BLE
attr_reader :bus
SERVICE_NAME = 'org.bluez'
SERVICE_PATH = '/org/bluez'
ADAPTER = 'hci0'
DEVICE_IF = 'org.bluez.Device1'
SERVICE_IF = 'org.bluez.GattService1'
CHARACTERISTIC_IF = 'org.bluez.GattCharacteristic1'
DBUS_PROPERTIES_IF = 'org.freedesktop.DBus.Properties'
SERVICE_RESOLVED_PROPERTY = 'ServicesResolved'
UUID_PROPERTY = 'UUID'
PROPERTIES_CHANGED_SIGNAL = 'PropertiesChanged'
SERVICE_RESOLVE_CHECK_INTERVAL = 0.1
DISCOVERY_WAITING_SECOND = 20
module UUID
GENERIC_ATTRIBUTE_SERVICE = '00001801-0000-1000-8000-00805f9b34fb'
DEVICE_INFORMATION_SERVICE = '0000180a-0000-1000-8000-00805f9b34fb'
BATTERY_SERVICE = '0000180f-0000-1000-8000-00805f9b34fb'
BATTERY_DATA = '00002a19-0000-1000-8000-00805f9b34fb'
end
class Device
attr_reader :bluez, :name, :address
def initialize(bluez, bluez_device, name, address, rssi = nil)
@bluez = bluez
@bluez_device = bluez_device
@name = name
@address = address
@rssi = rssi
end
def connect
@bluez_device.introspect
@bluez_device.Connect
@bluez_device.introspect
while !properties[SERVICE_RESOLVED_PROPERTY] do
sleep(SERVICE_RESOLVE_CHECK_INTERVAL)
end
@name = properties['Name']
end
def disconnect
@bluez_device.Disconnect
end
def properties
@bluez_device.introspect
@bluez_device.GetAll(DEVICE_IF).first
end
def services
services = []
@bluez_device.subnodes.each do |node|
service = @bluez.object("#{@bluez_device.path}/#{node}")
service.introspect
properties = service.GetAll(SERVICE_IF).first
services << Service.new(@bluez, service, properties[UUID_PROPERTY])
end
services
end
def service_by_uuid(uuid)
services.each do |service|
return service if service.uuid == uuid
end
raise 'Service not found.'
end
def read_battery_level_once
service = service_by_uuid(BLE::UUID::BATTERY_SERVICE)
characteristic = service.characteristic_by_uuid(BLE::UUID::BATTERY_DATA)
characteristic.read.first
end
def read_battery_level
service = service_by_uuid(BLE::UUID::BATTERY_SERVICE)
characteristic = service.characteristic_by_uuid(BLE::UUID::BATTERY_DATA)
yield(characteristic.read.first)
characteristic.start_notify do |v|
yield(v.first)
end
end
end
class Service
attr_reader :uuid
def initialize(bluez, bluez_service, uuid)
@bluez = bluez
@bluez_service = bluez_service
@uuid = uuid
end
def properties
@bluez_service.introspect
@bluez_service.GetAll(SERVICE_IF).first
end
def characteristics
characteristics = []
@bluez_service.subnodes.each do |node|
characteristic = @bluez.object("#{@bluez_service.path}/#{node}")
characteristic.introspect
properties = characteristic.GetAll(CHARACTERISTIC_IF).first
characteristics << Characteristic.new(characteristic, properties[UUID_PROPERTY])
end
characteristics
end
def characteristic_by_uuid(uuid)
characteristics.each do |characteristic|
return characteristic if characteristic.uuid == uuid
end
raise 'Characteristic not found.'
end
end
class Characteristic
attr_reader :uuid
def initialize(bluez_characteristic, uuid)
@bluez_characteristic = bluez_characteristic
@uuid = uuid
end
def properties
@bluez_characteristic.introspect
@bluez_characteristic.GetAll(CHARACTERISTIC_IF).first
end
def start_notify
@bluez_characteristic.StartNotify
@bluez_characteristic.default_iface = DBUS_PROPERTIES_IF
@bluez_characteristic.on_signal(PROPERTIES_CHANGED_SIGNAL) do |_, v|
yield(v['Value'])
end
end
def write(value)
@bluez_characteristic.WriteValue(value, {})
end
def read
@bluez_characteristic.ReadValue({}).first
end
def inspect
@bluez_characteristic.inspect
end
end
def initialize
@bus = DBus::system_bus
@bluez = @bus.service(SERVICE_NAME)
@adapter = @bluez.object("#{SERVICE_PATH}/#{ADAPTER}")
@adapter.introspect
end
def devices
@adapter.StartDiscovery
sleep(DISCOVERY_WAITING_SECOND)
devices = []
@adapter.introspect
@adapter.subnodes.each do |node|
device = @bluez.object("#{SERVICE_PATH}/#{ADAPTER}/#{node}")
device.introspect
next unless device.respond_to?(:GetAll)
properties = device.GetAll(DEVICE_IF).first
name = properties['Name']
address = properties['Address']
rssi = properties['RSSI']
next if name.nil? || rssi.nil?
devices << Device.new(@bluez, device, name, address, rssi)
end
@adapter.StopDiscovery
devices
end
def device_by_name(name)
devices.each do |device|
return device if device.name.downcase.include?(name.downcase)
end
raise 'No devices found.'
end
def devices_by_name(name)
devices.select do |device|
device.name.downcase.include?(name.downcase)
end
end
end
上記の BLE クラスを利用する形で、今回のドアセンサーを実現するための DoorSensor クラスを下記のように実装します。クラス設計や定数の持ち方などは正直かなり適当ですが、とりあえず動くので良しとします。
require 'bundler/setup'
require './ble.rb'
class DoorSensor
attr_accessor :opend_at
DEVICE_NAME = 'DoorSensor'
module UUID
DOOR_SENSOR_SERVICE = '0000a000-0000-1000-8000-00805f9b34fb'
DOOR_SENSOR_VALUE = '0000a001-0000-1000-8000-00805f9b34fb'
end
def self.find_all
ble = BLE.new
devices = ble.devices_by_name(DEVICE_NAME)
devices.map do |device|
device.services
DoorSensor.new(ble, device)
end
end
def initialize(ble, device)
@ble = ble
@device = device
@status = 0
@opend_at = nil
end
def name
@device.name
end
def address
@device.address
end
def connect
@device.connect
end
def read_door_sensor_value
service = @device.service_by_uuid(DoorSensor::UUID::DOOR_SENSOR_SERVICE)
characteristic = service.characteristic_by_uuid(DoorSensor::UUID::DOOR_SENSOR_VALUE)
characteristic.start_notify do |door_sensor_value|
yield(door_sensor_value.first)
end
end
def run
main = DBus::Main.new
main << @ble.bus
main.run
end
def monitor(log)
Thread.new do
begin
loop do
if !@opend_at.nil? && Time.now - @opend_at > 30
log.warn('The door is still opening!')
end
sleep(5)
end
rescue => e
log.error(e.backtrace.join("\n"))
end
end
end
def disconnect
@device.disconnect
end
end
if $0 == __FILE__
log = Logger.new('logs/door_sensor.log')
log.debug('Finding DoorSensor...')
door_sensors = DoorSensor.find_all
log.debug("#{door_sensors.size} DoorSensors found.")
begin
door_sensor = door_sensors.first
log.debug("Connecting to #{door_sensor.name}: #{door_sensor.address}")
door_sensor.connect
log.debug("Connected. #{door_sensor.name}")
door_sensor.read_door_sensor_value do |door_sensor_value|
if door_sensor_value == 1
door_sensor.opend_at = Time.now
else
door_sensor.opend_at = nil
end
@status = door_sensor_value
log.info("#{door_sensor.address}: #{door_sensor_value}")
end
door_sensor.monitor(log)
door_sensor.run
rescue => e
log.error(e.backtrace.join("\n"))
puts e
ensure
door_sensor.disconnect
end
end
Peripheral(BLE Nano)側でセンサーの値が変わるとシグナルが送られてくるので、 read_door_sensor_value メソッド内でそれを待ち受け、ドアが開いたときにはその時間を記録しておきます。また別スレッドでは記録された時間を5秒ごとに監視し、30秒以上経っている場合にはログにWARNを出力します。これを $ bundle exec ruby door_sensor.rb &
という感じで実行すると、下記のようにログが出力されていきます。
D, [2017-12-18T23:16:31.459778 #11409] DEBUG -- : Finding DoorSensor...
D, [2017-12-18T23:16:57.241822 #11409] DEBUG -- : 1 DoorSensors found.
D, [2017-12-18T23:16:57.242144 #11409] DEBUG -- : Connecting to DoorSensor: F4:CB:D1:35:5E:BA
D, [2017-12-18T23:16:59.618002 #11409] DEBUG -- : Connected. DoorSensor
I, [2017-12-18T23:20:05.662297 #11409] INFO -- : F4:CB:D1:35:5E:BA: 1
I, [2017-12-18T23:20:11.669245 #11409] INFO -- : F4:CB:D1:35:5E:BA: 0
I, [2017-12-18T23:20:17.676412 #11409] INFO -- : F4:CB:D1:35:5E:BA: 1
I, [2017-12-18T23:20:27.666568 #11409] INFO -- : F4:CB:D1:35:5E:BA: 0
I, [2017-12-18T23:20:31.649165 #11409] INFO -- : F4:CB:D1:35:5E:BA: 1
W, [2017-12-18T23:21:04.905865 #11409] WARN -- : The door is still opening!
W, [2017-12-18T23:21:09.906403 #11409] WARN -- : The door is still opening!
W, [2017-12-18T23:21:14.906943 #11409] WARN -- : The door is still opening!
W, [2017-12-18T23:21:19.907616 #11409] WARN -- : The door is still opening!
I, [2017-12-18T23:21:22.679355 #11409] INFO -- : F4:CB:D1:35:5E:BA: 0
まとめ
今回はとりあえずログに出力しているだけですが、 Raspberry Pi が Wi-Fi に繋がっていれば色々なことができるので、 Slack へのメッセージ通知などをできるようにしてみたいと思います。あと、今は回路もブレッドボードにさしてるだけですが、抜けやすかったりで心配な面もあるので、ある程度確認できたら基盤に半田付けして実装してみたいと思います。