huebot 0.4.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.
- 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
|