TEXAS INSTRUMENTS の SimpleLink SensorTag CC2650 から BLE でデータ取得

 TEXAS INSTRUMENTS の SimpleLink CC2650 というセンサータグを買ってみたので、Raspberry Pi から BLE でセンサーデータを取得してみたいと思います。

www.tij.co.jp

 SimpleLink SensorTag CC2650 は気温、湿度、気圧、加速度、ジャイロ、磁気、照度などのセンサーを搭載しています。

 外箱は下記のような感じです。

f:id:akanuma-hiroaki:20170717174130j:plain:w450

 デバイス本体は下記のようになります。写真だと大きく見えてしまいますが、約5cm x 4cm ぐらいの大きさです。

f:id:akanuma-hiroaki:20170717174141j:plain:w450

スマートフォンアプリからの接続

 まずは TEXAS INSTRUMENTS のセンサータグ用スマートフォンアプリからの接続を試してみたいと思います。自分で開発しなくても手軽にセンサーが取得しているデータを確認することができます。

TI SensorTag

TI SensorTag

  • Texas Instruments
  • ユーティリティ
  • 無料

 アプリを起動して、SensorTagの側面にあるスイッチを押すと Advertising が始まり、リストにSensorTagが表示されます。そこからセンサーが取得しているデータや、GATT Service や Characteristic の UUID を確認できます。

f:id:akanuma-hiroaki:20170717174406p:plain:w300 f:id:akanuma-hiroaki:20170717174413p:plain:w300 f:id:akanuma-hiroaki:20170717174420p:plain:w300 f:id:akanuma-hiroaki:20170717174427p:plain:w300

Raspberry Pi からの接続

 それでは Raspberry Pi から SensorTag に接続して、データを取得してみたいと思います。基本的な処理内容は以前の記事(D-BusからBLEデバイスのNotificationを受け取る)で試したものと同様ですが、今回はデバイス固有の処理とBLEデバイス共通の処理を分割してみました。

 まずはBLEデバイス共通の処理のコード全体を載せておきます。BlueZによる処理をラップする形で、Device、Service、Characteristic のクラスを用意しています。エラーハンドリングはまだ考慮してません。Bluetoothのインタフェースもとりあえず hci0 を直接指定しています。

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       = 10 # デバイス検出の待機時間

  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)
      @bluez        = bluez
      @bluez_device = bluez_device
      @name         = name
      @address      = address
    end

    # Device への接続処理。接続後に GATT Service が解決状態になるまで待機する
    def connect
      @bluez_device.introspect
      @bluez_device.Connect
      @bluez_device.introspect

      while !properties[SERVICE_RESOLVED_PROPERTY] do
        sleep(SERVICE_RESOLVE_CHECK_INTERVAL)
      end
    end

    def disconnect
      @bluez_device.Disconnect
    end

    def properties
      @bluez_device.introspect
      @bluez_device.GetAll(DEVICE_IF).first
    end

    # Device が持つ GATT Service のリストを返す
    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
      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

    # Service が持つ Characteristic のリストを返す
    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

    # Characteristic のプロパティに変更があった時にシグナルを受け取る
    # ブロックを渡してシグナル検知時にブロックを実行する
    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

  # Bluetoothアダプタ配下のBLEデバイスを検出して Device クラスのインスタンスのリストを返す
  # デバイス名や RSSI が nil のデバイスは除外
  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)
    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
end

 BLEクラスを new して #device_by_name メソッドにデバイス名を渡すと、デバイス名を含んだBLEデバイスを検出して Device クラスのインスタンスとして返します。

 次にSensorTag固有の処理のコードです。こちらもまだエラーハンドリングは考慮してません。SensorTag クラスには基本的に各センサーを有効化する処理と、センサーデータ検出時にデータをコールバックのブロックに受け渡す処理を実装しています。各センサーから検出されるデータは生データなので、実際に利用するにはコンバートする必要があります。コンバート処理の内容はいろいろ調べたのですが間違っているかもしれないので、参考までということで。

