huebot 0.5.0 → 1.0.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.
data/lib/huebot/cli.rb CHANGED
@@ -1,7 +1,3 @@
1
- require 'optparse'
2
- require 'ostruct'
3
- require 'yaml'
4
-
5
1
  module Huebot
6
2
  #
7
3
  # Helpers for running huebot in cli-mode.
@@ -14,140 +10,7 @@ module Huebot
14
10
  #
15
11
  Options = Struct.new(:inputs, :read_stdin)
16
12
 
17
- #
18
- # Returns the command given to huebot.
19
- #
20
- # @return [Symbol]
21
- #
22
- def self.get_cmd
23
- ARGV[0].to_s.to_sym
24
- end
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
-
51
- #
52
- # Parses and returns input from the CLI. Serious errors might result in the program exiting.
53
- #
54
- # @return [Huebot::CLI::Options] All given CLI options
55
- # @return [Array<Huebot::ProgramSrc>] Array of given program sources
56
- #
57
- def self.get_input!
58
- options, parser = option_parser
59
- parser.parse!
60
-
61
- files = ARGV[1..-1]
62
- if files.empty? and !options.read_stdin
63
- puts parser.help
64
- exit 1
65
- elsif (bad_paths = files.select { |p| !File.exist? p }).any?
66
- $stderr.puts "Cannot find #{bad_paths.join ', '}"
67
- exit 1
68
- else
69
- sources = files.map { |path|
70
- ProgramSrc.new(YAML.load_file(path), path)
71
- }
72
- sources << ProgramSrc.new(YAML.load($stdin.read), "STDIN") if options.read_stdin
73
- return options, sources
74
- end
75
- end
76
-
77
- #
78
- # Prints any program errors or warnings, and returns a boolean for each.
79
- #
80
- # @param programs [Array<Huebot::Program>]
81
- # @param io [IO] Usually $stdout or $stderr
82
- # @param quiet [Boolean] if true, don't print anything
83
- #
84
- def self.check!(programs, io, quiet: false)
85
- if (invalid_progs = programs.select { |prog| prog.errors.any? }).any?
86
- print_messages! io, "Errors", invalid_progs, :errors unless quiet
87
- end
88
-
89
- if (imperfect_progs = programs.select { |prog| prog.warnings.any? }).any?
90
- puts "" if invalid_progs.any?
91
- print_messages! io, "Warnings", imperfect_progs, :warnings unless quiet
92
- end
93
-
94
- return invalid_progs.any?, imperfect_progs.any?
95
- end
96
-
97
- # Print help and exit
98
- def self.help!
99
- _, parser = option_parser
100
- puts parser.help
101
- exit 1
102
- end
103
-
104
- private
105
-
106
- #
107
- # Print each message (of the given type) for each program.
108
- #
109
- # @param io [IO] Usually $stdout or $stderr
110
- # @param label [String] Top-level for this group of messages
111
- # @param progs [Array<Huebot::CLI::Program>]
112
- # @param msg_type [Symbol] name of method that holds the messages (i.e. :errors or :warnings)
113
- #
114
- def self.print_messages!(io, label, progs, msg_type)
115
- io.puts "#{label}:"
116
- progs.each { |prog|
117
- io.puts " #{prog.name}:"
118
- prog.send(msg_type).each_with_index { |msg, i| io.puts " #{i+1}. #{msg}" }
119
- }
120
- end
121
-
122
- def self.option_parser
123
- options = Options.new([], false)
124
- parser = OptionParser.new { |opts|
125
- opts.banner = %(
126
- List all lights and groups:
127
- huebot ls
128
-
129
- Run program(s):
130
- huebot run file1.yml [file2.yml [file3.yml ...]] [options]
131
-
132
- Validate programs and inputs:
133
- huebot check file1.yml [file2.yml [file3.yml ...]] [options]
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
-
142
- Options:
143
- ).strip
144
- opts.on("-lLIGHT", "--light=LIGHT", "Light ID or name") { |l| options.inputs << LightInput.new(l) }
145
- opts.on("-gGROUP", "--group=GROUP", "Group ID or name") { |g| options.inputs << GroupInput.new(g) }
146
- opts.on("--all", "All lights and groups TODO") { $stderr.puts "Not Implemented"; exit 1 }
147
- opts.on("-i", "Read program from STDIN") { options.read_stdin = true }
148
- opts.on("-h", "--help", "Prints this help") { puts opts; exit }
149
- }
150
- return options, parser
151
- end
13
+ autoload :Helpers, 'huebot/cli/helpers'
14
+ autoload :Runner, 'huebot/cli/runner'
152
15
  end
