huebot 0.4.0 → 1.0.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 +101 -31
- data/bin/huebot +60 -27
- data/lib/huebot/bot.rb +82 -39
- data/lib/huebot/bridge.rb +24 -0
- data/lib/huebot/cli/helpers.rb +170 -0
- data/lib/huebot/cli/runner.rb +78 -0
- data/lib/huebot/cli.rb +2 -107
- data/lib/huebot/client.rb +132 -0
- data/lib/huebot/compiler/api_v1.rb +285 -0
- data/lib/huebot/compiler.rb +12 -148
- data/lib/huebot/config.rb +41 -0
- data/lib/huebot/device_mapper.rb +40 -26
- data/lib/huebot/device_state.rb +11 -0
- data/lib/huebot/group.rb +30 -0
- data/lib/huebot/light.rb +30 -0
- data/lib/huebot/program.rb +71 -21
- data/lib/huebot/version.rb +1 -1
- data/lib/huebot.rb +9 -24
- metadata +44 -18
- data/lib/hue/LICENSE +0 -22
- data/lib/hue/bridge.rb +0 -140
- data/lib/hue/client.rb +0 -141
- data/lib/hue/editable_state.rb +0 -27
- data/lib/hue/errors.rb +0 -38
- data/lib/hue/group.rb +0 -181
- data/lib/hue/light.rb +0 -177
- data/lib/hue/scene.rb +0 -50
- data/lib/hue/translate_keys.rb +0 -21
- data/lib/hue/version.rb +0 -3
- data/lib/hue.rb +0 -13
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,108 +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
|
-
#
|
27
|
-
# Parses and returns input from the CLI. Serious errors might result in the program exiting.
|
28
|
-
#
|
29
|
-
# @return [Huebot::CLI::Options] All given CLI options
|
30
|
-
# @return [Array<Huebot::ProgramSrc>] Array of given program sources
|
31
|
-
#
|
32
|
-
def self.get_input!
|
33
|
-
options, parser = option_parser
|
34
|
-
parser.parse!
|
35
|
-
|
36
|
-
files = ARGV[1..-1]
|
37
|
-
if files.empty? and !options.read_stdin
|
38
|
-
puts parser.help
|
39
|
-
exit 1
|
40
|
-
elsif (bad_paths = files.select { |p| !File.exists? p }).any?
|
41
|
-
$stderr.puts "Cannot find #{bad_paths.join ', '}"
|
42
|
-
exit 1
|
43
|
-
else
|
44
|
-
sources = files.map { |path|
|
45
|
-
ProgramSrc.new(YAML.load_file(path), path)
|
46
|
-
}
|
47
|
-
sources << ProgramSrc.new(YAML.load($stdin.read), "STDIN") if options.read_stdin
|
48
|
-
return options, sources
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
#
|
53
|
-
# Prints any program errors or warnings, and returns a boolean for each.
|
54
|
-
#
|
55
|
-
# @param programs [Array<Huebot::Program>]
|
56
|
-
# @param io [IO] Usually $stdout or $stderr
|
57
|
-
# @param quiet [Boolean] if true, don't print anything
|
58
|
-
#
|
59
|
-
def self.check!(programs, io, quiet: false)
|
60
|
-
if (invalid_progs = programs.select { |prog| prog.errors.any? }).any?
|
61
|
-
print_messages! io, "Errors", invalid_progs, :errors unless quiet
|
62
|
-
end
|
63
|
-
|
64
|
-
if (imperfect_progs = programs.select { |prog| prog.warnings.any? }).any?
|
65
|
-
puts "" if invalid_progs.any?
|
66
|
-
print_messages! io, "Warnings", imperfect_progs, :warnings unless quiet
|
67
|
-
end
|
68
|
-
|
69
|
-
return invalid_progs.any?, imperfect_progs.any?
|
70
|
-
end
|
71
|
-
|
72
|
-
# Print help and exit
|
73
|
-
def self.help!
|
74
|
-
_, parser = option_parser
|
75
|
-
puts parser.help
|
76
|
-
exit 1
|
77
|
-
end
|
78
|
-
|
79
|
-
private
|
80
|
-
|
81
|
-
#
|
82
|
-
# Print each message (of the given type) for each program.
|
83
|
-
#
|
84
|
-
# @param io [IO] Usually $stdout or $stderr
|
85
|
-
# @param label [String] Top-level for this group of messages
|
86
|
-
# @param progs [Array<Huebot::CLI::Program>]
|
87
|
-
# @param msg_type [Symbol] name of method that holds the messages (i.e. :errors or :warnings)
|
88
|
-
#
|
89
|
-
def self.print_messages!(io, label, progs, msg_type)
|
90
|
-
io.puts "#{label}:"
|
91
|
-
progs.each { |prog|
|
92
|
-
io.puts " #{prog.name}:"
|
93
|
-
prog.send(msg_type).each_with_index { |msg, i| io.puts " #{i+1}. #{msg}" }
|
94
|
-
}
|
95
|
-
end
|
96
|
-
|
97
|
-
def self.option_parser
|
98
|
-
options = Options.new([], false)
|
99
|
-
parser = OptionParser.new { |opts|
|
100
|
-
opts.banner = %(
|
101
|
-
List all lights and groups:
|
102
|
-
huebot ls
|
103
|
-
|
104
|
-
Run program(s):
|
105
|
-
huebot run file1.yml [file2.yml [file3.yml ...]] [options]
|
106
|
-
|
107
|
-
Validate programs and inputs:
|
108
|
-
huebot check file1.yml [file2.yml [file3.yml ...]] [options]
|
109
|
-
|
110
|
-
Options:
|
111
|
-
).strip
|
112
|
-
opts.on("-lLIGHT", "--light=LIGHT", "Light ID or name") { |l| options.inputs << LightInput.new(l) }
|
113
|
-
opts.on("-gGROUP", "--group=GROUP", "Group ID or name") { |g| options.inputs << GroupInput.new(g) }
|
114
|
-
opts.on("--all", "All lights and groups TODO") { $stderr.puts "Not Implemented"; exit 1 }
|
115
|
-
opts.on("-i", "Read program from STDIN") { options.read_stdin = true }
|
116
|
-
opts.on("-h", "--help", "Prints this help") { puts opts; exit }
|
117
|
-
}
|
118
|
-
return options, parser
|
119
|
-
end
|
13
|
+
autoload :Helpers, 'huebot/cli/helpers'
|
14
|
+
autoload :Runner, 'huebot/cli/runner'
|
120
15
|
end
|
121
16
|
end
|
@@ -0,0 +1,132 @@
|
|
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
|
+
Error = Class.new(Error)
|
10
|
+
|
11
|
+
attr_reader :config
|
12
|
+
|
13
|
+
def initialize(config = Huebot::Config.new)
|
14
|
+
@config = config
|
15
|
+
@ip = config["ip"] # NOTE will usually be null
|
16
|
+
@username = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
def connect
|
20
|
+
if config["ip"]
|
21
|
+
@ip = config["ip"]
|
22
|
+
elsif config["id"]
|
23
|
+
@ip = bridges.detect { |b| b.id == id }&.ip
|
24
|
+
return "Unable to find Hue Bridge '#{config["id"]}' on your network" if @ip.nil?
|
25
|
+
else
|
26
|
+
bridge = bridges.first
|
27
|
+
return "Unable to find a Hue Bridge on your network" if bridge.nil?
|
28
|
+
config["id"] = bridge.id
|
29
|
+
@ip = bridge.ip
|
30
|
+
end
|
31
|
+
|
32
|
+
if config["username"]
|
33
|
+
if valid_username? config["username"]
|
34
|
+
@username = config["username"]
|
35
|
+
else
|
36
|
+
return "Invalid Hue Bridge username '#{config["username"]}'"
|
37
|
+
end
|
38
|
+
else
|
39
|
+
username, error = register
|
40
|
+
return error if error
|
41
|
+
config["username"] = @username = username
|
42
|
+
end
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def get!(path)
|
47
|
+
resp, error = get path
|
48
|
+
raise Error, error if error
|
49
|
+
resp
|
50
|
+
end
|
51
|
+
|
52
|
+
def get(path)
|
53
|
+
url = "http://#{@ip}/api"
|
54
|
+
url << "/#{@username}" if @username
|
55
|
+
url << path
|
56
|
+
req = Net::HTTP::Get.new(URI(url))
|
57
|
+
req_json req
|
58
|
+
end
|
59
|
+
|
60
|
+
def post!(path, body)
|
61
|
+
resp, error = post path, body
|
62
|
+
raise Error, error if error
|
63
|
+
resp
|
64
|
+
end
|
65
|
+
|
66
|
+
def post(path, body)
|
67
|
+
url = "http://#{@ip}/api"
|
68
|
+
url << "/#{@username}" if @username
|
69
|
+
url << path
|
70
|
+
req = Net::HTTP::Post.new(URI(url))
|
71
|
+
req["Content-Type"] = "application/json"
|
72
|
+
req.body = body.to_json
|
73
|
+
req_json req
|
74
|
+
end
|
75
|
+
|
76
|
+
def put!(path, body)
|
77
|
+
resp, error = put path, body
|
78
|
+
raise Error, error if error
|
79
|
+
resp
|
80
|
+
end
|
81
|
+
|
82
|
+
def put(path, body)
|
83
|
+
url = "http://#{@ip}/api"
|
84
|
+
url << "/#{@username}" if @username
|
85
|
+
url << path
|
86
|
+
req = Net::HTTP::Put.new(URI(url))
|
87
|
+
req["Content-Type"] = "application/json"
|
88
|
+
req.body = body.to_json
|
89
|
+
req_json req
|
90
|
+
end
|
91
|
+
|
92
|
+
def req_json(req)
|
93
|
+
resp = Net::HTTP.start req.uri.host, req.uri.port, {use_ssl: false} do |http|
|
94
|
+
http.request req
|
95
|
+
end
|
96
|
+
case resp.code.to_i
|
97
|
+
when 200..201
|
98
|
+
data = JSON.parse(resp.body)
|
99
|
+
if data[0] and (error = data[0]["error"])
|
100
|
+
return nil, error.fetch("description")
|
101
|
+
else
|
102
|
+
return data, nil
|
103
|
+
end
|
104
|
+
else
|
105
|
+
raise Error, "Unexpected response from Bridge (#{resp.code}): #{resp.body}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def bridges
|
110
|
+
req = Net::HTTP::Get.new(DISCOVERY_URI)
|
111
|
+
resp = Net::HTTP.start req.uri.host, req.uri.port, {use_ssl: true} do |http|
|
112
|
+
http.request req
|
113
|
+
end
|
114
|
+
JSON.parse(resp.body).map { |x|
|
115
|
+
Bridge.new(x.fetch("id"), x.fetch("internalipaddress"))
|
116
|
+
}
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def valid_username?(username)
|
122
|
+
_resp, error = get("/#{username}")
|
123
|
+
!error
|
124
|
+
end
|
125
|
+
|
126
|
+
def register
|
127
|
+
resp, error = post "/", {"devicetype": "huebot"}
|
128
|
+
return nil, error if error
|
129
|
+
resp[0].fetch("success").fetch("username")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -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
|
data/lib/huebot/compiler.rb
CHANGED
@@ -1,159 +1,23 @@
|
|
1
1
|
module Huebot
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
2
|
+
module Compiler
|
3
|
+
Error = Class.new(Error)
|
4
|
+
|
5
|
+
autoload :ApiV1, 'huebot/compiler/api_v1'
|
6
6
|
|
7
7
|
#
|
8
8
|
# Build a huebot program from an intermediate representation (a Hash).
|
9
9
|
#
|
10
|
-
# @param
|
11
|
-
# @param default_name [String] A name to use if one isn't specified
|
10
|
+
# @param src [Huebot::Program::Src]
|
12
11
|
# @return [Huebot::Program]
|
13
12
|
#
|
14
|
-
def build(
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
# loop/loops
|
20
|
-
val_loop = ir.delete("loop") || ir.delete(:loop)
|
21
|
-
prog.errors << "'loop' must be 'true' or 'false'." if !val_loop.nil? and ![true, false].include?(val_loop)
|
22
|
-
prog.loop = val_loop == true
|
23
|
-
|
24
|
-
val_loops = ir.delete("loops") || ir.delete(:loops)
|
25
|
-
prog.errors << "'loops' must be a positive integer." if !val_loops.nil? and val_loops.to_i < 0
|
26
|
-
prog.loops = val_loops.to_i
|
27
|
-
|
28
|
-
prog.errors << "'loop' and 'loops' are mutually exclusive." if prog.loop? and prog.loops > 0
|
29
|
-
|
30
|
-
# initial state
|
31
|
-
if (val_init = ir.delete("initial") || ir.delete(:initial))
|
32
|
-
errors, warnings, state = build_transition val_init
|
33
|
-
prog.initial_state = state
|
34
|
-
prog.errors += errors
|
35
|
-
prog.warnings += warnings
|
36
|
-
end
|
37
|
-
|
38
|
-
# transitions
|
39
|
-
if (val_trns = ir.delete("transitions") || ir.delete(:transitions))
|
40
|
-
val_trns.each do |val_trn|
|
41
|
-
errors, warnings, state = if val_trn["parallel"] || val_trn[:parallel]
|
42
|
-
build_parallel_transition val_trn
|
43
|
-
else
|
44
|
-
build_transition val_trn
|
45
|
-
end
|
46
|
-
prog.transitions << state
|
47
|
-
prog.errors += errors
|
48
|
-
prog.warnings += warnings
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
# final state
|
53
|
-
if (val_fnl = ir.delete("final") || ir.delete(:final))
|
54
|
-
errors, warnings, state = build_transition val_fnl
|
55
|
-
prog.final_state = state
|
56
|
-
prog.errors += errors
|
57
|
-
prog.warnings += warnings
|
58
|
-
end
|
59
|
-
|
60
|
-
# be strict about extra crap
|
61
|
-
if (unknown = ir.keys.map(&:to_s)).any?
|
62
|
-
prog.errors << "Unrecognized values: #{unknown.join ', '}."
|
63
|
-
end
|
64
|
-
|
65
|
-
# Add any warnings
|
66
|
-
prog.warnings << "'final' is defined but will never be reached because 'loop' is 'true'." if prog.final_state and prog.loop?
|
67
|
-
|
68
|
-
prog
|
69
|
-
end
|
70
|
-
|
71
|
-
private
|
72
|
-
|
73
|
-
def build_parallel_transition(t)
|
74
|
-
errors, warnings = [], []
|
75
|
-
transition = Huebot::Program::ParallelTransition.new(0, [])
|
76
|
-
|
77
|
-
transition.wait = t.delete("wait") || t.delete(:wait)
|
78
|
-
errors << "'wait' must be a positive integer." if transition.wait and transition.wait.to_i <= 0
|
79
|
-
|
80
|
-
parallel = t.delete("parallel") || t.delete(:parallel)
|
81
|
-
if !parallel.is_a? Array
|
82
|
-
errors << "'parallel' must be an array of transitions"
|
83
|
-
else
|
84
|
-
parallel.each do |sub_t|
|
85
|
-
sub_errors, sub_warnings, sub_transition = build_transition(sub_t)
|
86
|
-
errors += sub_errors
|
87
|
-
warnings += sub_warnings
|
88
|
-
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}'"
|
89
18
|
end
|
90
|
-
|
91
|
-
|
92
|
-
return errors, warnings, transition
|
93
|
-
end
|
94
|
-
|
95
|
-
def build_transition(t)
|
96
|
-
errors, warnings = [], []
|
97
|
-
transition = Huebot::Program::Transition.new
|
98
|
-
transition.devices = []
|
99
|
-
|
100
|
-
map_devices(t, :light, :lights, :light!) { |map_errors, devices|
|
101
|
-
errors += map_errors
|
102
|
-
transition.devices += devices
|
103
|
-
}
|
104
|
-
|
105
|
-
map_devices(t, :group, :groups, :group!) { |map_errors, devices|
|
106
|
-
errors += map_errors
|
107
|
-
transition.devices += devices
|
108
|
-
}
|
109
|
-
|
110
|
-
map_devices(t, :device, :devices, :var!) { |map_errors, devices|
|
111
|
-
errors += map_errors
|
112
|
-
transition.devices += devices
|
113
|
-
}
|
114
|
-
errors << "Missing light/lights, group/groups, or device/devices" if transition.devices.empty?
|
115
|
-
|
116
|
-
transition.wait = t.delete("wait") || t.delete(:wait)
|
117
|
-
errors << "'wait' must be a positive integer." if transition.wait and transition.wait.to_i <= 0
|
118
|
-
|
119
|
-
state = {}
|
120
|
-
switch = t.delete("switch")
|
121
|
-
switch = t.delete(:switch) if switch.nil?
|
122
|
-
if !switch.nil?
|
123
|
-
state[:on] = case switch
|
124
|
-
when true, :on then true
|
125
|
-
when false, :off then false
|
126
|
-
else
|
127
|
-
errors << "Unrecognized 'switch' value '#{switch}'."
|
128
|
-
nil
|
129
|
-
end
|
130
|
-
end
|
131
|
-
state[:transitiontime] = t.delete("time") || t.delete(:time) || t.delete("transitiontime") || t.delete(:transitiontime) || 4
|
132
|
-
|
133
|
-
transition.state = t.merge(state).reduce({}) { |a, (key, val)|
|
134
|
-
a[key.to_sym] = val
|
135
|
-
a
|
136
|
-
}
|
137
|
-
return errors, warnings, transition
|
138
|
-
end
|
139
|
-
|
140
|
-
private
|
141
|
-
|
142
|
-
def map_devices(t, singular_key, plural_key, ref_type)
|
143
|
-
errors, devices = [], []
|
144
|
-
|
145
|
-
key = t[singular_key.to_s] || t[singular_key]
|
146
|
-
keys = t[plural_key.to_s] || t[plural_key]
|
147
|
-
|
148
|
-
(Array(key) + Array(keys)).each { |x|
|
149
|
-
begin
|
150
|
-
devices += Array(@device_mapper.send(ref_type, x))
|
151
|
-
rescue Huebot::DeviceMapper::Unmapped => e
|
152
|
-
errors << e.message
|
153
|
-
end
|
154
|
-
}
|
155
|
-
|
156
|
-
yield errors, devices
|
19
|
+
compiler = compiler_class.new(src.api_version)
|
20
|
+
compiler.build(src.tokens, src.default_name)
|
157
21
|
end
|
158
22
|
end
|
159
23
|
end
|