huebot 1.1.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 587a390d34c338db6aa2cd4d94944b051cf25f6c77b2db25f86ae996cb76b44a
4
- data.tar.gz: 1a0d4d34b73caa8123cf1336e61ebed393b104c9aad82b8245f65d3356bab890
3
+ metadata.gz: 643b636258939c0eb021d13d9369d845be85a5854ca1f6c38fa66c6556551c68
4
+ data.tar.gz: e5e1e74fded7ed9d456f8a7ce0c65f8f787a08187eb019413bddaff7bddaca74
5
5
  SHA512:
6
- metadata.gz: 40d24c93bd23b68ccc914d0bd7834deee41d90faa96be78ce68d10f53ebcf55978b47f06beb569d5ef48d3d7cfb10501672df99a1312931e052a67fea9fb235b
7
- data.tar.gz: 3c0e33394c515e02212192229df35bde7d79a36bd20590edb40d89f5f751bccaa3432921413c7ff0762638d3804a1e06aaca3a13c835d006d8aaa3f78c62dd6e
6
+ metadata.gz: '084232072462be01ec7867aadb43df7b476e4c0b6fe0fcc33b1d10cd23f568b8474d83ed450f0487123ce874c33a729d84bfa9e4cc683a59958e3b5fe7bd64e0'
7
+ data.tar.gz: 99c1ba63f4ff21161017fe42180ae76c5ee27721e32e7663eb5d09906953ef88886dd7e003f40e783e0ede9e3e5e123fab5635b5e0413085bf5882ebcb386bb8
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Huebot
2
2
 
3
- Program your Hue lights!
3
+ Program your Hue lights using YAML or JSON!
4
4
 
5
- $ huebot run dimmer.yml --light="Office Desk"
5
+ $ huebot run dimmer.yaml --light="Office Desk"
6
6
 