153
16
  end
data/lib/huebot/client.rb CHANGED
@@ -6,6 +6,7 @@ module Huebot
6
6
  class Client
7
7
  DISCOVERY_URI = URI(ENV["HUE_DISCOVERY_API"] || "https://discovery.meethue.com/")
8
8
  Bridge = Struct.new(:id, :ip)
9
+ Error = Class.new(Error)
9
10
 
10
11
  attr_reader :config
11
12
 
@@ -44,7 +45,7 @@ module Huebot
44
45
 
45
46
  def get!(path)
46
47
  resp, error = get path
47
- raise error if error
48
+ raise Error, error if error
48
49
  resp
49
50
  end
50
51
 
@@ -58,7 +59,7 @@ module Huebot
58
59
 
59
60
  def post!(path, body)
60
61
  resp, error = post path, body
61
- raise error if error
62
+ raise Error, error if error
62
63
  resp
63
64
  end
64
65
 
@@ -74,7 +75,7 @@ module Huebot
74
75
 
75
76
  def put!(path, body)
76
77
  resp, error = put path, body
77
- raise error if error
78
+ raise Error, error if error
78
79
  resp
79
80
  end
80
81
 
@@ -101,7 +102,7 @@ module Huebot
101
102
  return data, nil
102
103
  end
103
104
  else
104
- raise "Unexpected response from Bridge (#{resp.code}): #{resp.body}"
105
+ raise Error, "Unexpected response from Bridge (#{resp.code}): #{resp.body}"
105
106
  end
106
107
  end
107
108
 
