huebot 0.5.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b919267fa7949b2e208ca3b62112c21f05aa1cb9e52a1cb175ab32a96e2b3411
4
- data.tar.gz: 8ba14d33785521841fe31daff20786b6ead22592d15e3938b7b518202411b888
3
+ metadata.gz: 5da1b1dcd0f32ce09e44155b39d7bc6ed92aa285944fc3a67801cf02642737c0
4
+ data.tar.gz: 9116d3a891e608276e64517f9252a42cddd615ca9952c55e816ca53196bdde15
5
5
  SHA512:
6
- metadata.gz: 462cf2419a33689160a4471157b07302ab3fbbc48341903dde3d1c520f4cfc72a21fe6f1dc83262251270c692ad991aa04b666229f30b7218dc70d98f20ec398
7
- data.tar.gz: 04c0e7d1fec4c384ceab06e022442ca90f9fefafbe0b15df9d6e3d8fd9db291ab16eca3af13e1db1edf0b8ea902e9d9e89bc36cdaa072e72fa956152db617c63
6
+ metadata.gz: fc1ae1f775c685e635fe36d85ef33548a50163f80378f42301f73e9bdf868343fecb4b9a9fa64490466771dc44b51f95745587bc32423e65cdc306dae0e01fa9
7
+ data.tar.gz: b5721fb115fc0f8c38e9389063a9012a51d7aa2fa0cf2f1b7a2d93812d916b2ffb1f27508358483a9656b4bdb09d7f78fc7bd9e4f65ff599214c9cdd2f0e8a93
data/README.md CHANGED
@@ -1,34 +1,109 @@
1
1
  # Huebot
2
2
 
3
- Program your Hue lights in YAML!
3
+ Program your Hue lights!
4
4
 
5
5
  $ huebot run dimmer.yml --light="Office Desk"
6
6
 