7
7
  A few examples are below. [See the Wiki](https://github.com/jhollinger/huebot/wiki) for full documentation.
8
8
 
@@ -34,13 +34,15 @@ serial:
34
34
  state:
35
35
  bri: 150
36
36
  time: 10 # 10 second transition
37
- pause: 2 # 2 second pause before the next step
37
+ pause:
38
+ after: 2 # 2 second pause before the next step
38
39
 
39
40
  - transition:
40
41
  state:
41
42
  bri: 254
42
43
  time: 10 # 10 second transition
43
- pause: 2 # 2 second pause before the next step
44
+ pause:
45
+ after: 2 # 2 second pause before the next step
44
46
 
45
47
  - transition:
46
48
  state:
@@ -64,7 +66,8 @@ serial:
64
66
 
65
67
  # Run these steps in parallel in an infinite loop
66
68
  - parallel:
67
- loop: true
69
+ loop:
70
+ infinite: true
68
71
  steps:
69
72
  # Parallel branch 1: Fade inputs #1 and #3 up and down
70
73
  - serial:
@@ -77,12 +80,14 @@ serial:
77
80
  state:
78
81
  bri: 254
79
82
  time: 10 # transition over 10 seconds
80
- pause: 5 # pause an extra 5 sec after the transition
83
+ pause:
84
+ after: 5 # pause an extra 5 sec after the transition
81
85
  - transition:
82
86
  state:
83
87
  bri: 25
84
88
  time: 10
85
- pause: 5
89
+ pause:
90
+ after: 5
86
91
 
87
92
  # Parallel branch 2: Fade inputs #2 and #4 down and up
88
93
  - serial:
@@ -95,12 +100,14 @@ serial:
95
100
  state:
96
101
  bri: 25
97
102
  time: 10
98
- pause: 5
103
+ pause:
104
+ after: 5
99
105
  - transition:
100
106
  state:
101
107
  bri: 254
102
108
  time: 10
103
- pause: 5
109
+ pause:
110
+ after: 5
104
111
  ```
105
112
 
106
113
  [See the Wiki](https://github.com/jhollinger/huebot/wiki) for more documentation and examples.
data/bin/huebot CHANGED
@@ -1,23 +1,27 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  # Used for local testing
4
- $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') if ENV["HUEBOT_DEV"] == "1"
4
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') if ENV["DEV"] == "1"
5
5
 
6
6
  require 'huebot'
7
7
 
8
8
  Huebot::CLI::Helpers.tap { |cli|
9
9
  case cli.get_cmd
10
10
  when :ls
11
+ cli.get_args!(num: 0)
12
+ opts = cli.get_opts!
11
13
  bridge, error = Huebot::Bridge.connect
12
14
  if error
13
15
  $stderr.puts error
14
16
  exit 1
15
17
  end
16
- retval = Huebot::CLI::Runner.ls bridge
18
+
19
+ retval = Huebot::CLI::Runner.ls(bridge.lights, bridge.groups, opts)
17
20
  exit retval
18
21
 
19
22
  when :run
20
- opts, sources = cli.get_input!
23
+ opts = cli.get_opts!
24
+ sources = cli.get_input! opts
21
25
  if sources.empty?
22
26
  cli.help!
23
27
  exit 1
@@ -28,26 +32,35 @@ Huebot::CLI::Helpers.tap { |cli|
28
32
  $stderr.puts error
29
33
  exit 1
30
34
  end
31
- retval = Huebot::CLI::Runner.run(bridge, sources, opts)
35
+
36
+ retval = Huebot::CLI::Runner.run(sources, bridge.lights, bridge.groups, opts)
32
37
  exit retval
33
38
 
34
39
  when :check
35
- opts, sources = cli.get_input!
40
+ opts = cli.get_opts!
41
+ sources = cli.get_input! opts
36
42
  if sources.empty?
37
43
  cli.help!
38
44
  exit 1
39
45
  end
40
46
 
41
- bridge, error = Huebot::Bridge.connect
42
- if error
43
- $stderr.puts error
44
- exit 1
45
- end
46
- retval = Huebot::CLI::Runner.check(bridge, sources, opts)
47
+ lights, groups =
48
+ if opts.no_device_check
49
+ [[], []]
50
+ else
51
+ bridge, error = Huebot::Bridge.connect
52
+ if error
53
+ $stderr.puts error
54
+ exit 1
55
+ end
56
+ [bridge.lights, bridge.groups]
57
+ end
58
+
59
+ retval = Huebot::CLI::Runner.check(sources, lights, groups, opts)
47
60
  exit retval
48
61
 
49
62
  when :"get-state"
50
- opts, _sources = cli.get_input!
63
+ opts = cli.get_opts!
51
64
  if opts.inputs.empty?
52
65
  cli.help!
53
66
  exit 1
@@ -58,22 +71,29 @@ Huebot::CLI::Helpers.tap { |cli|
58
71
  $stderr.puts error
59
72
  exit 1
60
73
  end
61
- retval = Huebot::CLI::Runner.get_state(bridge, opts.inputs)
74
+
75
+ retval = Huebot::CLI::Runner.get_state(bridge.lights, bridge.groups, opts)
62
76
  exit retval
63
77
 
64
78
  when :"set-ip"
65
- ip = cli.get_args(num: 1).first
66
- retval = Huebot::CLI::Runner.set_ip ip
79
+ opts = cli.get_opts!
80
+ ip = cli.get_args!(num: 1).first
81
+ config = Huebot::CLI::Config.new
82
+ retval = Huebot::CLI::Runner.set_ip config, ip, opts
67
83
  exit retval
68
84
 
69
85
  when :"clear-ip"
70
- cli.get_args(num: 0)
71
- retval = Huebot::CLI::Runner.unregister
86
+ opts = cli.get_opts!
87
+ cli.get_args!(num: 0)
88
+ config = Huebot::CLI::Config.new
89
+ retval = Huebot::CLI::Runner.clear_ip config, opts
72
90
  exit retval
73
91
 
74
92
  when :unregister
75
- cli.get_args(num: 0)
76
- retval = Huebot::CLI::Runner.unregister
93
+ opts = cli.get_opts!
94
+ cli.get_args!(num: 0)
95
+ config = Huebot::CLI::Config.new
96
+ retval = Huebot::CLI::Runner.unregister config, opts
77
97
  exit retval
78
98
 
79
99
  else cli.help!
data/lib/huebot/bot.rb CHANGED
@@ -1,56 +1,67 @@
1
1
  module Huebot
2
+ # The Huebot runtime
2
3
  class Bot
3
4
  Error = Class.new(StandardError)
4
5
 
5
- def initialize(device_mapper)
6
+ def initialize(device_mapper, waiter: nil, logger: nil)
6
7
  @device_mapper = device_mapper
7
- #@client = device_mapper.bridge.client
8
+ @logger = logger || Logging::NullLogger.new
9
+ @waiter = waiter || Waiter
8
10
  end
9
11
 
10
12
  def execute(program)
13
+ @logger.log :start, {program: program.name}
11
14
  exec program.data
15
+ @logger.log :stop, {program: program.name}
12
16
  end
13
17
 
14
18
  private
15
19
 
16
20
  def exec(node)
17
- i = node.instruction
18
- case i
21
+ case node.instruction
19
22
  when Program::AST::Transition
20
- transition i.state, i.devices, i.sleep
23
+ transition node.instruction
21
24
  when Program::AST::SerialControl
22
- serial node.children, i.loop, i.sleep
25
+ serial node.children, node.instruction
23
26
  when Program::AST::ParallelControl
24
- parallel node.children, i.loop, i.sleep
27
+ parallel node.children, node.instruction
25
28
  else
26
- raise Error, "Unexpected instruction '#{i.class.name}'"
29
+ raise Error, "Unexpected instruction '#{node.instruction.class.name}'"
27
30
  end
28
31
  end
29
32
 
30
- def transition(state, device_refs, sleep_time = nil)
31
- time = (state["transitiontime"] || 4).to_f / 10
32
- devices = map_devices device_refs
33
+ def transition(i)
34
+ time = (i.state["transitiontime"] || 4).to_f / 10
35
+ devices = map_devices i.devices
36
+ @logger.log :transition, {devices: devices.map(&:name)}
37
+
38
+ wait i.pause.pre if i.pause&.pre
33
39
  devices.map { |device|
34
40
  Thread.new {
35
41
  # TODO error handling
36
- device.set_state state
37
- wait time
42
+ _res = device.set_state i.state
43
+ @logger.log :set_state, {device: device.name, state: i.state, result: nil}
44
+ wait time if i.wait
38
45
  }
39
46
  }.map(&:join)
40
- wait sleep_time if sleep_time
47
+ wait i.pause.post if i.pause&.post
41
48
  end
42
49
 
43
- def serial(nodes, lp, sleep_time = nil)
44
- control_loop(lp) {
45
- nodes.map { |node|
50
+ def serial(nodes, i)
51
+ wait i.pause.pre if i.pause&.pre
52
+ control_loop(i.loop) { |loop_type|
53
+ @logger.log :serial, {loop: loop_type}
54
+ nodes.each { |node|
46
55
  exec node
47
56
  }
48
57
  }
49
- wait sleep_time if sleep_time
58
+ wait i.pause.post if i.pause&.post
50
59
  end
51
60
 
52
- def parallel(nodes, lp, sleep_time = nil)
53
- control_loop(lp) {
61
+ def parallel(nodes, i)
62
+ wait i.pause.pre if i.pause&.pre
63
+ control_loop(i.loop) { |loop_type|
64
+ @logger.log :parallel, {loop: loop_type}
54
65
  nodes.map { |node|
55
66
  Thread.new {
56
67
  # TODO error handling
@@ -58,25 +69,37 @@ module Huebot
58
69
  }
59
70
  }.map(&:join)
60
71
  }
61
- wait sleep_time if sleep_time
72
+ wait i.pause.post if i.pause&.post
62
73
  end
63
74
 
64
75
  def control_loop(lp)
65
76
  case lp
66
77
  when Program::AST::InfiniteLoop
67
- loop { yield }
78
+ loop {
79
+ wait lp.pause.pre if lp.pause&.pre
80
+ yield :infinite
81
+ wait lp.pause.post if lp.pause&.post
82
+ }
68
83
  when Program::AST::CountedLoop
69
- lp.n.times { yield }
84
+ lp.n.times {
85
+ wait lp.pause.pre if lp.pause&.pre
86
+ yield :counted
87
+ wait lp.pause.post if lp.pause&.post
88
+ }
70
89
  when Program::AST::DeadlineLoop
71
90
  until Time.now >= lp.stop_time
72
- yield
91
+ wait lp.pause.pre if lp.pause&.pre
92
+ yield :deadline
93
+ wait lp.pause.post if lp.pause&.post
73
94
  end
74
95
  when Program::AST::TimerLoop
75
96
  sec = ((lp.hours * 60) + lp.minutes) * 60
76
97
  time = 0
77
98
  until time >= sec
78
99
  start = Time.now
79
- yield
100
+ wait lp.pause.pre if lp.pause&.pre
101
+ yield :timer
102
+ wait lp.pause.post if lp.pause&.post
80
103
  time += (Time.now - start).round
81
104
  end
82
105
  else
@@ -102,8 +125,14 @@ module Huebot
102
125
  end
103
126
 
104
127
  def wait(seconds)
105
- # TODO sleep in small bursts in a loop so can detect if an Interrupt was caught
106
- sleep seconds
128
+ @logger.log :pause, {time: seconds}
129
+ @waiter.call seconds
130
+ end
131
+
132
+ module Waiter
133
+ def self.call(seconds)
134
+ sleep seconds
135
+ end
107
136
  end
108
137
  end
109
138
  end
data/lib/huebot/bridge.rb CHANGED
@@ -1,24 +1,21 @@
1
1
  module Huebot
2
2
  class Bridge
3
- def self.connect(config = Huebot::Config.new)
4
- client = Client.new(config)
3
+ def self.connect(client = Client.new)
5
4
  error = client.connect
6
5
  return nil, error if error
7
6
  return new(client)
8
7
  end
9
8
 
10
- attr_reader :client
11
-
12
9
  def initialize(client)
13
10
  @client = client
14
11
  end
15
12
 
16
13
  def lights
17
- client.get!("/lights").map { |(id, attrs)| Light.new(client, id, attrs) }
14
+ @client.get!("/lights").map { |(id, attrs)| Light.new(@client, id, attrs) }
18
15
  end
19
16
 
20
17
  def groups
21
- client.get!("/groups").map { |(id, attrs)| Group.new(client, id, attrs) }
18
+ @client.get!("/groups").map { |(id, attrs)| Group.new(@client, id, attrs) }
22
19
  end
23
20
  end
24
21
  end
@@ -8,8 +8,9 @@ module Huebot
8
8
  #
9
9
  # @attr inputs [Array<String>]
10
10
  #
11
- Options = Struct.new(:inputs, :read_stdin)
11
+ Options = Struct.new(:inputs, :read_stdin, :debug, :no_device_check, :stdin, :stdout, :stderr, :bot_waiter)
12
12
 
13
+ autoload :Config, 'huebot/cli/config'
13
14
  autoload :Helpers, 'huebot/cli/helpers'
14
15
  autoload :Runner, 'huebot/cli/runner'
15
16
  end
@@ -0,0 +1,47 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ module Huebot
5
+ module CLI
6
+ class Config
7
+ def initialize(path = "~/.config/huebot")
8
+ @path = File.expand_path(path)
9
+ @dir = File.dirname(@path)
10
+ @dir_exists = File.exist? @dir
11
+ reload
12
+ end
13
+
14
+ def [](attr)
15
+ @config[attr.to_s]
16
+ end
17
+
18
+ def []=(attr, val)
19
+ if val.nil?
20
+ @config.delete(attr.to_s)
21
+ else
22
+ @config[attr.to_s] = val
23
+ end
24
+ write
25
+ end
26
+
27
+ def clear
28
+ @config.clear
29
+ write
30
+ end
31
+
32
+ def reload
33
+ @config = File.exist?(@path) ? YAML.load_file(@path) : {}
34
+ end
35
+
36
+ private
37
+
38
+ def write
39
+ unless @dir_exists
40
+ FileUtils.mkdir_p @dir
41
+ @dir_exists = true
42
+ end
43
+ File.write(@path, YAML.dump(@config))
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,73 +1,91 @@
1
1
  require 'optparse'
2
2
  require 'yaml'
3
+ require 'json'
3
4
 
4
5
  module Huebot
5
6
  module CLI
6
7
  module Helpers
8
+ DEFAULT_API_VERSION = 1.1
9
+
7
10
  #
8
11
  # Returns the command given to huebot.
9
12
  #
10
13
  # @return [Symbol]
11
14
  #
12
- def self.get_cmd
13
- ARGV[0].to_s.to_sym
15
+ def self.get_cmd(argv = ARGV)
16
+ argv[0].to_s.to_sym
14
17
  end
15
18
 
16
- def self.get_args(min: nil, max: nil, num: nil)
17
- args = ARGV[1..]
19
+ def self.get_args!(argv = ARGV, min: nil, max: nil, num: nil)
20
+ args, error = get_args(argv, min: min, max: max, num: num)
21
+ if error
22
+ $stderr.puts error
23
+ exit 1
24
+ end
25
+ args
26
+ end
27
+
28
+ def self.get_args(argv = ARGV, min: nil, max: nil, num: nil)
29
+ args = argv[1..]
18
30
  if num
19
31
  if num != args.size
20
- $stderr.puts "Expected #{num} args, found #{args.size}"
21
- exit 1
32
+ return nil, "Expected #{num} args, found #{args.size}"
22
33
  end
23
34
  elsif min and max
24
35
  if args.size < min or args.size > max
25
- $stderr.puts "Expected #{min}-#{max} args, found #{args.size}"
36
+ return nil, "Expected #{min}-#{max} args, found #{args.size}"
26
37
  end
27
38
  elsif min
28
39
  if args.size < min
29
- $stderr.puts "Expected at least #{num} args, found #{args.size}"
30
- exit 1
40
+ return nil, "Expected at least #{min} args, found #{args.size}"
31
41
  end
32
42
  elsif max
33
43
  if args.size > max
34
- $stderr.puts "Expected no more than #{num} args, found #{args.size}"
35
- exit 1
44
+ return nil, "Expected no more than #{max} args, found #{args.size}"
36
45
  end
37
46
  end
38
- args
47
+ return args, nil
39
48
  end
40
49
 
41
50
  #
42
51
  # Parses and returns input from the CLI. Serious errors might result in the program exiting.
43
52
  #
44
- # @return [Huebot::CLI::Options] All given CLI options
53
+ # @param opts [Huebot::CLI::Options] All given CLI options
45
54
  # @return [Array<Huebot::Program::Src>] Array of given program sources
46
55
  #
47
- def self.get_input!
48
- options, parser = option_parser
49
- parser.parse!
50
-
51
- files = ARGV[1..-1]
56
+ def self.get_input!(opts, argv = ARGV)
57
+ files = argv[1..-1]
52
58
  if (bad_paths = files.select { |p| !File.exist? p }).any?
53
- $stderr.puts "Cannot find #{bad_paths.join ', '}"
54
- exit 1
59
+ opts.stderr.puts "Cannot find #{bad_paths.join ', '}"
60
+ return []
55
61
  end
56
62
 
57
63
  sources = files.map { |path|
58
- src = YAML.load_file(path)
59
- version = (src.delete("version") || 1.0).to_f
64
+ ext = File.extname path
65
+ src =
66
+ case ext
67
+ when ".yaml", ".yml"
68
+ YAML.safe_load(File.read path) || {}
69
+ when ".json"
70
+ JSON.load(File.read path) || {}
71
+ else
72
+ opts.stderr.puts "Unknown file extension '#{ext}'. Expected .yaml, .yml, or .json"
73
+ return []
74
+ end
75
+ version = (src.delete("version") || DEFAULT_API_VERSION).to_f
60
76
  Program::Src.new(src, path, version)
61
77
  }
62
78
 
63
- if !$stdin.isatty or options.read_stdin
64
- puts "Please enter your YAML Huebot program below, followed by Ctrl+d:" if options.read_stdin
65
- src = YAML.load($stdin.read)
66
- puts "Executing..." if options.read_stdin
67
- version = (src.delete("version") || 1.0).to_f
79
+ if !opts.stdin.isatty or opts.read_stdin
80
+ opts.stdout.puts "Please enter your YAML or JSON Huebot program below, followed by Ctrl+d:" if opts.read_stdin
81
+ raw = opts.stdin.read.lstrip
82
+ src = raw[0] == "{" ? JSON.load(raw) : YAML.safe_load(raw)
83
+
84
+ opts.stdout.puts "Executing..." if opts.read_stdin
85
+ version = (src.delete("version") || DEFAULT_API_VERSION).to_f
68
86
  sources << Program::Src.new(src, "STDIN", version)
69
87
  end
70
- return options, sources
88
+ sources
71
89
  end
72
90
 
73
91
  #
@@ -95,27 +113,35 @@ module Huebot
95
113
 
96
114
  all_lights = programs.reduce([]) { |acc, p| acc + p.light_names }
97
115
  if (missing_lights = device_mapper.missing_lights all_lights).any?
98
- print_messages! io, "Unknown lights", missing_lights
116
+ print_messages! io, "Unknown lights", missing_lights unless quiet
99
117
  end
100
118
 
101
119
  all_groups = programs.reduce([]) { |acc, p| acc + p.group_names }
102
120
  if (missing_groups = device_mapper.missing_groups all_groups).any?
103
- print_messages! io, "Unknown groups", missing_groups
121
+ print_messages! io, "Unknown groups", missing_groups unless quiet
104
122
  end
105
123
 
106
124
  all_vars = programs.reduce([]) { |acc, p| acc + p.device_refs }
107
125
  if (missing_vars = device_mapper.missing_vars all_vars).any?
108
- print_messages! io, "Unknown device inputs", missing_vars.map { |d| "$#{d}" }
126
+ print_messages! io, "Unknown device inputs", missing_vars.map { |d| "$#{d}" } unless quiet
109
127
  end
110
128
 
111
129
  invalid_devices = missing_lights.size + missing_groups.size + missing_vars.size
112
130
  return invalid_progs.any?, imperfect_progs.any?, invalid_devices > 0
113
131
  end
114
132
 
133
+ def self.get_opts!
134
+ opts = default_options
135
+ parser = option_parser opts
136
+ parser.parse!
137
+ opts
138
+ end
139
+
115
140
  # Print help and exit
116
141
  def self.help!
117
- _, parser = option_parser
118
- puts parser.help
142
+ opts = default_options
143
+ parser = option_parser opts
144
+ opts.stdout.puts parser.help
119
145
  exit 1
120
146
  end
121
147
 
@@ -135,15 +161,14 @@ module Huebot
135
161
  }
136
162
  end
137
163
 
138
- def self.option_parser
139
- options = Options.new([], false)
140
- parser = OptionParser.new { |opts|
164
+ def self.option_parser(options)
165
+ OptionParser.new { |opts|
141
166
  opts.banner = %(
142
167
  List all lights and groups:
143
168
  huebot ls
144
169
 
145
170
  Run program(s):
146
- huebot run prog1.yaml [prog2.yaml [prog3.yaml ...]] [options]
171
+ huebot run prog1.yaml [prog2.yml [prog3.json ...]] [options]
147
172
 
148
173
  Run program from STDIN:
149
174
  cat prog1.yaml | huebot run [options]
@@ -168,9 +193,18 @@ module Huebot
168
193
  opts.on("-lLIGHT", "--light=LIGHT", "Light ID or name") { |l| options.inputs << Light::Input.new(l) }
169
194
  opts.on("-gGROUP", "--group=GROUP", "Group ID or name") { |g| options.inputs << Group::Input.new(g) }
170
195
  opts.on("-i", "Read program from STDIN") { options.read_stdin = true }
171
- opts.on("-h", "--help", "Prints this help") { puts opts; exit }
196
+ opts.on("--debug", "Print debug info during run") { options.debug = true }
197
+ opts.on("--no-device-check", "Don't validate devices against the Bridge ('check' cmd only)") { options.no_device_check = true }
198
+ opts.on("-h", "--help", "Prints this help") { options.stdout.puts opts; exit }
172
199
  }
173
- return options, parser
200
+ end
201
+
202
+ def self.default_options
203
+ options = Options.new([], false)
204
+ options.stdin = $stdin
205
+ options.stdout = $stdout
206
+ options.stderr = $stderr
207
+ options
174
208
  end
175
209
  end
176
210
  end
@@ -1,76 +1,84 @@
1
1
  module Huebot
2
2
  module CLI
3
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")
4
+ def self.ls(lights, groups, opts)
5
+ opts.stdout.puts "Lights\n" + lights.map { |l| " #{l.id}: #{l.name}" }.join("\n") + \
6
+ "\nGroups\n" + groups.map { |g| " #{g.id}: #{g.name}" }.join("\n")
7
7
  return 0
8
8
  rescue ::Huebot::Error => e
9
- $stderr.puts "#{e.class.name}: #{e.message}"
9
+ opts.stderr.puts "#{e.class.name}: #{e.message}"
10
10
  return 1
11
11
  end
12
12
 
13
- def self.run(bridge, sources, opts)
14
- device_mapper = Huebot::DeviceMapper.new(bridge, opts.inputs)
13
+ def self.run(sources, lights, groups, opts)
15
14
  programs = sources.map { |src|
16
15
  Huebot::Compiler.build src
17
16
  }
18
- found_errors, _found_warnings, missing_devices = Helpers.check! programs, device_mapper, $stderr
17
+ device_mapper = Huebot::DeviceMapper.new(lights: lights, groups: groups, inputs: opts.inputs)
18
+ found_errors, _found_warnings, missing_devices = Helpers.check! programs, device_mapper, opts.stderr
19
19
  return 1 if found_errors || missing_devices
20
20
 
21
- bot = Huebot::Bot.new(device_mapper)
21
+ logger = opts.debug ? Logging::IOLogger.new(opts.stdout) : nil
22
+ bot = Huebot::Bot.new(device_mapper, logger: logger, waiter: opts.bot_waiter)
22
23
  programs.each { |prog| bot.execute prog }
23
24
  return 0
24
25
  rescue ::Huebot::Error => e
25
- $stderr.puts "#{e.class.name}: #{e.message}"
26
+ opts.stderr.puts "#{e.class.name}: #{e.message}"
26
27
  return 1
27
28
  end
28
29
 
29
- def self.check(bridge, sources, opts)
30
- device_mapper = Huebot::DeviceMapper.new(bridge, opts.inputs)
30
+ def self.check(sources, lights, groups, opts)
31
31
  programs = sources.map { |src|
32
32
  Huebot::Compiler.build src
33
33
  }
34
- found_errors, found_warnings, missing_devices = Helpers.check! programs, device_mapper, $stdout
34
+
35
+ # Assume all devices and inputs are correct
36
+ if opts.no_device_check
37
+ light_input_names = opts.inputs.select { |i| i.is_a? Light::Input }.map(&:val)
38
+ lights = programs.reduce(light_input_names) { |acc, p| acc + p.light_names }.uniq.each_with_index.map { |name, i| Light.new(nil, i+1, {"name" => name}) }
39
+
40
+ group_input_names = opts.inputs.select { |i| i.is_a? Group::Input }.map(&:val)
41
+ groups = programs.reduce(group_input_names) { |acc, p| acc + p.group_names }.uniq.each_with_index.map { |name, i| Group.new(nil, i+1, {"name" => name}) }
42
+ end
43
+
44
+ device_mapper = Huebot::DeviceMapper.new(lights: lights, groups: groups, inputs: opts.inputs)
45
+ found_errors, found_warnings, missing_devices = Helpers.check! programs, device_mapper, opts.stderr
35
46
  return (found_errors || found_warnings || missing_devices) ? 1 : 0
36
47
  rescue ::Huebot::Error => e
37
- $stderr.puts "#{e.class.name}: #{e.message}"
48
+ opts.stderr.puts "#{e.class.name}: #{e.message}"
38
49
  return 1
39
50
  end
40
51
 
41
- def self.get_state(bridge, inputs)
42
- device_mapper = Huebot::DeviceMapper.new(bridge, inputs)
52
+ def self.get_state(lights, groups, opts)
53
+ device_mapper = Huebot::DeviceMapper.new(lights: lights, groups: groups, inputs: opts.inputs)
43
54
  device_mapper.each do |device|
44
- puts device.name
45
- puts " #{device.get_state}"
55
+ opts.stdout.puts device.name
56
+ opts.stdout.puts " #{device.get_state}"
46
57
  end
47
58
  0
48
59
  end
49
60
 
50
- def self.set_ip
51
- config = Huebot::Config.new
61
+ def self.set_ip(config, ip, opts)
52
62
  config["ip"] = ip
53
63
  0
54
64
  rescue ::Huebot::Error => e
55
- $stderr.puts "#{e.class.name}: #{e.message}"
65
+ opts.stderr.puts "#{e.class.name}: #{e.message}"
56
66
  return 1
57
67
  end
58
68
 
59
- def self.clear_ip
60
- config = Huebot::Config.new
69
+ def self.clear_ip(config, opts)
61
70
  config["ip"] = nil
62
71
  0
63
72
  rescue ::Huebot::Error => e
64
- $stderr.puts "#{e.class.name}: #{e.message}"
73
+ opts.stderr.puts "#{e.class.name}: #{e.message}"
65
74
  return 1
66
75
  end
67
76
 
68
- def self.unregister
69
- config = Huebot::Config.new
77
+ def self.unregister(config, opts)
70
78
  config.clear
71
79
  0
72
80
  rescue ::Huebot::Error => e
73
- $stderr.puts "#{e.class.name}: #{e.message}"
81
+ opts.stderr.puts "#{e.class.name}: #{e.message}"
74
82
  return 1
75
83
  end
76
84
  end
data/lib/huebot/client.rb CHANGED
@@ -10,7 +10,7 @@ module Huebot
10
10
 
11
11
  attr_reader :config
12
12
 
13
- def initialize(config = Huebot::Config.new)
13
+ def initialize(config = Huebot::CLI::Config.new)
14
14
  @config = config
15
15
  @ip = config["ip"] # NOTE will usually be null
16
16
  @username = nil
@@ -51,45 +51,61 @@ module Huebot
51
51
  end
52
52
 
53
53
  def build_transition(t, errors, warnings, inherited_devices = nil)
54
+ if t.nil?
55
+ errors << "'transition' may not be blank"
56
+ t = {}
57
+ end
58
+
54
59
  state = build_state(t, errors, warnings)
55
60
  devices = build_devices(t, errors, warnings, inherited_devices)
56
- slp = build_sleep(t, errors, warnings)
61
+ pause = build_pause(t, errors, warnings)
62
+ wait = @api_version >= 1.1 ? build_wait(t, errors, warnings) : true
57
63
 
58
64
  errors << "'transition' requires devices" if devices.empty?
59
65
  errors << "Unknown keys in 'transition': #{t.keys.join ", "}" if t.keys.any?
60
66
 
61
- instruction = Program::AST::Transition.new(state, devices, slp)
67
+ instruction = Program::AST::Transition.new(state, devices, wait, pause)
62
68
  return instruction, []
63
69
  end
64
70
 
65
71
  def build_serial(t, errors, warnings, inherited_devices = nil)
72
+ if t.nil?
73
+ errors << "'serial' may not be blank"
74
+ t = {}
75
+ end
76
+
66
77
  lp = build_loop(t, errors, warnings)
67
- slp = build_sleep(t, errors, warnings)
78
+ pause = build_pause(t, errors, warnings)
68
79
  devices = build_devices(t, errors, warnings, inherited_devices)
69
80
  children = build_steps(t, errors, warnings, devices)
70
81
 
71
82
  errors << "'serial' requires steps" if children.empty?
72
83
  errors << "Unknown keys in 'serial': #{t.keys.join ", "}" if t.keys.any?
73
84
 
74
- instruction = Program::AST::SerialControl.new(lp, slp)
85
+ instruction = Program::AST::SerialControl.new(lp, pause)
75
86
  return instruction, children
76
87
  end
77
88
 
78
89
  def build_parallel(t, errors, warnings, inherited_devices = nil)
90
+ if t.nil?
91
+ errors << "'parallel' may not be blank"
92
+ t = {}
93
+ end
94
+
79
95
  lp = build_loop(t, errors, warnings)
80
- slp = build_sleep(t, errors, warnings)
96
+ pause = build_pause(t, errors, warnings)
81
97
  devices = build_devices(t, errors, warnings, inherited_devices)
82
98
  children = build_steps(t, errors, warnings, devices)
83
99
 
84
100
  errors << "'parallel' requires steps" if children.empty?
85
101
  errors << "Unknown keys in 'parallel': #{t.keys.join ", "}" if t.keys.any?
86
102
 
87
- instruction = Program::AST::ParallelControl.new(lp, slp)
103
+ instruction = Program::AST::ParallelControl.new(lp, pause)
88
104
  return instruction, children
89
105
  end
90
106
 
91
107
  def map_state_keys(state, errors, warnings)
92
- # bugfix to YAML
108
+ # bugfix to YAML - it parses the "on" key as a Boolean
93
109
  case state.delete true
94
110
  when true
95
111
  state["on"] = true
@@ -167,9 +183,7 @@ module Huebot
167
183
  loop_val = t.delete "loop"
168
184
  case loop_val
169
185
  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
-
186
+ pause = build_pause(loop_val, errors, warnings)
173
187
  lp =
174
188
  case loop_val.keys
175
189
  when INFINITE_KEYS
@@ -216,11 +230,19 @@ module Huebot
216
230
  Program::AST::DeadlineLoop.new(stop_time)
217
231
  end
218
232
 
219
- def build_sleep(t, errors, warnings)
220
- sleep_val = t.delete "pause"
221
- case sleep_val
233
+ def build_pause(t, errors, warnings)
234
+ case @api_version
235
+ when 1.0 then build_pause_1_0(t, errors, warnings)
236
+ when 1.1 then build_pause_1_1(t, errors, warnings)
237
+ else raise Error, "Unknown api version '#{@api_version}'"
238
+ end
239
+ end
240
+
241
+ def build_pause_1_0(t, errors, warnings)
242
+ pause_val = t.delete "pause"
243
+ case pause_val
222
244
  when Integer, Float
223
- sleep_val
245
+ Program::AST::Pause.new(nil, pause_val)
224
246
  when nil
225
247
  nil
226
248
  else
@@ -229,6 +251,39 @@ module Huebot
229
251
  end
230
252
  end
231
253
 
254
+ def build_pause_1_1(t, errors, warnings)
255
+ pause_val = t.delete "pause"
256
+ case pause_val
257
+ when Integer, Float
258
+ Program::AST::Pause.new(nil, pause_val)
259
+ when Hash
260
+ pre = pause_val.delete "before"
261
+ post = pause_val.delete "after"
262
+ errors << "'pause.before' must be an integer or float" unless pre.nil? or pre.is_a? Integer or pre.is_a? Float
263
+ errors << "'pause.after' must be an integer or float" unless post.nil? or post.is_a? Integer or post.is_a? Float
264
+ errors << "Unknown keys in 'pause': #{pause_val.keys.join ", "}" if pause_val.keys.any?
265
+ Program::AST::Pause.new(pre, post)
266
+ when nil
267
+ nil
268
+ else
269
+ errors << "'pause' must be an integer or float"
270
+ nil
271
+ end
272
+ end
273
+
274
+ def build_wait(t, errors, warnings)
275
+ wait = t.delete "wait"
276
+ case wait
277
+ when true, false
278
+ wait
279
+ when nil
280
+ true
281
+ else
282
+ errors << "'transition.wait' must be true or false"
283
+ true
284
+ end
285
+ end
286
+
232
287
  def build_devices(t, errors, warnings, inherited_devices = nil)
233
288
  devices_ref = t.delete("devices") || {}
234
289
  return inherited_devices if devices_ref.empty? and inherited_devices
@@ -13,7 +13,7 @@ module Huebot
13
13
  def self.build(src)
14
14
  compiler_class =
15
15
  case src.api_version
16
- when 1.0 then ApiV1
16
+ when 1.0, 1.1 then ApiV1
17
17
  else raise Error, "Unknown API version '#{src.api_version}'"
18
18
  end
19
19
  compiler = compiler_class.new(src.api_version)
@@ -2,20 +2,18 @@ module Huebot
2
2
  class DeviceMapper
3
3
  Unmapped = Class.new(Error)
4
4
 
5
- def initialize(bridge, inputs = [])
6
- all_lights, all_groups = bridge.lights, bridge.groups
7
-
8
- @lights_by_id = all_lights.reduce({}) { |a, l| a[l.id] = l; a }
9
- @lights_by_name = all_lights.reduce({}) { |a, l| a[l.name] = l; a }
10
- @groups_by_id = all_groups.reduce({}) { |a, g| a[g.id] = g; a }
11
- @groups_by_name = all_groups.reduce({}) { |a, g| a[g.name] = g; a }
5
+ def initialize(lights: [], groups:[], inputs: [])
6
+ @lights_by_id = lights.each_with_object({}) { |l, a| a[l.id] = l }
7
+ @lights_by_name = lights.each_with_object({}) { |l, a| a[l.name] = l }
8
+ @groups_by_id = groups.each_with_object({}) { |g, a| a[g.id] = g }
9
+ @groups_by_name = groups.each_with_object({}) { |g, a| a[g.name] = g }
12
10
  @devices_by_var = inputs.each_with_index.each_with_object({}) { |(x, idx), obj|
13
11
  obj[idx + 1] =
14
12
  case x
15
13
  when Light::Input then @lights_by_id[x.val.to_i] || @lights_by_name[x.val]
16
14
  when Group::Input then @groups_by_id[x.val.to_i] || @groups_by_name[x.val]
17
15
  else raise Error, "Invalid input: #{x}"
18
- end || raise(Unmapped, "Could not find #{x.class.name[8..-6].downcase} with id or name '#{x.val}'")
16
+ end || raise(Unmapped, "Could not find #{x.class.name[8..-8].downcase} with id or name '#{x.val}'")
19
17
  }
20
18
  @all = @devices_by_var.values
21
19
  end
data/lib/huebot/group.rb CHANGED
@@ -14,7 +14,6 @@ module Huebot
14
14
  @client = client
15
15
  @id = id.to_i
16
16
  @name = attrs.fetch("name")
17
- @attrs = attrs
18
17
  end
19
18
 
20
19
  private
data/lib/huebot/light.rb CHANGED
@@ -14,7 +14,6 @@ module Huebot
14
14
  @client = client
15
15
  @id = id.to_i
16
16
  @name = attrs.fetch("name")
17
- @attrs = attrs
18
17
  end
19
18
 
20
19
  private
@@ -0,0 +1,20 @@
1
+ module Huebot
2
+ module Logging
3
+ class CollectingLogger
4
+ attr_reader :events
5
+
6
+ def initialize
7
+ @events = []
8
+ @mut = Mutex.new
9
+ end
10
+
11
+ def log(event_type, data = {})
12
+ now = Time.now
13
+ @mut.synchronize {
14
+ @events << [now, event_type, data]
15
+ }
16
+ self
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ require 'json'
2
+
3
+ module Huebot
4
+ module Logging
5
+ class IOLogger
6
+ def initialize(io)
7
+ @io = io
8
+ @mut = Mutex.new
9
+ end
10
+
11
+ def log(event_type, data = {})
12
+ ts = Time.now.iso8601
13
+ @mut.synchronize {
14
+ @io.puts "#{ts} #{event_type} #{data.to_json}"
15
+ }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ module Huebot
2
+ module Logging
3
+ autoload :NullLogger, 'huebot/logging/null_logger'
4
+ autoload :CollectingLogger, 'huebot/logging/collecting_logger'
5
+ autoload :IOLogger, 'huebot/logging/io_logger'
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module Huebot
2
+ module Logging
3
+ class NullLogger
4
+ def log(_event_type, _data = {})
5
+ self
6
+ end
7
+ end
8
+ end
9
+ end
@@ -16,15 +16,16 @@ module Huebot
16
16
  module AST
17
17
  Node = Struct.new(:instruction, :children, :errors, :warnings)
18
18
 
19
- Transition = Struct.new(:state, :devices, :sleep)
20
- SerialControl = Struct.new(:loop, :sleep)
21
- ParallelControl = Struct.new(:loop, :sleep)
19
+ Transition = Struct.new(:state, :devices, :wait, :pause)
20
+ SerialControl = Struct.new(:loop, :pause)
21
+ ParallelControl = Struct.new(:loop, :pause)
22
22
 
23
23
  InfiniteLoop = Struct.new(:pause)
24
24
  CountedLoop = Struct.new(:n, :pause)
25
25
  TimerLoop = Struct.new(:hours, :minutes, :pause)
26
26
  DeadlineLoop = Struct.new(:stop_time, :pause)
27
27
 
28
+ Pause = Struct.new(:pre, :post)
28
29
  DeviceRef = Struct.new(:ref)
29
30
  Light = Struct.new(:name)
30
31
  Group = Struct.new(:name)
@@ -1,4 +1,4 @@
1
1
  module Huebot
2
2
  # Gem version
3
- VERSION = '1.1.0'
3
+ VERSION = '1.3.0'
4
4
  end
data/lib/huebot.rb CHANGED
@@ -1,16 +1,16 @@
1
1
  module Huebot
2
2
  Error = Class.new(StandardError)
3
3
 
4
- autoload :Config, 'huebot/config'
5
4
  autoload :Client, 'huebot/client'
6
- autoload :CLI, 'huebot/cli'
5
+ autoload :CLI, 'huebot/cli/cli'
7
6
  autoload :Bridge, 'huebot/bridge'
8
7
  autoload :DeviceState, 'huebot/device_state'
9
8
  autoload :Light, 'huebot/light'
10
9
  autoload :Group, 'huebot/group'
11
10
  autoload :DeviceMapper, 'huebot/device_mapper'
12
11
  autoload :Program, 'huebot/program'
13
- autoload :Compiler, 'huebot/compiler'
12
+ autoload :Compiler, 'huebot/compiler/compiler'
14
13
  autoload :Bot, 'huebot/bot'
14
+ autoload :Logging, 'huebot/logging/logging'
15
15
  autoload :VERSION, 'huebot/version'
16
16
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: huebot
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Hollinger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-19 00:00:00.000000000 Z
11
+ date: 2023-12-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -50,17 +50,21 @@ files:
50
50
  - lib/huebot.rb
51
51
  - lib/huebot/bot.rb
52
52
  - lib/huebot/bridge.rb
53
- - lib/huebot/cli.rb
53
+ - lib/huebot/cli/cli.rb
54
+ - lib/huebot/cli/config.rb
54
55
  - lib/huebot/cli/helpers.rb
55
56
  - lib/huebot/cli/runner.rb
56
57
  - lib/huebot/client.rb
57
- - lib/huebot/compiler.rb
58
58
  - lib/huebot/compiler/api_v1.rb
59
- - lib/huebot/config.rb
59
+ - lib/huebot/compiler/compiler.rb
60
60
  - lib/huebot/device_mapper.rb
61
61
  - lib/huebot/device_state.rb
62
62
  - lib/huebot/group.rb
63
63
  - lib/huebot/light.rb
64
+ - lib/huebot/logging/collecting_logger.rb
65
+ - lib/huebot/logging/io_logger.rb
66
+ - lib/huebot/logging/logging.rb
67
+ - lib/huebot/logging/null_logger.rb
64
68
  - lib/huebot/program.rb
65
69
  - lib/huebot/version.rb
66
70
  homepage: https://github.com/jhollinger/huebot
data/lib/huebot/config.rb DELETED
@@ -1,41 +0,0 @@
1
- require 'fileutils'
2
- require 'yaml'
3
-
4
- module Huebot
5
- class Config
6
- def initialize(path = "~/.config/huebot")
7
- @path = File.expand_path(path)
8
- @dir = File.dirname(@path)
9
- @dir_exists = File.exist? @dir
10
- @config = File.exist?(@path) ? YAML.load_file(@path) : {}
11
- end
12
-
13
- def [](attr)
14
- @config[attr.to_s]
15
- end
16
-
17
- def []=(attr, val)
18
- if val.nil?
19
- @config.delete(attr.to_s)
20
- else
21
- @config[attr.to_s] = val
22
- end
23
- write
24
- end
25
-
26
- def clear
27
- @config.clear
28
- write
29
- end
30
-
31
- private
32
-
33
- def write
34
- unless @dir_exists
35
- FileUtils.mkdir_p @dir
36
- @dir_exists = true
37
- end
38
- File.write(@path, YAML.dump(@config))
39
- end
40
- end
41
- end