@@ -0,0 +1,285 @@
1
+ require 'date'
2
+ require 'time'
3
+
4
+ module Huebot
5
+ module Compiler
6
+ class ApiV1
7
+ DEVICE_REF = /\A\$([1-9][0-9]*)\Z/.freeze
8
+ TRANSITION_KEYS = ["transition"].freeze
9
+ SERIAL_KEYS = ["serial"].freeze
10
+ PARALLEL_KEYS = ["parallel"].freeze
11
+ INFINITE_KEYS = ["infinite"].freeze
12
+ COUNT_KEYS = ["count"].freeze
13
+ TIMER_KEYS = ["timer"].freeze
14
+ DEADLINE_KEYS = ["until"].freeze
15
+ HHMM = /\A[0-9]{2}:[0-9]{2}\Z/.freeze
16
+
17
+ def initialize(api_version)
18
+ @api_version = api_version
19
+ end
20
+
21
+ # @return [Huebot::Program]
22
+ def build(tokens, default_name = nil)
23
+ prog = Program.new
24
+ prog.name = tokens.delete("name") || default_name
25
+ prog.api_version = @api_version
26
+ prog.data = node tokens.dup
27
+ prog
28
+ end
29
+
30
+ private
31
+
32
+ def node(t, inherited_devices = nil)
33
+ errors, warnings = [], []
34
+ instruction, child_nodes =
35
+ case t.keys
36
+ when TRANSITION_KEYS
37
+ build_transition t.fetch("transition"), errors, warnings, inherited_devices
38
+ when SERIAL_KEYS
39
+ build_serial t.fetch("serial"), errors, warnings, inherited_devices
40
+ when PARALLEL_KEYS
41
+ build_parallel t.fetch("parallel"), errors, warnings, inherited_devices
42
+ else
43
+ errors << "Expected exactly one of: transition, serial, parallel. Found #{t.keys}"
44
+ [Program::AST::NoOp.new, []]
45
+ end
46
+ Program::AST::Node.new(instruction, child_nodes, errors, warnings)
47
+ end
48
+
49
+ def build_transition(t, errors, warnings, inherited_devices = nil)
50
+ state = build_state(t, errors, warnings)
51
+ devices = build_devices(t, errors, warnings, inherited_devices)
52
+ slp = build_sleep(t, errors, warnings)
53
+
54
+ errors << "'transition' requires devices" if devices.empty?
55
+ errors << "Unknown keys in 'transition': #{t.keys.join ", "}" if t.keys.any?
56
+
57
+ instruction = Program::AST::Transition.new(state, devices, slp)
58
+ return instruction, []
59
+ end
60
+
61
+ def build_serial(t, errors, warnings, inherited_devices = nil)
62
+ lp = build_loop(t, errors, warnings)
63
+ slp = build_sleep(t, errors, warnings)
64
+ devices = build_devices(t, errors, warnings, inherited_devices)
65
+ children = build_steps(t, errors, warnings, devices)
66
+
67
+ errors << "'serial' requires steps" if children.empty?
68
+ errors << "Unknown keys in 'serial': #{t.keys.join ", "}" if t.keys.any?
69
+
70
+ instruction = Program::AST::SerialControl.new(lp, slp)
71
+ return instruction, children
72
+ end
73
+
74
+ def build_parallel(t, errors, warnings, inherited_devices = nil)
75
+ lp = build_loop(t, errors, warnings)
76
+ slp = build_sleep(t, errors, warnings)
77
+ devices = build_devices(t, errors, warnings, inherited_devices)
78
+ children = build_steps(t, errors, warnings, devices)
79
+
80
+ errors << "'parallel' requires steps" if children.empty?
81
+ errors << "Unknown keys in 'parallel': #{t.keys.join ", "}" if t.keys.any?
82
+
83
+ instruction = Program::AST::ParallelControl.new(lp, slp)
84
+ return instruction, children
85
+ end
86
+
87
+ def map_state_keys(state, errors, warnings)
88
+ # bugfix to YAML
89
+ case state.delete true
90
+ when true
91
+ state["on"] = true
92
+ when false
93
+ state["on"] = false
94
+ end
95
+
96
+ time = state.delete "time"
97
+ case time
98
+ when Integer, Float
99
+ state["transitiontime"] = (time.to_f * 10).round(0)
100
+ when nil
101
+ # pass
102
+ else
103
+ errors << "'transition.state.time' must be a number"
104
+ end
105
+
106
+ ctk = state.delete "ctk"
107
+ case ctk
108
+ when 2000..6530
109
+ state["ct"] = (1_000_000 / ctk).round # https://en.wikipedia.org/wiki/Mired
110
+ when nil
111
+ # pass
112
+ else
113
+ errors << "'transition.state.ctk' must be an integer between 2700 and 6530"
114
+ end
115
+
116
+ state
117
+ end
118
+
119
+ def build_state(t, errors, warnings)
120
+ state = t.delete "state"
121
+ case state
122
+ when Hash
123
+ map_state_keys state, errors, warnings
124
+ when nil
125
+ errors << "'state' is required in a transition"
126
+ {}
127
+ else
128
+ errors << "Expected 'state' to be an object, got a #{state.class.name}"
129
+ {}
130
+ end
131
+ end
132
+
133
+ def build_steps(t, errors, warnings, inherited_devices = nil)
134
+ steps_val = t.delete "steps"
135
+ case steps_val
136
+ when Array
137
+ steps_val.map { |s| node s, inherited_devices }
138
+ when nil
139
+ errors << "Missing 'steps'"
140
+ []
141
+ else
142
+ errors << "'steps' should be an array but is a #{steps_val.class.name}"
143
+ []
144
+ end
145
+ end
146
+
147
+ def build_loop(t, errors, warnings)
148
+ loop_val = t.delete "loop"
149
+ case loop_val
150
+ when Hash
151
+ pause = loop_val.delete "pause"
152
+ errors << "'loop.pause' must be an integer. Found '#{pause.class.name}'" if pause and !pause.is_a? Integer
153
+
154
+ lp =
155
+ case loop_val.keys
156
+ when INFINITE_KEYS
157
+ loop_val["infinite"] == true ? Program::AST::InfiniteLoop.new : Program::AST::CountedLoop.new(1)
158
+ when COUNT_KEYS
159
+ num = loop_val["count"]
160
+ errors << "'loop.count' must be an integer. Found '#{num.class.name}'" unless num.is_a? Integer
161
+ Program::AST::CountedLoop.new(num)
162
+ when TIMER_KEYS
163
+ build_timer_loop loop_val["timer"], errors, warnings
164
+ when DEADLINE_KEYS
165
+ build_deadline_loop loop_val["until"], errors, warnings
166
+ else
167
+ errors << "'loop' must contain exactly one of: 'infinite', 'count', 'timer', or 'until', and optionally 'pause'. Found: #{loop_val.keys.join ", "}"
168
+ Program::AST::CountedLoop.new(1)
169
+ end
170
+ lp.pause = pause
171
+ lp
172
+ when nil
173
+ Program::AST::CountedLoop.new(1)
174
+ else
175
+ errors << "'loop' must be an object. Found '#{loop_val.class.name}'"
176
+ Program::AST::CountedLoop.new(1)
177
+ end
178
+ end
179
+
180
+ def build_timer_loop(t, errors, warnings)
181
+ hours = t.delete "hours"
182
+ minutes = t.delete "minutes"
183
+
184
+ errors << "'loop.hours' must be an integer" if hours and !hours.is_a? Integer
185
+ errors << "'loop.minutes' must be an integer" if minutes and !minutes.is_a? Integer
186
+ errors << "Unknown keys in 'loop.timer': #{t.keys.join ", "}" if t.keys.any?
187
+
188
+ Program::AST::TimerLoop.new(hours || 0, minutes || 0)
189
+ end
190
+
191
+ def build_deadline_loop(t, errors, warnings)
192
+ date = t.delete "date"
193
+ time = t.delete "time"
194
+ errors << "Unknown keys in 'loop.until': #{t.keys.join ", "}" if t.keys.any?
195
+
196
+ stop_time = build_stop_time(date, time, errors, warnings)
197
+ Program::AST::DeadlineLoop.new(stop_time)
198
+ end
199
+
200
+ def build_sleep(t, errors, warnings)
201
+ sleep_val = t.delete "pause"
202
+ case sleep_val
203
+ when Integer, Float
204
+ sleep_val
205
+ when nil
206
+ nil
207
+ else
208
+ errors << "'pause' must be an integer or float"
209
+ nil
210
+ end
211
+ end
212
+
213
+ def build_devices(t, errors, warnings, inherited_devices = nil)
214
+ devices_ref = t.delete("devices") || {}
215
+ return inherited_devices if devices_ref.empty? and inherited_devices
216
+
217
+ refs_val, lights_val, groups_val = devices_ref.delete("inputs"), devices_ref.delete("lights"), devices_ref.delete("groups")
218
+ lights = lights_val ? device_names(Program::AST::Light, "lights", lights_val, errors, warnings) : []
219
+ groups = groups_val ? device_names(Program::AST::Group, "groups", groups_val, errors, warnings) : []
220
+ refs =
221
+ case refs_val
222
+ when "$all"
223
+ [Program::AST::DeviceRef.new(:all)]
224
+ when nil
225
+ []
226
+ when Array
227
+ if refs_val.all? { |ref| ref.is_a?(String) && ref =~ DEVICE_REF }
228
+ refs_val.map { |ref|
229
+ n = ref.match(DEVICE_REF).captures[0].to_i
230
+ Program::AST::DeviceRef.new(n)
231
+ }
232
+ else
233
+ errors << "If 'inputs' is an array, it must be an array of input variables (e.g. [$1, $2, ...])"
234
+ []
235
+ end
236
+ else
237
+ errors << "'inputs' must be '$all' or an array of input variables (e.g. [$1, $2, ...])"
238
+ []
239
+ end
240
+
241
+ errors << "Unknown keys in 'devices': #{devices_ref.keys.join ", "}" if devices_ref.keys.any?
242
+ lights + groups + refs
243
+ end
244
+
245
+ def device_names(type, key, val, errors, warnings)
246
+ if val.is_a?(Array) and val.all? { |name| name.is_a? String }
247
+ val.map { |name| type.new(name) }
248
+ else
249
+ errors << "'#{key}' must be an array of names (found #{val.class.name})"
250
+ []
251
+ end
252
+ end
253
+
254
+ def build_stop_time(date_val, time_val, errors, warnings)
255
+ now = Time.now
256
+ d =
257
+ begin
258
+ date_val ? Date.iso8601(date_val) : now.to_date
259
+ rescue Date::Error
260
+ errors << "Invalid date '#{date_val}'. Use \"YYYY-MM-DD\" format."
261
+ Date.today
262
+ end
263
+
264
+ hrs, min =
265
+ if time_val.nil?
266
+ [now.hour, now.min]
267
+ elsif time_val.is_a?(String) and time_val =~ HHMM
268
+ time_val.split(":", 2).map(&:to_i)
269
+ else
270
+ errors << "Invalid time '#{time_val}'. Use \"HH:MM\" format."
271
+ [0, 0]
272
+ end
273
+
274
+ begin
275
+ t = Time.new(d.year, d.month, d.day, hrs, min, 0, now.utc_offset)
276
+ warnings << "Time (#{t.iso8601}) is already in the past" if t < now
277
+ t
278
+ rescue ArgumentError
279
+ errors << "Invalid datetime (year=#{d.year} month=#{d.month} day=#{d.day} hrs=#{hrs} min=#{min} sec=0 offset=#{now.utc_offset})"
280
+ now
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
@@ -1,161 +1,23 @@
1
1
  module Huebot
