├── .gitignore ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── README.md ├── Rakefile ├── bin └── home_assistant-ble ├── example_config.yml ├── home_assistant-ble.gemspec ├── home_assistant-ble.service └── lib └── home_assistant ├── ble.rb └── ble └── version.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Metrics/LineLength: 2 | Max: 140 3 | 4 | Metrics/MethodLength: 5 | Max: 25 6 | 7 | Metrics/ClassLength: 8 | Max: 200 9 | 10 | Style/Documentation: 11 | Enabled: false 12 | 13 | Metrics/AbcSize: 14 | Enabled: false 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.4.0 5 | before_install: gem install bundler -v 1.13.7 6 | deploy: 7 | provider: rubygems 8 | api_key: 9 | secure: BY92AHiphWOXtY7gGs83haZwZb+HeM+f12omyu5+Kv98WzNt85BmWgrstMQd9+kZXXbbU4kTIHgpir7CQlBHq+fMLKV5mgNHYwPpEe3l9Fe1lxIrPRV7uKIGxHiJvKUgwMPNfAHIIiN4GyhJr3Fb33i8sJ8WvGwYzQYgSDKOVOzUtx3aVAFh+6AmCaM4kCAukLn3c3s6PNvCIAIELl/GusZzDVRYHRdVoYKoAfA8beB+xSS5YykUIiQKKIXPRghuwynbyak96n1yIcDet5LDgBq3PYe0uQP4yLRI4UbN5WQbPVbHNFZT7TRYvYqIhQdePzsf4GEP+1hr7e4q7n0HoRnKXe9u6kcUlXCIj874wYw9WG11aehlYeyzQNWXpTsofz9GlDacVGEB7L7KZcEtccbApN1c9SfzkaMj1qLN8noRQ2oD4NzW1DZ58FjXUD6Igdzy3r0AOKrrTxCy8j66l/zB+p77urhgUs08+jhV9UIgB/plrrhRbg/eHZEnU/O6XEjEdi2zpClM3jnR4ZkVcopIdyHhLbImGQvGITYNh9WfjWA/AbL5OTJQT3GRNL++fQnXSNVSZQlv/5Fet5YrCw19fOyQGO1xDY+TguCfS5OhqhpYMbUUgX5LMKuKGCFJdTL+9xypq9FlPEHqJI67TFHPcn18JpO5dUjbx/yBUDI= 10 | gem: home_assistant-ble 11 | on: 12 | repo: kamaradclimber/home_assistant-ble 13 | addons: 14 | apt: 15 | packages: 16 | - libcap2-bin 17 | - libcap2-dev 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HomeAssistant::Ble 2 | 3 | [![Build Status](https://travis-ci.org/kamaradclimber/home_assistant-ble.svg?branch=master)](https://travis-ci.org/kamaradclimber/home_assistant-ble) 4 | [![Gem Version](https://badge.fury.io/rb/home_assistant-ble.svg)](https://badge.fury.io/rb/home_assistant-ble) 5 | 6 | Companion app from home-assistant sending BLE events. 7 | 8 | Since HA does not cope well with bluetooth device tracking (https://home-assistant.io/components/device_tracker.bluetooth_le_tracker/) this app runs along home-assistant and sends device tracking to it. 9 | 10 | ## Installation 11 | 12 | For raspbian install required packages: 13 | 14 | $ sudo apt-get install ruby-dev libcap-dev 15 | 16 | Build the gem from source to use the latest version: 17 | 18 | $ git clone https://github.com/kirichkov/home_assistant-ble.git 19 | $ cd home_assistant-ble 20 | $ gem build home_assistant-ble.gemspec 21 | $ sudo gem install home_assistant-ble-1.4.2.gem 22 | 23 | ## Usage 24 | 25 | Run `home_assistant-ble [your config file]` binary. 26 | 27 | ### Systemd 28 | 29 | To launch as a systemd service, you can copy `home_assistant-ble.service` file present in this repo. 30 | 31 | I'll probably build an archlinux package at some point (TODO). 32 | 33 | 34 | ### Non noot 35 | 36 | Running as non-root on recent Raspbian, Ubuntu and Debian-based distros requires changes to DBus configuration and adding the user to the `bluetooth` group. For more information check this [stackexchange post](https://unix.stackexchange.com/questions/348441/how-to-allow-non-root-systemd-service-to-use-dbus-for-ble-operation/348449#348449). 37 | 38 | Make sure you have the following in your `/etc/dbus-1/system.d/bluetooth.conf`: 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Then reload DBus: 50 | 51 | sudo service dbus reload 52 | 53 | In other Linux distros to be able to run with a non-root user, read http://unix.stackexchange.com/questions/96106/bluetooth-le-scan-as-non-root. In short (adapt if using a non-debian distribution): 54 | 55 | ``` 56 | sudo apt install libcap2-bin 57 | sudo setcap 'cap_net_raw,cap_net_admin+eip' `readlink -f \`which ruby\`` 58 | ``` 59 | **Note**: these instructions are probably not sufficient, see https://github.com/kamaradclimber/home_assistant-ble/issues/1 60 | 61 | ### Configuration 62 | 63 | ``` 64 | interval: 30 # in seconds, interval between device scan. Defaults to 30 65 | grace_period: 60 # in seconds, delay before considering a device has disappeared. Defaults to 60 66 | home_assistant_url: http://localhost:8123 # url to contact home-assistant. Defaults to http://localhost:8123 67 | home_assistant_token: token # Long lived access token if you're using the `homeassistant` http auth type. 68 | home_assistant_password: xxxxx # non mandatory password to authenticate to home-assistant api. Default is nil. If `home_assistant_token` is provided this setting has no effect 69 | home_assistant_devices: # devices whose activity will be sent to home-assistant. Default is empty (no tracked devices) 70 | F0:5C:F4:EA:BF:C8: nut1 # [macaddress]: [identifier for home-assistant] 71 | 72 | home_assistant_devices_file: /var/lib/hass/known_devices.yaml # read devices whose activity will be sent to home-assistant. Default is empty (devices from home-assistant are not tracked). This can easily replace home_assistant_devices setting. 73 | ``` 74 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rubocop/rake_task' 3 | 4 | RuboCop::RakeTask.new 5 | task default: :rubocop 6 | -------------------------------------------------------------------------------- /bin/home_assistant-ble: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'home_assistant/ble' 4 | require 'yaml' 5 | 6 | # force stdout/stderr to flush immediately 7 | # this will allow to follow logs when launched via systemd 8 | # the performance penalty is neglected since this is not a real-time program 9 | $stdout.sync = true 10 | $stderr.sync = true 11 | 12 | def shut_down 13 | @detector.clean_all_devices 14 | $stdout.puts 'Quitting...' 15 | end 16 | 17 | Signal.trap('INT') do 18 | $stdout.puts 'Received SIGINT.' 19 | shut_down 20 | exit 21 | end 22 | 23 | Signal.trap('TERM') do 24 | $stdout.puts 'Received SIGTERM.' 25 | shut_down 26 | exit 27 | end 28 | 29 | config_file = ARGV.shift 30 | config = {} 31 | if config_file 32 | config = YAML.load_file(config_file) 33 | else 34 | $stderr.puts 'No configuration file specified, will use all default values' 35 | end 36 | 37 | @detector = HomeAssistant::Ble::Detector.new(config) 38 | 39 | begin 40 | @detector.run 41 | rescue ScriptError 42 | # If no BLE interface is available clean up and exit 43 | $stdout.puts 'No Bluetooth interfaces available.' 44 | shut_down 45 | end 46 | -------------------------------------------------------------------------------- /example_config.yml: -------------------------------------------------------------------------------- 1 | interval: 30 # in seconds 2 | grace_period: 60 # in seconds 3 | home_assistant_url: http://localhost:8123 4 | # home_assistant_password: xxxxx 5 | # home_assistant_token: xxxxx 6 | home_assistant_devices: 7 | F0:5C:F4:EA:BE:C1: a_device 8 | home_assistant_devices_file: /var/lib/hass/known_devices.yaml 9 | -------------------------------------------------------------------------------- /home_assistant-ble.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | lib = File.expand_path('../lib', __FILE__) 4 | 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | require 'home_assistant/ble/version' 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = 'home_assistant-ble' 10 | spec.version = HomeAssistant::Ble::VERSION 11 | spec.version = "#{spec.version}-alpha-#{ENV['TRAVIS_BUILD_NUMBER']}" if ENV['TRAVIS'] 12 | spec.version = ENV['TRAVIS_TAG'] if ENV['TRAVIS_TAG'] && !ENV['TRAVIS_TAG'].empty? 13 | spec.authors = ['Grégoire Seux'] 14 | spec.email = ['grego_homeassistant@familleseux.net'] 15 | 16 | spec.summary = 'Companion app for home-assistant sending event about BLE devices' 17 | spec.description = 'home-assistant does not cope well with bluetooth on raspberry pi. This gem sends event to HA.' 18 | spec.homepage = 'https://github.com/kamaradclimber/home_assistant-ble' 19 | 20 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 21 | f.match(%r{^(test|spec|features)/}) 22 | end 23 | spec.bindir = 'bin' 24 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 25 | spec.require_paths = ['lib'] 26 | 27 | spec.add_development_dependency 'bundler' 28 | spec.add_development_dependency 'rake' 29 | spec.add_development_dependency 'rubocop', '~> 0.49.0' 30 | 31 | spec.add_runtime_dependency 'ble' 32 | spec.add_runtime_dependency 'mash' 33 | spec.add_runtime_dependency 'cap2' 34 | end 35 | -------------------------------------------------------------------------------- /home_assistant-ble.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Home assistant BLE 3 | After=network.target 4 | 5 | [Service] 6 | #User=hass 7 | #Group=hass 8 | 9 | Environment=RUBYOPT="-W0" 10 | ExecStart=/usr/bin/home_assistant-ble /var/lib/hass/ble.yml 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /lib/home_assistant/ble.rb: -------------------------------------------------------------------------------- 1 | require 'home_assistant/ble/version' 2 | require 'ble' 3 | require 'mash' 4 | require 'net/http' 5 | require 'uri' 6 | require 'json' 7 | require 'cap2' 8 | require 'digest/md5' 9 | 10 | module HomeAssistant 11 | module Ble 12 | class Detector 13 | attr_reader :config, :known_devices 14 | 15 | def initialize(config) 16 | @config = Mash.new(config) 17 | @known_devices = {} 18 | end 19 | 20 | def discovery_time 21 | config['discovery_time'] || 60 22 | end 23 | 24 | # polling interval 25 | def interval 26 | config['interval'] || 30 27 | end 28 | 29 | # time after which a device is considered as disappeared 30 | def grace_period 31 | config['grace_period'] || 60 32 | end 33 | 34 | def home_assistant_url 35 | config['home_assistant_url'] || 'http://localhost:8123' 36 | end 37 | 38 | def home_assistant_password 39 | config['home_assistant_password'] 40 | end 41 | 42 | def home_assistant_token 43 | config['home_assistant_token'] 44 | end 45 | 46 | def home_assistant_devices 47 | devices = {} 48 | if config['home_assistant_devices_file'] 49 | YAML.load_file(config['home_assistant_devices_file']).each do |name, conf| 50 | next unless conf['mac'] =~ /^(ble_|bt_)/i 51 | next unless conf['track'] 52 | mac = conf['mac'].gsub(/^(ble_|bt_)/i, '').upcase 53 | conf['dev_id'] = name 54 | devices[mac] = conf.select { |k, _v| %w(dev_id mac).include? k } 55 | debug "Adding #{mac} (#{conf['name']}) found in known_devices.yaml" 56 | end 57 | end 58 | if config['home_assistant_devices'] 59 | config['home_assistant_devices'].each do |mac, _name| 60 | ble_mac = "BLE_#{mac.upcase}" unless mac =~ /^(ble_|bt_)/i 61 | devices[mac] = Mash.new(mac: ble_mac) 62 | end 63 | end 64 | 65 | devices 66 | end 67 | 68 | def run 69 | loop do 70 | discover 71 | detect_new_devices 72 | clean_devices 73 | debug "Will sleep #{interval}s before relisting devices" 74 | sleep interval 75 | end 76 | end 77 | 78 | def clean_all_devices 79 | known_devices.keys.each do |name| 80 | known_devices.delete(name).tap do 81 | home_assistant_devices[name] && update_home_assistant(home_assistant_devices[name], :not_home) 82 | end 83 | end 84 | end 85 | 86 | private 87 | 88 | def log(message) 89 | puts message 90 | end 91 | 92 | def debug(message) 93 | log 'Set DEBUG environment variable to activate debug logs' unless ENV['DEBUG'] || @debug_tip 94 | @debug_tip = true 95 | return unless ENV['DEBUG'] 96 | print '(debug) ' 97 | puts message 98 | end 99 | 100 | def detect_new_devices 101 | adapter.devices.each do |name| 102 | unless known_devices.key?(name) 103 | log "Just discovered #{name}" 104 | home_assistant_devices[name] && update_home_assistant(home_assistant_devices[name], :home) 105 | end 106 | known_devices[name] = Time.now 107 | end 108 | end 109 | 110 | def clean_devices 111 | disappeared = (known_devices.keys - adapter.devices).select do |name| 112 | Time.now - known_devices[name] > grace_period 113 | end 114 | disappeared.each do |name| 115 | known_devices.delete(name).tap do |last_seen| 116 | log "#{name} has disappeared (last seen #{last_seen})" 117 | home_assistant_devices[name] && update_home_assistant(home_assistant_devices[name], :not_home) 118 | end 119 | end 120 | end 121 | 122 | def update_home_assistant(ha_conf, state) 123 | ha_conf['location_name'] = state 124 | uri = URI.join(home_assistant_url, '/api/services/device_tracker/see') 125 | request = Net::HTTP::Post.new(uri) 126 | request.content_type = 'application/json' 127 | request['Accept-Encoding'] = 'plain' 128 | 129 | if home_assistant_token 130 | request['Authorization'] = "Bearer #{home_assistant_token}" 131 | elsif home_assistant_password 132 | request['X-Ha-Access'] = home_assistant_password 133 | end 134 | 135 | request.body = ha_conf.to_json 136 | req_options = { use_ssl: uri.scheme == 'https' } 137 | 138 | begin 139 | response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http| 140 | http.request(request) 141 | end 142 | rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError, 143 | Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, 144 | Net::ProtocolError, Errno::ECONNREFUSED => e 145 | log "Could not update HA: #{e.message}" 146 | return 147 | end 148 | 149 | if response.code.to_i == 200 150 | debug "State update #{state} sent to HA for #{ha_conf['dev_id']}" 151 | debug response.body 152 | else 153 | log "Error while sending #{state} to HA form #{ha_conf['dev_id']}." 154 | log "URI was: #{uri}. Response was:" 155 | log response.body 156 | end 157 | end 158 | 159 | def adapter 160 | @adapter ||= begin 161 | ensure_rights! 162 | iface = BLE::Adapter.list.first 163 | debug "Selecting #{iface} to listen for bluetooth events" 164 | raise 'Unable to find a bluetooth device' unless iface 165 | BLE::Adapter.new(iface) 166 | end 167 | end 168 | 169 | def discover 170 | debug 'Cleaning old devices' 171 | adapter.devices.dup.each do |d| 172 | adapter[d].remove 173 | end 174 | debug 'Activating discovery' 175 | adapter.start_discovery 176 | debug 'Sleeping a bit to discover devices' 177 | sleep discovery_time 178 | adapter.stop_discovery 179 | end 180 | 181 | def ensure_rights! 182 | BLE::Adapter.list.first 183 | rescue DBus::Error => e 184 | raise unless e.message =~ /DBus.Error.AccessDenied/ 185 | log 'Not enough rights to use bluetooth device, read https://github.com/kamaradclimber/home_assistant-ble' 186 | log 'See also https://github.com/kamaradclimber/home_assistant-ble/issues/1' 187 | log 'Current capabilities:' 188 | log Cap2.process.inspect 189 | exit 1 190 | end 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /lib/home_assistant/ble/version.rb: -------------------------------------------------------------------------------- 1 | module HomeAssistant 2 | module Ble 3 | VERSION = '1.4.2'.freeze 4 | end 5 | end 6 | --------------------------------------------------------------------------------