huebot 1.1.0 → 1.3.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: 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