require 'bundler/setup'
require './ble.rb' # 前述のBLEデバイス共通処理を読み込む

class SensorTag
  DEVICE_NAME = 'CC2650'
  SCALE_LSB = 0.03125

  module UUID
    IR_TEMPERATURE_SERVICE     = 'f000aa00-0451-4000-b000-000000000000'
    HUMIDITY_SERVICE           = 'f000aa20-0451-4000-b000-000000000000'
    BAROMETER_SERVICE          = 'f000aa40-0451-4000-b000-000000000000'
    MOVEMENT_SERVICE           = 'f000aa80-0451-4000-b000-000000000000'
    LUXOMETER_SERVICE          = 'f000aa70-0451-4000-b000-000000000000'
    SIMPLE_KEYS_SERVICE        = '0000ffe0-0000-1000-8000-00805f9b34fb'
    IO_SERVICE                 = 'f000aa64-0451-4000-b000-000000000000'
    REGISTER_SERVICE           = 'f000ac00-0451-4000-b000-000000000000'
    CONNECTION_CONTROL_SERVICE = 'f000ccc0-0451-4000-b000-000000000000'
    OAT_SERVICE                = 'f000ffc0-0451-4000-b000-000000000000'

    IR_TEMPERATURE_CONFIG = 'f000aa02-0451-4000-b000-000000000000'
    IR_TEMPERATURE_DATA   = 'f000aa01-0451-4000-b000-000000000000'
    HUMIDITY_CONFIG       = 'f000aa22-0451-4000-b000-000000000000'
    HUMIDITY_DATA         = 'f000aa21-0451-4000-b000-000000000000'
    MOVEMENT_CONFIG       = 'f000aa82-0451-4000-b000-000000000000'
    MOVEMENT_DATA         = 'f000aa81-0451-4000-b000-000000000000'
    BAROMETER_CONFIG      = 'f000aa42-0451-4000-b000-000000000000'
    BAROMETER_DATA        = 'f000aa41-0451-4000-b000-000000000000'
    LUXOMETER_CONFIG      = 'f000aa72-0451-4000-b000-000000000000'
    LUXOMETER_DATA        = 'f000aa71-0451-4000-b000-000000000000'
    IO_CONFIG             = 'f000aa66-0451-4000-b000-000000000000'
    IO_DATA               = 'f000aa65-0451-4000-b000-000000000000'
  end

  # SensorTag に接続
  def connect
    @ble = BLE.new
    @device = @ble.device_by_name(DEVICE_NAME)
    @device.connect
  end

  # 各センサーの有効化のための処理
  def enable(service, config_uuid, value = [0x01])
    characteristic = service.characteristic_by_uuid(config_uuid)
    characteristic.write(value)
  end

  def enable_ir_temperature
    service = @device.service_by_uuid(SensorTag::UUID::IR_TEMPERATURE_SERVICE)
    enable(service, SensorTag::UUID::IR_TEMPERATURE_CONFIG)
  end

  def enable_humidity
    service = @device.service_by_uuid(SensorTag::UUID::HUMIDITY_SERVICE)
    enable(service, SensorTag::UUID::HUMIDITY_CONFIG)
  end

  def enable_movement
    service = @device.service_by_uuid(SensorTag::UUID::MOVEMENT_SERVICE)
    enable(service, SensorTag::UUID::MOVEMENT_CONFIG, [0b11111111, 0b00000000])
  end

  def enable_barometer
    service = @device.service_by_uuid(SensorTag::UUID::BAROMETER_SERVICE)
    enable(service, SensorTag::UUID::BAROMETER_CONFIG)
  end

  def enable_luxometer
    service = @device.service_by_uuid(SensorTag::UUID::LUXOMETER_SERVICE)
    enable(service, SensorTag::UUID::LUXOMETER_CONFIG)
  end

  # 各センサーの生データをコンバートする処理
  def convert_ir_temperature_value(upper_byte, lower_byte)
    (((upper_byte << 8) + lower_byte) >> 2) * SCALE_LSB
  end

  def convert_temp_value(upper_byte, lower_byte)
    temp_byte = (upper_byte << 8) + lower_byte
    (temp_byte / 65536.0) * 165 - 40
  end

  def convert_humidity_value(upper_byte, lower_byte)
    hum_byte = (upper_byte << 8) + lower_byte
    (hum_byte / 65536.0) * 100
  end

  def convert_movement_value(upper_byte, lower_byte)
      value = (upper_byte << 8) + lower_byte
      if upper_byte > 0x7f
        value = ~value + 1
      end
      (value >> 2).to_f
  end

  def convert_gyro_value(upper_byte, lower_byte)
    convert_movement_value(upper_byte, lower_byte) / 128.0
  end

  def convert_acc_value(upper_byte, lower_byte)
    convert_movement_value(upper_byte, lower_byte) / (32768 / 2)
  end

  def convert_mag_value(upper_byte, lower_byte)
    convert_movement_value(upper_byte, lower_byte) * 4912.0 / 32768.0
  end

  def convert_barometer_value(upper_byte, middle_byte, lower_byte)
    ((upper_byte << 16) + (middle_byte << 8) + lower_byte) / 100.0
  end

  def convert_luxometer_value(upper_byte, lower_byte)
    lux = (upper_byte << 8) + lower_byte

    m = lux & 0x0fff
    e = (lux & 0xf000) >> 12
    e = (e == 0) ? 1 : 2 << (e - 1)
    m * (0.01 * e)
  end

  # 各センサーのデータを読み取り、コンバート後のデータをコールバックに渡す
  def read_ir_temperature
    service = @device.service_by_uuid(SensorTag::UUID::IR_TEMPERATURE_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::IR_TEMPERATURE_DATA)
    characteristic.start_notify do |v|
      amb_lower_byte = v[2]
      amb_upper_byte = v[3]
      ambient = convert_ir_temperature_value(amb_upper_byte, amb_lower_byte)

      obj_lower_byte = v[0]
      obj_upper_byte = v[1]
      object = convert_ir_temperature_value(obj_upper_byte, obj_lower_byte)

      yield(ambient, object)
    end
  end

  def read_humidity
    service = @device.service_by_uuid(SensorTag::UUID::HUMIDITY_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::HUMIDITY_DATA)
    characteristic.start_notify do |v|
      temp_lower_byte = v[0]
      temp_upper_byte = v[1]
      temp = convert_temp_value(temp_upper_byte, temp_lower_byte)

      hum_lower_byte = v[2]
      hum_upper_byte = v[3]
      hum = convert_humidity_value(hum_upper_byte, hum_lower_byte)

      yield(temp, hum)
    end
  end

  def read_movement
    service = @device.service_by_uuid(SensorTag::UUID::MOVEMENT_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::MOVEMENT_DATA)
    characteristic.start_notify do |v|
      gyro_x_lower = v[0]
      gyro_x_upper = v[1]
      gyro_y_lower = v[2]
      gyro_y_upper = v[3]
      gyro_z_lower = v[4]
      gyro_z_upper = v[5]

      gyro_x = convert_gyro_value(gyro_x_upper, gyro_x_lower)
      gyro_y = convert_gyro_value(gyro_y_upper, gyro_y_lower)
      gyro_z = convert_gyro_value(gyro_z_upper, gyro_z_lower)

      acc_x_lower = v[6]
      acc_x_upper = v[7]
      acc_y_lower = v[8]
      acc_y_upper = v[9]
      acc_z_lower = v[10]
      acc_z_upper = v[11]

      acc_x = convert_acc_value(acc_x_upper, acc_x_lower)
      acc_y = convert_acc_value(acc_y_upper, acc_y_lower)
      acc_z = convert_acc_value(acc_z_upper, acc_z_lower)

      mag_x_lower = v[12]
      mag_x_upper = v[13]
      mag_y_lower = v[14]
      mag_y_upper = v[15]
      mag_z_lower = v[16]
      mag_z_upper = v[17]

      mag_x = convert_mag_value(mag_x_upper, mag_x_lower)
      mag_y = convert_mag_value(mag_y_upper, mag_y_lower)
      mag_z = convert_mag_value(mag_z_upper, mag_z_lower)

      yield(gyro_x, gyro_y, gyro_z, acc_x, acc_y, acc_z, mag_x, mag_y, mag_z)
    end
  end

  def read_barometer
    service = @device.service_by_uuid(SensorTag::UUID::BAROMETER_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::BAROMETER_DATA)
    characteristic.start_notify do |v|
      temp_lower  = v[0]
      temp_middle = v[1]
      temp_upper  = v[2]
      temp = convert_barometer_value(temp_upper, temp_middle, temp_lower)

      press_lower  = v[3]
      press_middle = v[4]
      press_upper  = v[5]
      press = convert_barometer_value(press_upper, press_middle, press_lower)

      yield(temp, press)
    end
  end

  def read_luxometer
    service = @device.service_by_uuid(SensorTag::UUID::LUXOMETER_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::LUXOMETER_DATA)
    characteristic.start_notify do |v|
      lux_lower  = v[0]
      lux_upper  = v[1]
      lux = convert_luxometer_value(lux_upper, lux_lower)

      yield(lux)
    end
  end

  def read_battery_level
    @device.read_battery_level do |battery_level|
      yield(battery_level)
    end
  end

  # シグナルの待受を開始
  def run
    main = DBus::Main.new
    main << @ble.bus

    main.run
  end

  def disconnect
    @device.disconnect
  end