7
- **dimmer.yml**
7
+ A few examples are below. [See the Wiki](https://github.com/jhollinger/huebot/wiki) for full documentation.
8
8
 
9
- This (very simple) program starts with the light(s) on at full brightness, then enters an infinite loop of slowly dimming and raising the light(s). Since no color is specified, the light(s) will retain whatever color they last had.
9
+ **dimmer.yaml**
10
+
11
+ This (very simple) program starts with the light(s) on at full brightness, then enters an hour and a half long loop of slowly dimming and raising the light(s). It finishes by turning them off again. Since no color is specified, the light(s) will retain whatever color they last had.
12
+
13
+ ```yaml
14
+ serial:
15
+ devices:
16
+ lights:
17
+ - LR Lamp 1
18
+ - LR Lamp 2
19
+ groups:
20
+ - Dining Room
21
+ steps:
22
+ - transition:
23
+ state:
24
+ on: true
25
+ bri: 254
26
+
27
+ - serial:
28
+ loop:
29
+ timer:
30
+ hours: 1
31
+ minutes: 30
32
+ steps:
33
+ - transition:
34
+ state:
35
+ bri: 150
36
+ time: 10 # 10 second transition
37
+ pause: 2 # 2 second pause before the next step
38
+
39
+ - transition:
40
+ state:
41
+ bri: 254
42
+ time: 10 # 10 second transition
43
+ pause: 2 # 2 second pause before the next step
44
+
45
+ - transition:
46
+ state:
47
+ on: false
48
+ ```
49
+
50
+ **party.yaml**
51
+
52
+ This more complicated program starts by switching devices on, then enters an infinite loop of two parallel steps. One branch fades up and down while the other branch is fading more lights down _then_ up.
10
53
 
11
54
  ```yaml
12
- initial:
13
- switch: on
14
- brightness: 254
15
- device: $all
16
-
17
- loop: true
18
-
19
- transitions:
20
- - device: $all
21
- brightness: 150
22
- time: 100
23
- wait: 20
24
-
25
- - device: $all
26
- brightness: 254
27
- time: 100
28
- wait: 20
55
+ serial:
56
+ steps:
57
+ # Turn all inputs on to a mid brightness
58
+ - transition:
59
+ devices:
60
+ inputs: $all
61
+ state:
62
+ on: true
63
+ bri: 100
64
+
65
+ # Run these steps in parallel in an infinite loop
66
+ - parallel:
67
+ loop: true
68
+ steps:
69
+ # Parallel branch 1: Fade inputs #1 and #3 up and down
70
+ - serial:
71
+ devices:
72
+ inputs:
73
+ - $1
74
+ - $3
75
+ steps:
76
+ - transition:
77
+ state:
78
+ bri: 254
79
+ time: 10 # transition over 10 seconds
80
+ pause: 5 # pause an extra 5 sec after the transition
81
+ - transition:
82
+ state:
83
+ bri: 25
84
+ time: 10
85
+ pause: 5
86
+
87
+ # Parallel branch 2: Fade inputs #2 and #4 down and up
88
+ - serial:
89
+ devices:
90
+ inputs:
91
+ - $2
92
+ - $4
93
+ steps:
94
+ - transition:
95
+ state:
96
+ bri: 25
97
+ time: 10
98
+ pause: 5
99
+ - transition:
100
+ state:
101
+ bri: 254
102
+ time: 10
103
+ pause: 5
29
104
  ```
30
105
 
31
- The variable `$all` refers to all lights and/or groups passed in on the command line. They can be also referred to individually as `$1`, `$2`, `$3`, etc. The names of lights and groups can also be hard-coded into your program. [See examples in the Wiki.](https://github.com/jhollinger/huebot/wiki)
106
+ [See the Wiki](https://github.com/jhollinger/huebot/wiki) for more documentation and examples.
32
107
 
33
108
  ## Install
34
109
 
@@ -43,9 +118,3 @@ Configuration is stored in `~/.config/huebot`.
43
118
  ## License
44
119
 
45
120
  Huebot is licensed under the MIT license (see LICENSE file).
46
-
47
- **TODO**
48
-
49
- * Validate number of inputs against compiled programs
50
- * More explanation various features in Wiki
51
- * More examples in Wiki
data/bin/huebot CHANGED
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  # Used for local testing
4
- # $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
4
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
5
5
 
6
6
  require 'huebot'
7
- require 'huebot/cli'
8
7
 
9
- Huebot::CLI.tap { |cli|
8
+ Huebot::CLI::Helpers.tap { |cli|
10
9
  case cli.get_cmd
11
10
  when :ls
12
11
  bridge, error = Huebot::Bridge.connect
@@ -14,64 +13,68 @@ Huebot::CLI.tap { |cli|
14
13
  $stderr.puts error
15
14
  exit 1
16
15
  end
17
-
18
- puts "Lights\n" + bridge.lights.map { |l| " #{l.id}: #{l.name}" }.join("\n") + \
19
- "\nGroups\n" + bridge.groups.map { |g| " #{g.id}: #{g.name}" }.join("\n")
16
+ retval = Huebot::CLI::Runner.ls bridge
17
+ exit retval
20
18
 
21
19
  when :run
22
20
  opts, sources = cli.get_input!
21
+ if sources.empty?
22
+ cli.help!
23
+ exit 1
24
+ end
23
25
 
24
26
  bridge, error = Huebot::Bridge.connect
25
27
  if error
26
28
  $stderr.puts error
27
29
  exit 1
28
30
  end
29
-
30
- device_mapper = Huebot::DeviceMapper.new(bridge, opts.inputs)
31
- compiler = Huebot::Compiler.new(device_mapper)
32
-
33
- programs = sources.map { |src|
34
- compiler.build src.ir, File.basename(src.filepath, ".*")
35
- }
36
- found_errors, _found_warnings = cli.check! programs, $stderr
37
- exit 1 if found_errors
38
-
39
- bot = Huebot::Bot.new(bridge)
40
- programs.each { |prog| bot.execute prog }
31
+ retval = Huebot::CLI::Runner.run(bridge, sources, opts)
32
+ exit retval
41
33
 
42
34
  when :check
43
35
  opts, sources = cli.get_input!
36
+ if sources.empty?
37
+ cli.help!
38
+ exit 1
39
+ end
44
40
 
45
41
  bridge, error = Huebot::Bridge.connect
46
42
  if error
47
43
  $stderr.puts error
48
44
  exit 1
49
45
  end
46
+ retval = Huebot::CLI::Runner.check(bridge, sources, opts)
47
+ exit retval
50
48
 
51
- device_mapper = Huebot::DeviceMapper.new(bridge, opts.inputs)
52
- compiler = Huebot::Compiler.new(device_mapper)
49
+ when :"get-state"
50
+ opts, _sources = cli.get_input!
51
+ if opts.inputs.empty?
52
+ cli.help!
53
+ exit 1
54
+ end
53
55
 
54
- programs = sources.map { |src|
55
- compiler.build src.ir, File.basename(src.filepath, ".*")
56
- }
57
- found_errors, found_warnings = cli.check! programs, $stdout
58
- # TODO validate NUMBER of inputs against each program
59
- exit (found_errors || found_warnings) ? 1 : 0
56
+ bridge, error = Huebot::Bridge.connect
57
+ if error
58
+ $stderr.puts error
59
+ exit 1
60
+ end
61
+ retval = Huebot::CLI::Runner.get_state(bridge, opts.inputs)
62
+ exit retval
60
63
 
61
64
  when :"set-ip"
62
65
  ip = cli.get_args(num: 1).first
63
- config = Huebot::Config.new
64
- config["ip"] = ip
66
+ retval = Huebot::CLI::Runner.set_ip ip
67
+ exit retval
65
68
 
66
69
  when :"clear-ip"
67
70
  cli.get_args(num: 0)
68
- config = Huebot::Config.new
69
- config["ip"] = nil
71
+ retval = Huebot::CLI::Runner.unregister
72
+ exit retval
70
73
 
71
74
  when :unregister
72
75
  cli.get_args(num: 0)
73
- config = Huebot::Config.new
74
- config.clear
76
+ retval = Huebot::CLI::Runner.unregister
77
+ exit retval
75
78
 
76
79
  else cli.help!
77
80
  end
data/lib/huebot/bot.rb CHANGED
@@ -1,64 +1,107 @@
1
1
  module Huebot
2
2
  class Bot
3
- attr_reader :client
4
-
5
3
  Error = Class.new(StandardError)
6
4
 
7
- def initialize(client)
8
- @client = client
5
+ def initialize(device_mapper)
6
+ @device_mapper = device_mapper
7
+ #@client = device_mapper.bridge.client
9
8
  end
10
9
 
11
10
  def execute(program)
12
- transition program.initial_state if program.initial_state
13
-
14
- if program.transitions.any?
15
- if program.loop?
16
- loop { iterate program.transitions }
17
- elsif program.loops > 0
18
- program.loops.times { iterate program.transitions }
19
- else
20
- iterate program.transitions
21
- end
22
- end
23
-
24
- transition program.final_state if program.final_state
11
+ exec program.data
25
12
  end
26
13
 
27
14
  private
28
15
 
29
- def iterate(transitions)
30
- transitions.each do |t|
31
- if t.respond_to?(:children)
32
- parallel_transitions t
33
- else
34
- transition t
35
- end
16
+ def exec(node)
17
+ i = node.instruction
18
+ case i
19
+ when Program::AST::Transition
20
+ transition i.state, i.devices, i.sleep
21
+ when Program::AST::SerialControl
22
+ serial node.children, i.loop, i.sleep
23
+ when Program::AST::ParallelControl
24
+ parallel node.children, i.loop, i.sleep
25
+ else
26
+ raise Error, "Unexpected instruction '#{i.class.name}'"
36
27
  end
37
28
  end
38
29
 
39
- def parallel_transitions(t)
40
- t.children.map { |sub_t|
30
+ def transition(state, device_refs, sleep_time = nil)
31
+ time = (state["transitiontime"] || 4).to_f / 10
32
+ devices = map_devices device_refs
33
+ devices.map { |device|
41
34
  Thread.new {
42
- transition sub_t
35
+ # TODO error handling
36
+ device.set_state state
37
+ wait time
43
38
  }
44
39
  }.map(&:join)
45
- wait t.wait if t.wait and t.wait > 0
40
+ wait sleep_time if sleep_time
46
41
  end
47
42
 
48
- def transition(t)
49
- time = t.state[:transitiontime] || 4
50
- t.devices.map { |device|
51
- Thread.new {
52
- device.set_state t.state
53
- wait time
54
- wait t.wait if t.wait
43
+ def serial(nodes, lp, sleep_time = nil)
44
+ control_loop(lp) {
45
+ nodes.map { |node|
46
+ exec node
55
47
  }
56
- }.map(&:join)
48
+ }
49
+ wait sleep_time if sleep_time
50
+ end
51
+
52
+ def parallel(nodes, lp, sleep_time = nil)
53
+ control_loop(lp) {
54
+ nodes.map { |node|
55
+ Thread.new {
56
+ # TODO error handling
57
+ exec node
58
+ }
59
+ }.map(&:join)
60
+ }
61
+ wait sleep_time if sleep_time
62
+ end
63
+
64
+ def control_loop(lp)
65
+ case lp
66
+ when Program::AST::InfiniteLoop
67
+ loop { yield }
68
+ when Program::AST::CountedLoop
69
+ lp.n.times { yield }
70
+ when Program::AST::DeadlineLoop
71
+ until Time.now >= lp.stop_time
72
+ yield
73
+ end
74
+ when Program::AST::TimerLoop
75
+ sec = ((lp.hours * 60) + lp.minutes) * 60
76
+ time = 0
77
+ until time >= sec
78
+ start = Time.now
79
+ yield
80
+ time += (Time.now - start).round
81
+ end
82
+ else
83
+ raise Error, "Unexpected loop type '#{lp.class.name}'"
84
+ end
85
+ end
86
+
87
+ def map_devices(refs)
88
+ refs.reduce([]) { |acc, ref|
89
+ devices =
90
+ case ref
91
+ when Program::AST::Light
92
+ [@device_mapper.light!(ref.name)]
93
+ when Program::AST::Group
94
+ [@device_mapper.group!(ref.name)]
95
+ when Program::AST::DeviceRef
96
+ Array(@device_mapper.var! ref.ref)
97
+ else
98
+ raise Error, "Unknown device reference '#{ref.class.name}'"
99
+ end
100
+ acc + devices
101
+ }
57
102
  end
58
103
 
59
- def wait(time)
60
- ms = time * 100
61
- seconds = ms / 1000.to_f
104
+ def wait(seconds)
62
105
  # TODO sleep in small bursts in a loop so can detect if an Interrupt was caught
63
106
  sleep seconds
64
107
  end
@@ -0,0 +1,170 @@
1
+ require 'optparse'
2
+ require 'yaml'
3
+
4
+ module Huebot
5
+ module CLI
6
+ module Helpers
7
+ #
8
+ # Returns the command given to huebot.
9
+ #
10
+ # @return [Symbol]
11
+ #
12
+ def self.get_cmd
13
+ ARGV[0].to_s.to_sym
14
+ end
15
+
16
+ def self.get_args(min: nil, max: nil, num: nil)
17
+ args = ARGV[1..]
18
+ if num
19
+ if num != args.size
20
+ $stderr.puts "Expected #{num} args, found #{args.size}"
21
+ exit 1
22
+ end
23
+ elsif min and max
24
+ if args.size < min or args.size > max
25
+ $stderr.puts "Expected #{min}-#{max} args, found #{args.size}"
26
+ end
27
+ elsif min
28
+ if args.size < min
29
+ $stderr.puts "Expected at least #{num} args, found #{args.size}"
30
+ exit 1
31
+ end
32
+ elsif max
33
+ if args.size > max
34
+ $stderr.puts "Expected no more than #{num} args, found #{args.size}"
35
+ exit 1
36
+ end
37
+ end
38
+ args
39
+ end
40
+
41
+ #
42
+ # Parses and returns input from the CLI. Serious errors might result in the program exiting.
43
+ #
44
+ # @return [Huebot::CLI::Options] All given CLI options
45
+ # @return [Array<Huebot::Program::Src>] Array of given program sources
46
+ #
47
+ def self.get_input!
48
+ options, parser = option_parser
49
+ parser.parse!
50
+
51
+ files = ARGV[1..-1]
52
+ if (bad_paths = files.select { |p| !File.exist? p }).any?
53
+ $stderr.puts "Cannot find #{bad_paths.join ', '}"
54
+ exit 1
55
+ end
56
+
57
+ sources = files.map { |path|
58
+ src = YAML.load_file(path)
59
+ version = (src.delete("version") || 1.0).to_f
60
+ Program::Src.new(src, path, version)
61
+ }
62
+
63
+ if options.read_stdin
64
+ src = YAML.load($stdin.read)
65
+ version = (src.delete("version") || 1.0).to_f
66
+ sources << Program::Src.new(src, "STDIN", version)
67
+ end
68
+ return options, sources
69
+ end
70
+
71
+ #
72
+ # Prints any program errors or warnings, and returns a boolean for each.
73
+ #
74
+ # @param programs [Array<Huebot::Program>]
75
+ # @param device_mapper [Huebot::DeviceMapper]
76
+ # @param io [IO] Usually $stdout or $stderr
77
+ # @param quiet [Boolean] if true, don't print anything
78
+ #
79
+ def self.check!(programs, device_mapper, io, quiet: false)
80
+ if (invalid_progs = programs.select { |prog| prog.errors.any? }).any?
81
+ errors = invalid_progs.reduce([]) { |acc, prog|
82
+ acc + prog.errors.map { |e| " #{prog.name}: #{e}" }
83
+ }
84
+ print_messages! io, "Errors", errors unless quiet
85
+ end
86
+
87
+ if (imperfect_progs = programs.select { |prog| prog.warnings.any? }).any?
88
+ warnings = imperfect_progs.reduce([]) { |acc, prog|
89
+ acc + prog.warnings.map { |e| " #{prog.name}: #{e}" }
90
+ }
91
+ print_messages! io, "Warnings", warnings unless quiet
92
+ end
93
+
94
+ all_lights = programs.reduce([]) { |acc, p| acc + p.light_names }
95
+ if (missing_lights = device_mapper.missing_lights all_lights).any?
96
+ print_messages! io, "Unknown lights", missing_lights
97
+ end
98
+
99
+ all_groups = programs.reduce([]) { |acc, p| acc + p.group_names }
100
+ if (missing_groups = device_mapper.missing_groups all_groups).any?
101
+ print_messages! io, "Unknown groups", missing_groups
102
+ end
103
+
104
+ all_vars = programs.reduce([]) { |acc, p| acc + p.device_refs }
105
+ if (missing_vars = device_mapper.missing_vars all_vars).any?
106
+ print_messages! io, "Unknown device inputs", missing_vars.map { |d| "$#{d}" }
107
+ end
108
+
109
+ invalid_devices = missing_lights.size + missing_groups.size + missing_vars.size
110
+ return invalid_progs.any?, imperfect_progs.any?, invalid_devices > 0
111
+ end
112
+
113
+ # Print help and exit
114
+ def self.help!
115
+ _, parser = option_parser
116
+ puts parser.help
117
+ exit 1
118
+ end
119
+
120
+ private
121
+
122
+ #
123
+ # Print each message (of the given type) for each program.
124
+ #
125
+ # @param io [IO] Usually $stdout or $stderr
126
+ # @param label [String] Top-level for this group of messages
127
+ # @param errors [Array<String>]
128
+ #
129
+ def self.print_messages!(io, label, errors)
130
+ io.puts "#{label}:"
131
+ errors.each_with_index { |msg, i|
132
+ io.puts " #{i+1}) #{msg}"
133
+ }
134
+ end
135
+
136
+ def self.option_parser
137
+ options = Options.new([], false)
138
+ parser = OptionParser.new { |opts|
139
+ opts.banner = %(
140
+ List all lights and groups:
141
+ huebot ls
142
+
143
+ Run program(s):
144
+ huebot run file1.yml [file2.yml [file3.yml ...]] [options]
145
+
146
+ Validate programs and inputs:
147
+ huebot check file1.yml [file2.yml [file3.yml ...]] [options]
148
+
149
+ Print the current state of the given lights and/or groups:
150
+ huebot get-state [options]
151
+
152
+ Manually set/clear the IP for your Hue Bridge (useful when on a VPN):
153
+ huebot set-ip 192.168.1.20
154
+ huebot clear-ip
155
+
156
+ Clear all connection config:
157
+ huebot unregister
158
+
159
+ Options:
160
+ ).strip
161
+ opts.on("-lLIGHT", "--light=LIGHT", "Light ID or name") { |l| options.inputs << Light::Input.new(l) }
162
+ opts.on("-gGROUP", "--group=GROUP", "Group ID or name") { |g| options.inputs << Group::Input.new(g) }
163
+ opts.on("-i", "Read program from STDIN") { options.read_stdin = true }
164
+ opts.on("-h", "--help", "Prints this help") { puts opts; exit }
165
+ }
166
+ return options, parser
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,78 @@
1
+ module Huebot
2
+ module CLI
3
+ module Runner
4
+ def self.ls(bridge)
5
+ puts "Lights\n" + bridge.lights.map { |l| " #{l.id}: #{l.name}" }.join("\n") + \
6
+ "\nGroups\n" + bridge.groups.map { |g| " #{g.id}: #{g.name}" }.join("\n")
7
+ return 0
8
+ rescue ::Huebot::Error => e
9
+ $stderr.puts "#{e.class.name}: #{e.message}"
10
+ return 1
11
+ end
12
+
13
+ def self.run(bridge, sources, opts)
14
+ device_mapper = Huebot::DeviceMapper.new(bridge, opts.inputs)
15
+ programs = sources.map { |src|
16
+ Huebot::Compiler.build src
17
+ }
18
+ found_errors, _found_warnings, missing_devices = Helpers.check! programs, device_mapper, $stderr
19
+ return 1 if found_errors || missing_devices
20
+
21
+ bot = Huebot::Bot.new(device_mapper)
22
+ programs.each { |prog| bot.execute prog }
23
+ return 0
24
+ rescue ::Huebot::Error => e
25
+ $stderr.puts "#{e.class.name}: #{e.message}"
26
+ return 1
27
+ end
28
+
29
+ def self.check(bridge, sources, opts)
30
+ device_mapper = Huebot::DeviceMapper.new(bridge, opts.inputs)
31
+ programs = sources.map { |src|
32
+ Huebot::Compiler.build src
33
+ }
34
+ found_errors, found_warnings, missing_devices = Helpers.check! programs, device_mapper, $stdout
35
+ return (found_errors || found_warnings || missing_devices) ? 1 : 0
36
+ rescue ::Huebot::Error => e
37
+ $stderr.puts "#{e.class.name}: #{e.message}"
38
+ return 1
39
+ end
40
+
41
+ def self.get_state(bridge, inputs)
42
+ device_mapper = Huebot::DeviceMapper.new(bridge, inputs)
43
+ device_mapper.each do |device|
44
+ puts device.name
45
+ puts " #{device.get_state}"
46
+ end
47
+ 0
48
+ end
49
+
50
+ def self.set_ip
51
+ config = Huebot::Config.new
52
+ config["ip"] = ip
53
+ 0
54
+ rescue ::Huebot::Error => e
55
+ $stderr.puts "#{e.class.name}: #{e.message}"
56
+ return 1
57
+ end
58
+
59
+ def self.clear_ip
60
+ config = Huebot::Config.new
61
+ config["ip"] = nil
62
+ 0
63
+ rescue ::Huebot::Error => e
64
+ $stderr.puts "#{e.class.name}: #{e.message}"
65
+ return 1
66
+ end
67
+
68
+ def self.unregister
69
+ config = Huebot::Config.new
70
+ config.clear
71
+ 0
72
+ rescue ::Huebot::Error => e
73
+ $stderr.puts "#{e.class.name}: #{e.message}"
74
+ return 1
75
+ end
76
+ end
77
+ end
78
+ end