├── .gitignore ├── Gemfile ├── README.md ├── bin └── hass-send ├── hass-ruby.gemspec └── lib └── hass ├── client.rb └── domain.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hass-client, Ruby client for HomeAssistant 2 | 3 | hass-client is a simple Ruby client for the HomeAssistant API. 4 | 5 | The idea was to have a client which has actual classes for the available devices. This way, you can inspect objects and see your possible actions. It works for the available services but not yet for all states. 6 | 7 | ## Installation 8 | 9 | Install the gem via 10 | 11 | ```bash 12 | gem install hass-client 13 | ``` 14 | 15 | or put it in your Gemfile 16 | 17 | ```ruby 18 | gem 'hass-client' 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### Command line client 24 | 25 | The command line client allows to send simple commands to your HomeAssistant instance. 26 | 27 | You need to set the environment variables HASS_HOST, HASS_PORT, and HASS_TOKEN. To get your API token, log in to your HomeAssistant instance and go your profile (the initials in the sidebar). Then scroll down and ceate an authentication token. 28 | 29 | To set these variables in the shell use: 30 | ``` 31 | export HASS_HOST=your-hostname.example.com 32 | export HASS_PORT=8123 33 | export HASS_TOKEN=abcdef 34 | export HASS_TLS=1 35 | ``` 36 | 37 | HASS_SSL=1 ensures a TLS (SSL) connection to your server. 38 | 39 | ```bash 40 | hass-send 41 | ``` 42 | 43 | for example: 44 | 45 | ```bash 46 | hass-send light.living_room turn_on 47 | hass-send light.living_room turn_off 48 | hass-send light.living_room toggle 49 | ``` 50 | 51 | Always use the full object name (with the dot) so the client can derive the object type. 52 | 53 | ### Library 54 | 55 | A simple script the toogle a light switch might look like this: 56 | 57 | ```ruby 58 | require 'hass/client' 59 | 60 | client = Hass::Client.new('localhost', 8123, 'api_token') 61 | 62 | light = client.light('light.living_room') 63 | light.toggle 64 | ``` 65 | 66 | You can get a list of your available device types (domains) via 67 | 68 | ```ruby 69 | pp(client.domains.map { |domain| domain['domain'] }) 70 | ``` 71 | 72 | You can also get a list of your available entity ids via 73 | 74 | ```ruby 75 | pp(client.states.map {|state| state['entity_id']} ) 76 | ``` 77 | 78 | After initializing the client, you can instantiate the available classes directly. You need to provide the client object to be able to use them: 79 | 80 | ```ruby 81 | client = Hass::Client.new('localhost', 8123, 'api_token') 82 | mediaplayer = Hass::MediaPlayer.new('media_player.yamaha_receiver') 83 | mediaplayer.client = client 84 | pp mediaplayer.attributes 85 | mediaplayer.select_source(source: 'HDMI1') 86 | ``` 87 | 88 | ## Caveats 89 | 90 | To make multiple calls to client.domains faster, the result is cached. If you have a longer running application, you will have to set @domains to nil to re-request the data. Other requests are not cached at all and this might significantly slow down access to multiple devices. 91 | -------------------------------------------------------------------------------- /bin/hass-send: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'hass/client' 4 | 5 | HOST = ENV['HASS_HOST'] ? ENV['HASS_HOST'].freeze : 'localhost'.freeze 6 | PORT = ENV['HASS_PORT'] ? ENV['HASS_PORT'].to_i : 8123 7 | TOKEN = ENV['HASS_TOKEN'] 8 | 9 | entity_id = ARGV[0] 10 | domain = entity_id.split('.').first 11 | service = ARGV[1] 12 | 13 | hass = Hass::Client.new(HOST, PORT, TOKEN) 14 | hass.send(domain.to_sym, entity_id).send(service.to_sym) 15 | -------------------------------------------------------------------------------- /hass-ruby.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | Gem::Specification.new do |spec| 5 | spec.name = 'hass-client' 6 | spec.version = '0.2.0' 7 | spec.authors = ['Gerrit Visscher'] 8 | spec.email = ['gerrit@visscher.de'] 9 | spec.summary = 'A small library to access Home Assistant.' 10 | spec.description = 'Read and write to the HomeAssistant API. Control your smart home devices via Ruby/CLI.' 11 | spec.homepage = 'https://github.com/kayssun/hass-ruby' 12 | spec.license = 'MIT' 13 | 14 | spec.files = `git ls-files -z`.split("\x0") 15 | spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) } 16 | spec.test_files = spec.files.grep(/^(test|spec|features)\//) 17 | spec.require_paths = ['lib'] 18 | 19 | spec.add_development_dependency 'bundler', '~> 1.7' 20 | end 21 | -------------------------------------------------------------------------------- /lib/hass/client.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'uri' 3 | require 'json' 4 | require 'hass/domain' 5 | 6 | module Hass 7 | # The HomeAssistant server 8 | class Client 9 | def initialize(host, port, token, base_path = '/api') 10 | @host = host 11 | @port = port 12 | @token = token 13 | @base_path = base_path 14 | prepare_domains 15 | prepare_methods 16 | end 17 | 18 | def get(path) 19 | path = @base_path + path 20 | request = Net::HTTP::Get.new path 21 | header.each_pair { |field, content| request[field] = content } 22 | response = send_http(request) 23 | parse(response.body) 24 | end 25 | 26 | def post(path, data) 27 | path = @base_path + path 28 | request = Net::HTTP::Post.new path 29 | request.body = data.to_json 30 | header.each_pair { |field, content| request[field] = content } 31 | response = send_http(request) 32 | parse(response.body) 33 | end 34 | 35 | def send_http(request) 36 | Net::HTTP.start(@host, @port, use_ssl: true) do |http| 37 | response = http.request request 38 | if response.code.to_i > 299 39 | handle_http_error(response) 40 | return {} # if handle does not throw an exception 41 | end 42 | return response 43 | end 44 | end 45 | 46 | def handle_http_error(response) 47 | raise "Server returned #{response.code}: #{response.body}" 48 | end 49 | 50 | def parse(response_text) 51 | JSON.parse(response_text) 52 | rescue JSON::ParserError => e 53 | puts "Cannot parse JSON data: #{e}" 54 | puts response_text 55 | end 56 | 57 | def header 58 | { 59 | 'Content-Type' => 'application/json', 60 | 'Authorization' => "Bearer #{@token}", 61 | 'Accept-Encoding' => 'identity' 62 | } 63 | end 64 | 65 | def domains 66 | @domains ||= get('/services') 67 | end 68 | 69 | def states 70 | get('/states') 71 | end 72 | 73 | def snake_to_camel(text) 74 | text.split('_').collect(&:capitalize).join 75 | end 76 | 77 | def prepare_domains 78 | domains.each do |domain| 79 | domain_name = snake_to_camel(domain['domain']) 80 | domain_class = Class.new(Domain) 81 | domain_class.const_set('DATA', domain) 82 | domain['services'].keys.each do |service| 83 | domain_class.send(:define_method, service.to_sym) do |params = {}| 84 | execute_service(service, params) 85 | end 86 | end 87 | Hass.const_set(domain_name, domain_class) 88 | end 89 | end 90 | 91 | def prepare_methods 92 | domains.each do |domain| 93 | domain_name = snake_to_camel(domain['domain']) 94 | self.class.send(:define_method, domain['domain'].to_sym) do |entity_id| 95 | domain_class = Hass.const_get(domain_name).new(entity_id) 96 | domain_class.client = self 97 | domain_class 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/hass/domain.rb: -------------------------------------------------------------------------------- 1 | module Hass 2 | # Base class for all domains (lights, switches, media_player...) 3 | class Domain 4 | attr_accessor :client 5 | attr_reader :entity_id 6 | 7 | # Just to make sure, the constant exists 8 | DATA = {}.freeze 9 | 10 | def initialize(entity_id) 11 | @entity_id = entity_id 12 | end 13 | 14 | def required_fields(method_name) 15 | data['services'][method_name]['fields'].keys.reject { |name| name == 'entity_id' } 16 | end 17 | 18 | def check_params(method_name, given_params) 19 | required_fields(method_name).each do |required_field| 20 | next if given_params.key?(required_field) 21 | 22 | raise "Parameter #{required_field} might be missing. #{method_help(method_name)}" 23 | end 24 | end 25 | 26 | # Returns a method description as a help text 27 | def method_help(method_name) 28 | param_help = required_fields(method_name).map { |name| "#{name}: #{name}_value" } 29 | method_hint = "#{method_name}(#{param_help.join(', ')})" 30 | fields = data['services'][method_name]['fields'] 31 | method_description = fields.keys.map { |field| "#{field}: #{fields[field]['description']}" }.join("\n") 32 | "Hint: you can call this method with #{method_hint}\n#{method_description}" 33 | end 34 | 35 | def execute_service(service, params = {}) 36 | params['entity_id'] = @entity_id 37 | @client.post("/services/#{data['domain']}/#{service}", params) 38 | rescue RuntimeError => error 39 | puts "Rescuing from #{error.class}: #{error}" 40 | check_params(service, params) 41 | end 42 | 43 | def state_data 44 | @client.get("/states/#{@entity_id}") 45 | end 46 | 47 | def attributes 48 | state_data['attributes'] 49 | end 50 | 51 | def state 52 | state_data['state'] 53 | end 54 | 55 | def data 56 | self.class.const_get('DATA') 57 | end 58 | end 59 | end 60 | --------------------------------------------------------------------------------