2
- class Compiler
3
- DEVICE_FIELDS = %i(light lights group groups device devices).freeze
2
+ module Compiler
3
+ Error = Class.new(Error)
4
4
 
5
- def initialize(device_mapper)
6
- @device_mapper = device_mapper
7
- end
5
+ autoload :ApiV1, 'huebot/compiler/api_v1'
8
6
 
9
7
  #
10
8
  # Build a huebot program from an intermediate representation (a Hash).
11
9
  #
12
- # @param ir [Hash]
13
- # @param default_name [String] A name to use if one isn't specified
10
+ # @param src [Huebot::Program::Src]
14
11
  # @return [Huebot::Program]
15
12
  #
16
- def build(ir, default_name = nil)
17
- ir = ir.clone
18
- prog = Huebot::Program.new
19
- prog.name = ir.delete("name") || default_name
20
-
21
- # loop/loops
22
- val_loop = ir.delete("loop") || ir.delete(:loop)
23
- prog.errors << "'loop' must be 'true' or 'false'." if !val_loop.nil? and ![true, false].include?(val_loop)
24
- prog.loop = val_loop == true
25
-
26
- val_loops = ir.delete("loops") || ir.delete(:loops)
27
- prog.errors << "'loops' must be a positive integer." if !val_loops.nil? and val_loops.to_i < 0
28
- prog.loops = val_loops.to_i
29
-
30
- prog.errors << "'loop' and 'loops' are mutually exclusive." if prog.loop? and prog.loops > 0
31
-
32
- # initial state
33
- if (val_init = ir.delete("initial") || ir.delete(:initial))
34
- errors, warnings, state = build_transition val_init
35
- prog.initial_state = state
36
- prog.errors += errors
37
- prog.warnings += warnings
38
- end
39
-
40
- # transitions
41
- if (val_trns = ir.delete("transitions") || ir.delete(:transitions))
42
- val_trns.each do |val_trn|
43
- errors, warnings, state = if val_trn["parallel"] || val_trn[:parallel]
44
- build_parallel_transition val_trn
45
- else
46
- build_transition val_trn
47
- end
48
- prog.transitions << state
49
- prog.errors += errors
50
- prog.warnings += warnings
51
- end
52
- end
53
-
54
- # final state
55
- if (val_fnl = ir.delete("final") || ir.delete(:final))
56
- errors, warnings, state = build_transition val_fnl
57
- prog.final_state = state
58
- prog.errors += errors
59
- prog.warnings += warnings
60
- end
61
-
62
- # be strict about extra crap
63
- if (unknown = ir.keys.map(&:to_s)).any?
64
- prog.errors << "Unrecognized values: #{unknown.join ', '}."
65
- end
66
-
67
- # Add any warnings
68
- prog.warnings << "'final' is defined but will never be reached because 'loop' is 'true'." if prog.final_state and prog.loop?
69
-
70
- prog
71
- end
72
-
73
- private
74
-
75
- def build_parallel_transition(t)
76
- errors, warnings = [], []
77
- transition = Huebot::Program::ParallelTransition.new(0, [])
78
-
79
- transition.wait = t.delete("wait") || t.delete(:wait)
80
- errors << "'wait' must be a positive integer." if transition.wait and transition.wait.to_i <= 0
81
-
82
- parallel = t.delete("parallel") || t.delete(:parallel)
83
- if !parallel.is_a? Array
84
- errors << "'parallel' must be an array of transitions"
85
- else
86
- parallel.each do |sub_t|
87
- sub_errors, sub_warnings, sub_transition = build_transition(sub_t)
88
- errors += sub_errors
89
- warnings += sub_warnings
90
- transition.children << sub_transition
13
+ def self.build(src)
14
+ compiler_class =
15
+ case src.api_version
16
+ when 1.0 then ApiV1
17
+ else raise Error, "Unknown API version '#{src.api_version}'"
91
18
  end
