huebot 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 267bb3cc14e143785af2250a762b365f513bb18a3630b68edf10d3df3748de2a
4
- data.tar.gz: 7afa69b3aa54a936deebbcea75b409f2411fa5482554b22cd29aff088ce351b6
3
+ metadata.gz: b919267fa7949b2e208ca3b62112c21f05aa1cb9e52a1cb175ab32a96e2b3411
4
+ data.tar.gz: 8ba14d33785521841fe31daff20786b6ead22592d15e3938b7b518202411b888
5
5
  SHA512:
6
- metadata.gz: 442d3fb05c7f1e6610437ff5fc2f92d25694e2e9342ff9b16217ba80b7982538d48f807304e487fd583385864e9f4cf86491e33d2c66ba663b22449867ba780c
7
- data.tar.gz: '01908c65fb9c472271904973e313bc080eb96d91b557ef2e8bb3902a5ea054f7fb71b3b37bf6f51f356bf742c5ab32a336e4c013059a0a537dc3a538955dfc97'
6
+ metadata.gz: 462cf2419a33689160a4471157b07302ab3fbbc48341903dde3d1c520f4cfc72a21fe6f1dc83262251270c692ad991aa04b666229f30b7218dc70d98f20ec398
7
+ data.tar.gz: 04c0e7d1fec4c384ceab06e022442ca90f9fefafbe0b15df9d6e3d8fd9db291ab16eca3af13e1db1edf0b8ea902e9d9e89bc36cdaa072e72fa956152db617c63
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Huebot
2
2
 
3
- Orchestration and automation for Philips Hue devices. Huebot can be used as a Ruby library or a command line utility. Huebot programs are declared as YAML files.
3
+ Program your Hue lights in YAML!
4
4
 
5
5
  $ huebot run dimmer.yml --light="Office Desk"
6
6
 
@@ -34,17 +34,18 @@ The variable `$all` refers to all lights and/or groups passed in on the command
34
34
 
35
35
  gem install huebot
36
36
 
37
- ## License
37
+ Having trouble with Hue Bridge auto discovery? Me too. If you know your bridge's IP (and ideally have assigned it a static one), you can set it manually:
38
38
 
39
- Huebot is licensed under the MIT license (see LICENSE file).
39
+ huebot set-ip <your bridge's IP>
40
40
 
41
- A patched version of the "hue" gem is bundled in huebot's codebase (to remove a dependency that's unnecessarily annoying to install). The license for it can be found at `lib/hue/LICENSE`.
41
+ Configuration is stored in `~/.config/huebot`.
42
42
 
43
- ## UNDER ACTIVE DEVELOPMENT
43
+ ## License
44
+
45
+ Huebot is licensed under the MIT license (see LICENSE file).
44
46
 
45
47
  **TODO**
46
48
 
47
49
  * Validate number of inputs against compiled programs
