huebot 0.5.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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