92
- end
93
-
94
- return errors, warnings, transition
95
- end
96
-
97
- def build_transition(t)
98
- errors, warnings = [], []
99
- transition = Huebot::Program::Transition.new
100
- transition.devices = []
101
-
102
- map_devices(t, :light, :lights, :light!) { |map_errors, devices|
103
- errors += map_errors
104
- transition.devices += devices
105
- }
106
-
107
- map_devices(t, :group, :groups, :group!) { |map_errors, devices|
108
- errors += map_errors
109
- transition.devices += devices
110
- }
111
-
112
- map_devices(t, :device, :devices, :var!) { |map_errors, devices|
113
- errors += map_errors
114
- transition.devices += devices
115
- }
116
- errors << "Missing light/lights, group/groups, or device/devices" if transition.devices.empty?
117
-
118
- transition.wait = t.delete("wait") || t.delete(:wait)
119
- errors << "'wait' must be a positive integer." if transition.wait and transition.wait.to_i <= 0
120
-
121
- state = {}
122
- switch = t.delete("switch")
123
- switch = t.delete(:switch) if switch.nil?
124
- if !switch.nil?
125
- state[:on] = case switch
126
- when true, :on then true
127
- when false, :off then false
128
- else
129
- errors << "Unrecognized 'switch' value '#{switch}'."
130
- nil
131
- end
132
- end
133
- state[:transitiontime] = t.delete("time") || t.delete(:time) || t.delete("transitiontime") || t.delete(:transitiontime) || 4
134
-
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
138
- }
139
- return errors, warnings, transition
140
- end
141
-
142
- private
143
-
144
- def map_devices(t, singular_key, plural_key, ref_type)
145
- errors, devices = [], []
146
-
147
- key = t[singular_key.to_s] || t[singular_key]
148
- keys = t[plural_key.to_s] || t[plural_key]
149
-
150
- (Array(key) + Array(keys)).each { |x|
151
- begin
152
- devices += Array(@device_mapper.send(ref_type, x))
153
- rescue Huebot::DeviceMapper::Unmapped => e
154
- errors << e.message
155
- end
156
- }
157
-
158
- yield errors, devices
19
+ compiler = compiler_class.new(src.api_version)
20
+ compiler.build(src.tokens, src.default_name)
159
21
  end
160
22
  end
161
23
  end