48
- * Brief explanation various features
49
- * Wiki entry with more examples
50
- * Link to official Hue docs
50
+ * More explanation various features in Wiki
51
+ * More examples in Wiki
data/bin/huebot CHANGED
@@ -9,15 +9,25 @@ require 'huebot/cli'
9
9
  Huebot::CLI.tap { |cli|
10
10
  case cli.get_cmd
11
11
  when :ls
12
- client = Hue::Client.new
13
- puts "Lights\n" + client.lights.map { |l| " #{l.id}: #{l.name}" }.join("\n") + \
14
- "\nGroups\n" + client.groups.map { |g| " #{g.id}: #{g.name}" }.join("\n")
12
+ bridge, error = Huebot::Bridge.connect
13
+ if error
14
+ $stderr.puts error
15
+ exit 1
16
+ end
17
+
18
+ puts "Lights\n" + bridge.lights.map { |l| " #{l.id}: #{l.name}" }.join("\n") + \
19
+ "\nGroups\n" + bridge.groups.map { |g| " #{g.id}: #{g.name}" }.join("\n")
15
20
 
16
21
  when :run
17
22
  opts, sources = cli.get_input!
18
23
 
19
- client = Hue::Client.new
20
- device_mapper = Huebot::DeviceMapper.new(client, opts.inputs)
24
+ bridge, error = Huebot::Bridge.connect
25
+ if error
26
+ $stderr.puts error
27
+ exit 1
28
+ end
29
+
30
+ device_mapper = Huebot::DeviceMapper.new(bridge, opts.inputs)
21
31
  compiler = Huebot::Compiler.new(device_mapper)
22
32
 
23
33
  programs = sources.map { |src|
@@ -26,14 +36,19 @@ Huebot::CLI.tap { |cli|
26
36
  found_errors, _found_warnings = cli.check! programs, $stderr
27
37
  exit 1 if found_errors
28
38
 
29
- bot = Huebot::Bot.new(client)
39
+ bot = Huebot::Bot.new(bridge)
30
40
  programs.each { |prog| bot.execute prog }
31
41
 
32
42
  when :check
33
43
  opts, sources = cli.get_input!
34
44
 
35
- client = Hue::Client.new
36
- device_mapper = Huebot::DeviceMapper.new(client, opts.inputs)
45
+ bridge, error = Huebot::Bridge.connect
46
+ if error
47
+ $stderr.puts error
48
+ exit 1
49
+ end
50
+
51
+ device_mapper = Huebot::DeviceMapper.new(bridge, opts.inputs)
37
52
  compiler = Huebot::Compiler.new(device_mapper)
38
53
 
39
54
  programs = sources.map { |src|
@@ -43,6 +58,21 @@ Huebot::CLI.tap { |cli|
43
58
  # TODO validate NUMBER of inputs against each program
44
59
  exit (found_errors || found_warnings) ? 1 : 0
45
60
 
61
+ when :"set-ip"
62
+ ip = cli.get_args(num: 1).first
63
+ config = Huebot::Config.new
64
+ config["ip"] = ip
65
+
66
+ when :"clear-ip"
67
+ cli.get_args(num: 0)
68
+ config = Huebot::Config.new
69
+ config["ip"] = nil
70
+
71
+ when :unregister
72
+ cli.get_args(num: 0)
73
+ config = Huebot::Config.new
74
+ config.clear
75
+
46
76
  else cli.help!
47
77
  end
48
78
  }
@@ -0,0 +1,24 @@
1
+ module Huebot
2
+ class Bridge
3
+ def self.connect(config = Huebot::Config.new)
4
+ client = Client.new(config)
5
+ error = client.connect
6
+ return nil, error if error
7
+ return new(client)
8
+ end
9
+
10
+ attr_reader :client
11
+
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ def lights
17
+ client.get!("/lights").map { |(id, attrs)| Light.new(client, id, attrs) }
18
+ end
19
+
20
+ def groups
21
+ client.get!("/groups").map { |(id, attrs)| Group.new(client, id, attrs) }
22
+ end
23
+ end
24
+ end
data/lib/huebot/cli.rb CHANGED
@@ -23,6 +23,31 @@ module Huebot
23
23
  ARGV[0].to_s.to_sym
24
24
  end
25
25
 
26
+ def self.get_args(min: nil, max: nil, num: nil)
27
+ args = ARGV[1..]
28
+ if num
29
+ if num != args.size
30
+ $stderr.puts "Expected #{num} args, found #{args.size}"
31
+ exit 1
32
+ end
33
+ elsif min and max
34
+ if args.size < min or args.size > max
35
+ $stderr.puts "Expected #{min}-#{max} args, found #{args.size}"
36
+ end
37
+ elsif min
38
+ if args.size < min
39
+ $stderr.puts "Expected at least #{num} args, found #{args.size}"
40
+ exit 1
41
+ end
42
+ elsif max
43
+ if args.size > max
44
+ $stderr.puts "Expected no more than #{num} args, found #{args.size}"
45
+ exit 1
46
+ end
47
+ end
48
+ args
49
+ end
50
+
26
51
  #
27
52
  # Parses and returns input from the CLI. Serious errors might result in the program exiting.
28
53
  #
@@ -37,7 +62,7 @@ module Huebot
37
62
  if files.empty? and !options.read_stdin
38
63
  puts parser.help
39
64
  exit 1
40
- elsif (bad_paths = files.select { |p| !File.exists? p }).any?
65
+ elsif (bad_paths = files.select { |p| !File.exist? p }).any?
41
66
  $stderr.puts "Cannot find #{bad_paths.join ', '}"
42
67
  exit 1
43
68
  else
@@ -107,6 +132,13 @@ Run program(s):
107
132
  Validate programs and inputs:
108
133
  huebot check file1.yml [file2.yml [file3.yml ...]] [options]
109
134
 
135
+ Manually set/clear the IP for your Hue Bridge (useful when on a VPN):
136
+ huebot set-ip 192.168.1.20
137
+ huebot clear-ip
138
+
139
+ Clear all connection config:
140
+ huebot unregister
141
+
110
142
  Options:
111
143
  ).strip
112
144
  opts.on("-lLIGHT", "--light=LIGHT", "Light ID or name") { |l| options.inputs << LightInput.new(l) }
@@ -0,0 +1,131 @@
1
+ require 'uri'
2
+ require 'net/http'
3
+ require 'json'
4
+
5
+ module Huebot
6
+ class Client
7
+ DISCOVERY_URI = URI(ENV["HUE_DISCOVERY_API"] || "https://discovery.meethue.com/")
8
+ Bridge = Struct.new(:id, :ip)
9
+
10
+ attr_reader :config
11
+
12
+ def initialize(config = Huebot::Config.new)
13
+ @config = config
14
+ @ip = config["ip"] # NOTE will usually be null
15
+ @username = nil
16
+ end
17
+
18
+ def connect
19
+ if config["ip"]
20
+ @ip = config["ip"]
21
+ elsif config["id"]
22
+ @ip = bridges.detect { |b| b.id == id }&.ip
23
+ return "Unable to find Hue Bridge '#{config["id"]}' on your network" if @ip.nil?
24
+ else
25
+ bridge = bridges.first
26
+ return "Unable to find a Hue Bridge on your network" if bridge.nil?
27
+ config["id"] = bridge.id
28
+ @ip = bridge.ip
29
+ end
30
+
31
+ if config["username"]
32
+ if valid_username? config["username"]
33
+ @username = config["username"]
34
+ else
35
+ return "Invalid Hue Bridge username '#{config["username"]}'"
36
+ end
37
+ else
38
+ username, error = register
39
+ return error if error
40
+ config["username"] = @username = username
41
+ end
42
+ nil
43
+ end
44
+
45
+ def get!(path)
46
+ resp, error = get path
47
+ raise error if error
48
+ resp
49
+ end
50
+
51
+ def get(path)
52
+ url = "http://#{@ip}/api"
53
+ url << "/#{@username}" if @username
54
+ url << path
55
+ req = Net::HTTP::Get.new(URI(url))
56
+ req_json req
57
+ end
58
+
59
+ def post!(path, body)
60
+ resp, error = post path, body
61
+ raise error if error
62
+ resp
63
+ end
64
+
65
+ def post(path, body)
66
+ url = "http://#{@ip}/api"
67
+ url << "/#{@username}" if @username
68
+ url << path
69
+ req = Net::HTTP::Post.new(URI(url))
70
+ req["Content-Type"] = "application/json"
71
+ req.body = body.to_json
72
+ req_json req
73
+ end
74
+
75
+ def put!(path, body)
76
+ resp, error = put path, body
77
+ raise error if error
78
+ resp
79
+ end
80
+
81
+ def put(path, body)
82
+ url = "http://#{@ip}/api"
83
+ url << "/#{@username}" if @username
84
+ url << path
85
+ req = Net::HTTP::Put.new(URI(url))
86
+ req["Content-Type"] = "application/json"
87
+ req.body = body.to_json
88
+ req_json req
89
+ end
90
+
91
+ def req_json(req)
92
+ resp = Net::HTTP.start req.uri.host, req.uri.port, {use_ssl: false} do |http|
93
+ http.request req
94
+ end
95
+ case resp.code.to_i
96
+ when 200..201
97
+ data = JSON.parse(resp.body)
98
+ if data[0] and (error = data[0]["error"])
99
+ return nil, error.fetch("description")
100
+ else
101
+ return data, nil
102
+ end
103
+ else
104
+ raise "Unexpected response from Bridge (#{resp.code}): #{resp.body}"
105
+ end
106
+ end
107
+
108
+ def bridges
109
+ req = Net::HTTP::Get.new(DISCOVERY_URI)
110
+ resp = Net::HTTP.start req.uri.host, req.uri.port, {use_ssl: true} do |http|
111
+ http.request req
112
+ end
113
+ JSON.parse(resp.body).map { |x|
114
+ Bridge.new(x.fetch("id"), x.fetch("internalipaddress"))
115
+ }
116
+ end
117
+
118
+ private
119
+
120
+ def valid_username?(username)
121
+ _resp, error = get("/#{username}")
122
+ !error
123
+ end
124
+
125
+ def register
126
+ resp, error = post "/", {"devicetype": "huebot"}
127
+ return nil, error if error
128
+ resp[0].fetch("success").fetch("username")
129
+ end
130
+ end
131
+ end
@@ -1,5 +1,7 @@
1
1
  module Huebot
2
2
  class Compiler
3
+ DEVICE_FIELDS = %i(light lights group groups device devices).freeze
4
+
3
5
  def initialize(device_mapper)
4
6
  @device_mapper = device_mapper
5
7
  end
@@ -130,9 +132,9 @@ module Huebot
130
132
  end
131
133
  state[:transitiontime] = t.delete("time") || t.delete(:time) || t.delete("transitiontime") || t.delete(:transitiontime) || 4
132
134
 
133
- transition.state = t.merge(state).reduce({}) { |a, (key, val)|
134
- a[key.to_sym] = val
135
- a
135
+ transition.state = t.merge(state).each_with_object({}) { |(key, val), obj|
136
+ key = key.to_sym
137
+ obj[key] = val unless DEVICE_FIELDS.include? key
136
138
  }
137
139
  return errors, warnings, transition
138
140
  end
@@ -0,0 +1,41 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ module Huebot
5
+ class Config
6
+ def initialize(path = "~/.config/huebot")
7
+ @path = File.expand_path(path)
8
+ @dir = File.dirname(@path)
9
+ @dir_exists = File.exist? @dir
10
+ @config = File.exist?(@path) ? YAML.load_file(@path) : {}
11
+ end
12
+
13
+ def [](attr)
14
+ @config[attr.to_s]
15
+ end
16
+
17
+ def []=(attr, val)
18
+ if val.nil?
19
+ @config.delete(attr.to_s)
20
+ else
21
+ @config[attr.to_s] = val
22
+ end
23
+ write
24
+ end
25
+
26
+ def clear
27
+ @config.clear
28
+ write
29
+ end
30
+
31
+ private
32
+
33
+ def write
34
+ unless @dir_exists
35
+ FileUtils.mkdir_p @dir
36
+ @dir_exists = true
37
+ end
38
+ File.write(@path, YAML.dump(@config))
39
+ end
40
+ end
41
+ end
@@ -2,8 +2,8 @@ module Huebot
2
2
  class DeviceMapper
3
3
  Unmapped = Class.new(StandardError)
4
4
 
5
- def initialize(client, inputs = [])
6
- all_lights, all_groups = client.lights, client.groups
5
+ def initialize(bridge, inputs = [])
6
+ all_lights, all_groups = bridge.lights, bridge.groups
7
7
 
8
8
  @lights_by_id = all_lights.reduce({}) { |a, l| a[l.id] = l; a }
9
9
  @lights_by_name = all_lights.reduce({}) { |a, l| a[l.name] = l; a }
@@ -0,0 +1,7 @@
1
+ module Huebot
2
+ module DeviceState
3
+ def set_state(state)
4
+ client.put!(state_url, state)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,23 @@
1
+ module Huebot
2
+ class Group
3
+ include DeviceState
4
+ attr_reader :client, :id, :name
5
+
6
+ def initialize(client, id, attrs)
7
+ @client = client
8
+ @id = id
9
+ @name = attrs.fetch("name")
10
+ @attrs = attrs
11
+ end
12
+
13
+ private
14
+
15
+ def state_url
16
+ url "/action"
17
+ end
18
+
19
+ def url(path)
20
+ "/groups/#{id}#{path}"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ module Huebot
2
+ class Light
3
+ include DeviceState
4
+ attr_reader :client, :id, :name
5
+
6
+ def initialize(client, id, attrs)
7
+ @client = client
8
+ @id = id
9
+ @name = attrs.fetch("name")
10
+ @attrs = attrs
11
+ end
12
+
13
+ private
14
+
15
+ def state_url
16
+ url "/state"
17
+ end
18
+
19
+ def url(path)
20
+ "/lights/#{id}#{path}"
21
+ end
22
+ end
23
+ end
@@ -1,4 +1,4 @@
1
1
  module Huebot
2
2
  # Gem version
3
- VERSION = '0.4.0'
3
+ VERSION = '0.5.0'
4
4
  end
data/lib/huebot.rb CHANGED
@@ -1,6 +1,10 @@
1
- require 'hue'
2
-
3
1
  module Huebot
2
+ autoload :Config, 'huebot/config'
3
+ autoload :Client, 'huebot/client'
4
+ autoload :Bridge, 'huebot/bridge'
5
+ autoload :DeviceState, 'huebot/device_state'
6
+ autoload :Light, 'huebot/light'
7
+ autoload :Group, 'huebot/group'
4
8
  autoload :DeviceMapper, 'huebot/device_mapper'
5
9
  autoload :Program, 'huebot/program'
6
10
  autoload :Compiler, 'huebot/compiler'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: huebot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Hollinger
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-12-17 00:00:00.000000000 Z
11
+ date: 2023-12-08 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Declare and run YAML programs for Philips Hue devices
14
14
  email: jordan.hollinger@gmail.com
@@ -19,29 +19,24 @@ extra_rdoc_files: []
19
19
  files:
20
20
  - README.md
21
21
  - bin/huebot
22
- - lib/hue.rb
23
- - lib/hue/LICENSE
24
- - lib/hue/bridge.rb
25
- - lib/hue/client.rb
26
- - lib/hue/editable_state.rb
27
- - lib/hue/errors.rb
28
- - lib/hue/group.rb
29
- - lib/hue/light.rb
30
- - lib/hue/scene.rb
31
- - lib/hue/translate_keys.rb
32
- - lib/hue/version.rb
33
22
  - lib/huebot.rb
34
23
  - lib/huebot/bot.rb
24
+ - lib/huebot/bridge.rb
35
25
  - lib/huebot/cli.rb
26
+ - lib/huebot/client.rb
36
27
  - lib/huebot/compiler.rb
28
+ - lib/huebot/config.rb
37
29
  - lib/huebot/device_mapper.rb
30
+ - lib/huebot/device_state.rb
31
+ - lib/huebot/group.rb
32
+ - lib/huebot/light.rb
38
33
  - lib/huebot/program.rb
39
34
  - lib/huebot/version.rb
40
35
  homepage: https://github.com/jhollinger/huebot
41
36
  licenses:
42
37
  - MIT
43
38
  metadata: {}
44
- post_install_message:
39
+ post_install_message:
45
40
  rdoc_options: []
46
41
  require_paths:
47
42
  - lib
@@ -56,8 +51,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
56
51
  - !ruby/object:Gem::Version
57
52
  version: '0'
58
53
  requirements: []
59
- rubygems_version: 3.0.3.1
60
- signing_key:
54
+ rubygems_version: 3.4.1
55
+ signing_key:
61
56
  specification_version: 4
62
57
  summary: Orchestration for Hue devices
63
58
  test_files: []
data/lib/hue/LICENSE DELETED
@@ -1,22 +0,0 @@
1
- Copyright (c) 2013-2014 Sam Soffes, http://soff.es
2
-
3
- MIT License
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining
6
- a copy of this software and associated documentation files (the
7
- "Software"), to deal in the Software without restriction, including
8
- without limitation the rights to use, copy, modify, merge, publish,
9
- distribute, sublicense, and/or sell copies of the Software, and to
10
- permit persons to whom the Software is furnished to do so, subject to
11
- the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be
14
- included in all copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
- LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.