こんにちは、ユニファの赤沼です。みなさんのオフィスではセキュリティはどうされてますでしょうか?ユニファではエントランスから会議室エリアもしくは執務室へ入る場合にはICカードによるドアロックを設置しています。ただ、会議室エリアと執務室を隔てる扉は、いずれのエリアもセキュリティ通過後であるため特に何もしていないのですが、開きっぱなしになっているとお客様が会議室エリアを通られる際に執務室の中も見えてしまうことになり、あまりよろしくありません。そこで今回はドアセンサーを自作して、ひとまずは開きっぱなしになっていることを検知できるようにしてみたいと思います。
構成
全体の構成としては今回はひとまずシンプルに、インターネットへのアクセスは行わず、Raspberry Pi を Central、 ドアセンサーを Peripheral として BLE で接続し、一定時間以上ドアが開きっぱなしの状態だったら Raspberry Pi にログを出力してみます。
ドアセンサーの構成としては、今回はホールセンサーを使用し、ドア側に貼り付けた磁石と壁側に設置したホールセンサーの距離が離れた場合をドアが開いているとみなします。ホールセンサーとは磁石や電流が発する磁界を電気信号に変えるセンサーで、下記サイトに詳しい解説が掲載されていました。
今回使用したホールセンサーは下記製品で、ユニポーラのスイッチタイプでS極を近づけると出力がLowになります。
今回は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) { /* spin loop */ } 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 へのメッセージ通知などをできるようにしてみたいと思います。あと、今は回路もブレッドボードにさしてるだけですが、抜けやすかったりで心配な面もあるので、ある程度確認できたら基盤に半田付けして実装してみたいと思います。