end

if $0 == __FILE__
  sensor_tag = SensorTag.new
  begin
    sensor_tag.connect

    ir_temperature_log = Logger.new('logs/ir_temperature.log')
    sensor_tag.enable_ir_temperature
    sensor_tag.read_ir_temperature do |ambient, object|
      ir_temperature_log.info("amb: #{ambient} obj: #{object}")
    end

    humidity_log = Logger.new('logs/humidity.log')
    sensor_tag.enable_humidity
    sensor_tag.read_humidity do |temp, hum|
      humidity_log.info("temp: #{temp} hum: #{hum}")
    end

    gyro_log = Logger.new('logs/gyro.log')
    acc_log = Logger.new('logs/acc.log')
    mag_log = Logger.new('logs/mag.log')
    sensor_tag.enable_movement
    sensor_tag.read_movement do |gyro_x, gyro_y, gyro_z, acc_x, acc_y, acc_z, mag_x, mag_y, mag_z|
      gyro_log.info("gyro: #{gyro_x} #{gyro_y} #{gyro_z}")
      acc_log.info("acc: #{acc_x} #{acc_y} #{acc_z}")
      mag_log.info("mag: #{mag_x} #{mag_y} #{mag_z}")
    end

    barometer_log = Logger.new('logs/barometer.log')
    sensor_tag.enable_barometer
    sensor_tag.read_barometer do |temp, press|
      barometer_log.info("temp: #{temp} press: #{press}")
    end

    lux_log = Logger.new('logs/lux.log')
    sensor_tag.enable_luxometer
    sensor_tag.read_luxometer do |lux|
      lux_log.info("lux: #{lux}")
    end

    battery_log = Logger.new('logs/battery.log')
    sensor_tag.read_battery_level do |battery_level|
      battery_log.info("battery: #{battery_level}")
    end

    sensor_tag.run
  rescue Interrupt => e
    puts e
  ensure
    sensor_tag.disconnect
  end
end

 今回はコールバックとして単純に各データをログに出力するようにしていますので、スクリプトを実行するとセンサーのデータが各ログファイルに出力されます。

f:id:akanuma-hiroaki:20170717215619p:plain

 データのコンバート処理はちゃんと実装する必要ありますが、SensorTag では手軽にいろいろなセンサーデータを取得できて面白いですね。今回はとりあえずデータを出力するだけでしたが、このデータを使って何か面白いことができないか考えてみたいと思います。