huebot 0.5.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +96 -27
- data/bin/huebot +35 -32
- data/lib/huebot/bot.rb +82 -39
- data/lib/huebot/cli/helpers.rb +177 -0
- data/lib/huebot/cli/runner.rb +78 -0
- data/lib/huebot/cli.rb +2 -139
- data/lib/huebot/client.rb +5 -4
- data/lib/huebot/compiler/api_v1.rb +304 -0
- data/lib/huebot/compiler.rb +11 -149
- data/lib/huebot/device_mapper.rb +38 -24
- data/lib/huebot/device_state.rb +5 -1
- data/lib/huebot/group.rb +10 -3
- data/lib/huebot/light.rb +10 -3
- data/lib/huebot/program.rb +71 -21
- data/lib/huebot/version.rb +1 -1
- data/lib/huebot.rb +3 -22
- metadata +34 -3
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
|
-
|
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,304 @@
|
|
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
|
+
PERCENT_CAPTURE = /\A([0-9]+)%\Z/.freeze
|
17
|
+
MIN_KELVIN = 2000
|
18
|
+
MAX_KELVIN = 6530
|
19
|
+
MAX_BRI = 254
|
20
|
+
|
21
|
+
def initialize(api_version)
|
22
|
+
@api_version = api_version
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Huebot::Program]
|
26
|
+
def build(tokens, default_name = nil)
|
27
|
+
prog = Program.new
|
28
|
+
prog.name = tokens.delete("name") || default_name
|
29
|
+
prog.api_version = @api_version
|
30
|
+
prog.data = node tokens.dup
|
31
|
+
prog
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def node(t, inherited_devices = nil)
|
37
|
+
errors, warnings = [], []
|
38
|
+
instruction, child_nodes =
|
39
|
+
case t.keys
|
40
|
+
when TRANSITION_KEYS
|
41
|
+
build_transition t.fetch("transition"), errors, warnings, inherited_devices
|
42
|
+
when SERIAL_KEYS
|
43
|
+
build_serial t.fetch("serial"), errors, warnings, inherited_devices
|
44
|
+
when PARALLEL_KEYS
|
45
|
+
build_parallel t.fetch("parallel"), errors, warnings, inherited_devices
|
46
|
+
else
|
47
|
+
errors << "Expected exactly one of: transition, serial, parallel. Found #{t.keys}"
|
48
|
+
[Program::AST::NoOp.new, []]
|
49
|
+
end
|
50
|
+
Program::AST::Node.new(instruction, child_nodes, errors, warnings)
|
51
|
+
end
|
52
|
+
|
53
|
+
def build_transition(t, errors, warnings, inherited_devices = nil)
|
54
|
+
state = build_state(t, errors, warnings)
|
55
|
+
devices = build_devices(t, errors, warnings, inherited_devices)
|
56
|
+
slp = build_sleep(t, errors, warnings)
|
57
|
+
|
58
|
+
errors << "'transition' requires devices" if devices.empty?
|
59
|
+
errors << "Unknown keys in 'transition': #{t.keys.join ", "}" if t.keys.any?
|
60
|
+
|
61
|
+
instruction = Program::AST::Transition.new(state, devices, slp)
|
62
|
+
return instruction, []
|
63
|
+
end
|
64
|
+
|
65
|
+
def build_serial(t, errors, warnings, inherited_devices = nil)
|
66
|
+
lp = build_loop(t, errors, warnings)
|
67
|
+
slp = build_sleep(t, errors, warnings)
|
68
|
+
devices = build_devices(t, errors, warnings, inherited_devices)
|
69
|
+
children = build_steps(t, errors, warnings, devices)
|
70
|
+
|
71
|
+
errors << "'serial' requires steps" if children.empty?
|
72
|
+
errors << "Unknown keys in 'serial': #{t.keys.join ", "}" if t.keys.any?
|
73
|
+
|
74
|
+
instruction = Program::AST::SerialControl.new(lp, slp)
|
75
|
+
return instruction, children
|
76
|
+
end
|
77
|
+
|
78
|
+
def build_parallel(t, errors, warnings, inherited_devices = nil)
|
79
|
+
lp = build_loop(t, errors, warnings)
|
80
|
+
slp = build_sleep(t, errors, warnings)
|
81
|
+
devices = build_devices(t, errors, warnings, inherited_devices)
|
82
|
+
children = build_steps(t, errors, warnings, devices)
|
83
|
+
|
84
|
+
errors << "'parallel' requires steps" if children.empty?
|
85
|
+
errors << "Unknown keys in 'parallel': #{t.keys.join ", "}" if t.keys.any?
|
86
|
+
|
87
|
+
instruction = Program::AST::ParallelControl.new(lp, slp)
|
88
|
+
return instruction, children
|
89
|
+
end
|
90
|
+
|
91
|
+
def map_state_keys(state, errors, warnings)
|
92
|
+
# bugfix to YAML
|
93
|
+
case state.delete true
|
94
|
+
when true
|
95
|
+
state["on"] = true
|
96
|
+
when false
|
97
|
+
state["on"] = false
|
98
|
+
end
|
99
|
+
|
100
|
+
time = state.delete "time"
|
101
|
+
case time
|
102
|
+
when Integer, Float
|
103
|
+
state["transitiontime"] = (time.to_f * 10).round(0)
|
104
|
+
when nil
|
105
|
+
# pass
|
106
|
+
else
|
107
|
+
errors << "'transition.state.time' must be a number"
|
108
|
+
end
|
109
|
+
|
110
|
+
ctk = state.delete "ctk"
|
111
|
+
case ctk
|
112
|
+
when MIN_KELVIN..MAX_KELVIN
|
113
|
+
state["ct"] = (1_000_000 / ctk).round # https://en.wikipedia.org/wiki/Mired
|
114
|
+
when nil
|
115
|
+
# pass
|
116
|
+
else
|
117
|
+
errors << "'transition.state.ctk' must be an integer between #{MIN_KELVIN} and #{MAX_KELVIN}"
|
118
|
+
end
|
119
|
+
|
120
|
+
case state["bri"]
|
121
|
+
when Integer, nil
|
122
|
+
# pass
|
123
|
+
when PERCENT_CAPTURE
|
124
|
+
n = $1.to_i
|
125
|
+
if n >= 0 and n <= 100
|
126
|
+
percent = n * 0.01
|
127
|
+
state["bri"] = (MAX_BRI * percent).round
|
128
|
+
else
|
129
|
+
errors << "'transition.state.bri' must be an integer or a percent between 0% and 100%"
|
130
|
+
end
|
131
|
+
else
|
132
|
+
errors << "'transition.state.bri' must be an integer or a percent between 0% and 100%"
|
133
|
+
end
|
134
|
+
|
135
|
+
state
|
136
|
+
end
|
137
|
+
|
138
|
+
def build_state(t, errors, warnings)
|
139
|
+
state = t.delete "state"
|
140
|
+
case state
|
141
|
+
when Hash
|
142
|
+
map_state_keys state, errors, warnings
|
143
|
+
when nil
|
144
|
+
errors << "'state' is required in a transition"
|
145
|
+
{}
|
146
|
+
else
|
147
|
+
errors << "Expected 'state' to be an object, got a #{state.class.name}"
|
148
|
+
{}
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def build_steps(t, errors, warnings, inherited_devices = nil)
|
153
|
+
steps_val = t.delete "steps"
|
154
|
+
case steps_val
|
155
|
+
when Array
|
156
|
+
steps_val.map { |s| node s, inherited_devices }
|
157
|
+
when nil
|
158
|
+
errors << "Missing 'steps'"
|
159
|
+
[]
|
160
|
+
else
|
161
|
+
errors << "'steps' should be an array but is a #{steps_val.class.name}"
|
162
|
+
[]
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def build_loop(t, errors, warnings)
|
167
|
+
loop_val = t.delete "loop"
|
168
|
+
case loop_val
|
169
|
+
when Hash
|
170
|
+
pause = loop_val.delete "pause"
|
171
|
+
errors << "'loop.pause' must be an integer. Found '#{pause.class.name}'" if pause and !pause.is_a? Integer
|
172
|
+
|
173
|
+
lp =
|
174
|
+
case loop_val.keys
|
175
|
+
when INFINITE_KEYS
|
176
|
+
loop_val["infinite"] == true ? Program::AST::InfiniteLoop.new : Program::AST::CountedLoop.new(1)
|
177
|
+
when COUNT_KEYS
|
178
|
+
num = loop_val["count"]
|
179
|
+
errors << "'loop.count' must be an integer. Found '#{num.class.name}'" unless num.is_a? Integer
|
180
|
+
Program::AST::CountedLoop.new(num)
|
181
|
+
when TIMER_KEYS
|
182
|
+
build_timer_loop loop_val["timer"], errors, warnings
|
183
|
+
when DEADLINE_KEYS
|
184
|
+
build_deadline_loop loop_val["until"], errors, warnings
|
185
|
+
else
|
186
|
+
errors << "'loop' must contain exactly one of: 'infinite', 'count', 'timer', or 'until', and optionally 'pause'. Found: #{loop_val.keys.join ", "}"
|
187
|
+
Program::AST::CountedLoop.new(1)
|
188
|
+
end
|
189
|
+
lp.pause = pause
|
190
|
+
lp
|
191
|
+
when nil
|
192
|
+
Program::AST::CountedLoop.new(1)
|
193
|
+
else
|
194
|
+
errors << "'loop' must be an object. Found '#{loop_val.class.name}'"
|
195
|
+
Program::AST::CountedLoop.new(1)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def build_timer_loop(t, errors, warnings)
|
200
|
+
hours = t.delete "hours"
|
201
|
+
minutes = t.delete "minutes"
|
202
|
+
|
203
|
+
errors << "'loop.hours' must be an integer" if hours and !hours.is_a? Integer
|
204
|
+
errors << "'loop.minutes' must be an integer" if minutes and !minutes.is_a? Integer
|
205
|
+
errors << "Unknown keys in 'loop.timer': #{t.keys.join ", "}" if t.keys.any?
|
206
|
+
|
207
|
+
Program::AST::TimerLoop.new(hours || 0, minutes || 0)
|
208
|
+
end
|
209
|
+
|
210
|
+
def build_deadline_loop(t, errors, warnings)
|
211
|
+
date = t.delete "date"
|
212
|
+
time = t.delete "time"
|
213
|
+
errors << "Unknown keys in 'loop.until': #{t.keys.join ", "}" if t.keys.any?
|
214
|
+
|
215
|
+
stop_time = build_stop_time(date, time, errors, warnings)
|
216
|
+
Program::AST::DeadlineLoop.new(stop_time)
|
217
|
+
end
|
218
|
+
|
219
|
+
def build_sleep(t, errors, warnings)
|
220
|
+
sleep_val = t.delete "pause"
|
221
|
+
case sleep_val
|
222
|
+
when Integer, Float
|
223
|
+
sleep_val
|
224
|
+
when nil
|
225
|
+
nil
|
226
|
+
else
|
227
|
+
errors << "'pause' must be an integer or float"
|
228
|
+
nil
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def build_devices(t, errors, warnings, inherited_devices = nil)
|
233
|
+
devices_ref = t.delete("devices") || {}
|
234
|
+
return inherited_devices if devices_ref.empty? and inherited_devices
|
235
|
+
|
236
|
+
refs_val, lights_val, groups_val = devices_ref.delete("inputs"), devices_ref.delete("lights"), devices_ref.delete("groups")
|
237
|
+
lights = lights_val ? device_names(Program::AST::Light, "lights", lights_val, errors, warnings) : []
|
238
|
+
groups = groups_val ? device_names(Program::AST::Group, "groups", groups_val, errors, warnings) : []
|
239
|
+
refs =
|
240
|
+
case refs_val
|
241
|
+
when "$all"
|
242
|
+
[Program::AST::DeviceRef.new(:all)]
|
243
|
+
when nil
|
244
|
+
[]
|
245
|
+
when Array
|
246
|
+
if refs_val.all? { |ref| ref.is_a?(String) && ref =~ DEVICE_REF }
|
247
|
+
refs_val.map { |ref|
|
248
|
+
n = ref.match(DEVICE_REF).captures[0].to_i
|
249
|
+
Program::AST::DeviceRef.new(n)
|
250
|
+
}
|
251
|
+
else
|
252
|
+
errors << "If 'inputs' is an array, it must be an array of input variables (e.g. [$1, $2, ...])"
|
253
|
+
[]
|
254
|
+
end
|
255
|
+
else
|
256
|
+
errors << "'inputs' must be '$all' or an array of input variables (e.g. [$1, $2, ...])"
|
257
|
+
[]
|
258
|
+
end
|
259
|
+
|
260
|
+
errors << "Unknown keys in 'devices': #{devices_ref.keys.join ", "}" if devices_ref.keys.any?
|
261
|
+
lights + groups + refs
|
262
|
+
end
|
263
|
+
|
264
|
+
def device_names(type, key, val, errors, warnings)
|
265
|
+
if val.is_a?(Array) and val.all? { |name| name.is_a? String }
|
266
|
+
val.map { |name| type.new(name) }
|
267
|
+
else
|
268
|
+
errors << "'#{key}' must be an array of names (found #{val.class.name})"
|
269
|
+
[]
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
def build_stop_time(date_val, time_val, errors, warnings)
|
274
|
+
now = Time.now
|
275
|
+
d =
|
276
|
+
begin
|
277
|
+
date_val ? Date.iso8601(date_val) : now.to_date
|
278
|
+
rescue Date::Error
|
279
|
+
errors << "Invalid date '#{date_val}'. Use \"YYYY-MM-DD\" format."
|
280
|
+
Date.today
|
281
|
+
end
|
282
|
+
|
283
|
+
hrs, min =
|
284
|
+
if time_val.nil?
|
285
|
+
[now.hour, now.min]
|
286
|
+
elsif time_val.is_a?(String) and time_val =~ HHMM
|
287
|
+
time_val.split(":", 2).map(&:to_i)
|
288
|
+
else
|
289
|
+
errors << "Invalid time '#{time_val}'. Use \"HH:MM\" format."
|
290
|
+
[0, 0]
|
291
|
+
end
|
292
|
+
|
293
|
+
begin
|
294
|
+
t = Time.new(d.year, d.month, d.day, hrs, min, 0, now.utc_offset)
|
295
|
+
warnings << "Time (#{t.iso8601}) is already in the past" if t < now
|
296
|
+
t
|
297
|
+
rescue ArgumentError
|
298
|
+
errors << "Invalid datetime (year=#{d.year} month=#{d.month} day=#{d.day} hrs=#{hrs} min=#{min} sec=0 offset=#{now.utc_offset})"
|
299
|
+
now
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
data/lib/huebot/compiler.rb
CHANGED
@@ -1,161 +1,23 @@
|
|
1
1
|
module Huebot
|
2
|
-
|
3
|
-
|
2
|
+
module Compiler
|
3
|
+
Error = Class.new(Error)
|
4
4
|
|
5
|
-
|
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
|
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(
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
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
|