ユニファ開発者ブログ

ユニファ株式会社システム開発部メンバーによるブログです。

BLE Nano + ホールセンサー + Raspberry Pi でドアセンサーを作る

 こんにちは、ユニファの赤沼です。みなさんのオフィスではセキュリティはどうされてますでしょうか?ユニファではエントランスから会議室エリアもしくは執務室へ入る場合には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 に電力を供給します。

f:id:akanuma-hiroaki:20171219064313p:plain:w300

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 へのメッセージ通知などをできるようにしてみたいと思います。あと、今は回路もブレッドボードにさしてるだけですが、抜けやすかったりで心配な面もあるので、ある程度確認できたら基盤に半田付けして実装してみたいと思います。