huebot 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 565062504fb9277b5047788566d50d5ea36223567f9ad5d8466a7ffa7886b661
4
+ data.tar.gz: 31d364292a69ffa6193ead6699d4b0642249c686a4917e040acf6e42408e9d02
5
+ SHA512:
6
+ metadata.gz: cbaaa7f8beb87c9443a697957766338d961ae08602e8472298d80234945b152e101099fa8ad5edae3226b86fe7c6a7528273034a29680a02f9d40c5ab99f4b34
7
+ data.tar.gz: e31b3ed57ccd4f47c64841c2707d067776d89f5f7c7b45c26393de0928f321f0a18d3c5fc7a89c7e432abea401f8262891d0b6b770dcfcc2b21ec9e0d99aa14f
@@ -0,0 +1,46 @@
1
+ # Huebot
2
+
3
+ Orchestration and automation for Philips Hue devices. Huebot can be used as a Ruby library or a command line utility. Huebot programs are declared as YAML files.
4
+
5
+ $ huebot run dimmer.yml --light="Office Desk"
6
+
7
+ **dimmer.yml**
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.
10
+
11
+ ## Install
12
+
13
+ gem install huebot
14
+
15
+ The curl library headers are required. On Ubuntu they can be installed with `apt-get install libcurl4-openssl-dev`.
16
+
17
+ ```yaml
18
+ initial:
19
+ switch: on
20
+ brightness: 254
21
+ device: $all
22
+
23
+ loop: true
24
+
25
+ transitions:
26
+ - device: $all
27
+ brightness: 150
28
+ time: 100
29
+ wait: 20
30
+
31
+ - device: $all
32
+ brightness: 254
33
+ time: 100
34
+ wait: 20
35
+ ```
36
+
37
+ 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)
38
+
39
+ ## UNDER ACTIVE DEVELOPMENT
40
+
41
+ **TODO**
42
+
43
+ * Validate number of inputs against compiled programs
44
+ * Brief explanation various features
45
+ * Wiki entry with more examples
46
+ * Link to official Hue docs
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # TODO remove
4
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
5
+
6
+ require 'huebot'
7
+ require 'huebot/cli'
8
+
9
+ Huebot::CLI.tap { |cli|
10
+ case cli.get_cmd
11
+ when :ls
12
+ client = Hue::Client.new
13
+ puts "Lights\n" + client.lights.map { |l| " #{l.id}: #{l.name}" }.join("\n") + \
14
+ "\nGroups\n" + client.groups.map { |g| " #{g.id}: #{g.name}" }.join("\n")
15
+
16
+ when :run
17
+ opts, sources = cli.get_input!
18
+
19
+ client = Hue::Client.new
20
+ device_mapper = Huebot::DeviceMapper.new(client, opts.inputs)
21
+ compiler = Huebot::Compiler.new(device_mapper)
22
+
23
+ programs = sources.map { |src|
24
+ compiler.build src.ir, File.basename(src.filepath, ".*")
25
+ }
26
+ found_errors, _found_warnings = cli.check! programs, $stderr
27
+ exit 1 if found_errors
28
+
29
+ bot = Huebot::Bot.new(client)
30
+ programs.each { |prog| bot.execute prog }
31
+
32
+ when :check
33
+ opts, sources = cli.get_input!
34
+
35
+ client = Hue::Client.new
36
+ device_mapper = Huebot::DeviceMapper.new(client, opts.inputs)
37
+ compiler = Huebot::Compiler.new(device_mapper)
38
+
39
+ programs = sources.map { |src|
40
+ compiler.build src.ir, File.basename(src.filepath, ".*")
41
+ }
42
+ found_errors, found_warnings = cli.check! programs, $stdout
43
+ # TODO validate NUMBER of inputs against each program
44
+ exit (found_errors || found_warnings) ? 1 : 0
45
+
46
+ else cli.help!
47
+ end
48
+ }
@@ -0,0 +1,31 @@
1
+ require 'hue'
2
+
3
+ module Huebot
4
+ autoload :DeviceMapper, 'huebot/device_mapper'
5
+ autoload :Program, 'huebot/program'
6
+ autoload :Compiler, 'huebot/compiler'
7
+ autoload :Bot, 'huebot/bot'
8
+ autoload :VERSION, 'huebot/version'
9
+
10
+ #
11
+ # Struct for storing a program's Intermediate Representation and source filepath.
12
+ #
13
+ # @attr ir [Hash]
14
+ # @attr filepath [String]
15
+ #
16
+ ProgramSrc = Struct.new(:ir, :filepath)
17
+
18
+ #
19
+ # Struct for specifying a Light input (id or name)
20
+ #
21
+ # @attr val [Integer|String] id or name
22
+ #
23
+ LightInput = Struct.new(:val)
24
+
25
+ #
26
+ # Struct for specifying a Gropu input (id or name)
27
+ #
28
+ # @attr val [Integer|String] id or name
29
+ #
30
+ GroupInput = Struct.new(:val)
31
+ end
@@ -0,0 +1,66 @@
1
+ module Huebot
2
+ class Bot
3
+ attr_reader :client
4
+
5
+ Error = Class.new(StandardError)
6
+
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ 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
25
+ end
26
+
27
+ private
28
+
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
36
+ end
37
+ end
38
+
39
+ def parallel_transitions(t)
40
+ t.children.map { |sub_t|
41
+ Thread.new {
42
+ transition sub_t
43
+ }
44
+ }.map(&:join)
45
+ wait t.wait if t.wait and t.wait > 0
46
+ end
47
+
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
55
+ }
56
+ }.map(&:join)
57
+ end
58
+
59
+ def wait(time)
60
+ ms = time * 100
61
+ seconds = ms / 1000.to_f
62
+ # TODO sleep in small bursts in a loop so can detect if an Interrupt was caught
63
+ sleep seconds
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,118 @@
1
+ require 'optparse'
2
+ require 'ostruct'
3
+ require 'yaml'
4
+
5
+ module Huebot
6
+ #
7
+ # Helpers for running huebot in cli-mode.
8
+ #
9
+ module CLI
10
+ #
11
+ # Struct for storing cli options and program files.
12
+ #
13
+ # @attr inputs [Array<String>]
14
+ #
15
+ Options = Struct.new(:inputs)
16
+
17
+ #
18
+ # Returns the command given to huebot.
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?
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
+ return options, files.map { |path|
45
+ ProgramSrc.new(YAML.load_file(path), path)
46
+ }
47
+ end
48
+ end
49
+
50
+ #
51
+ # Prints any program errors or warnings, and returns a boolean for each.
52
+ #
53
+ # @param programs [Array<Huebot::Program>]
54
+ # @param io [IO] Usually $stdout or $stderr
55
+ # @param quiet [Boolean] if true, don't print anything
56
+ #
57
+ def self.check!(programs, io, quiet: false)
58
+ if (invalid_progs = programs.select { |prog| prog.errors.any? }).any?
59
+ print_messages! io, "Errors", invalid_progs, :errors unless quiet
60
+ end
61
+
62
+ if (imperfect_progs = programs.select { |prog| prog.warnings.any? }).any?
63
+ puts "" if invalid_progs.any?
64
+ print_messages! io, "Warnings", imperfect_progs, :warnings unless quiet
65
+ end
66
+
67
+ return invalid_progs.any?, imperfect_progs.any?
68
+ end
69
+
70
+ # Print help and exit
71
+ def self.help!
72
+ _, parser = option_parser
73
+ puts parser.help
74
+ exit 1
75
+ end
76
+
77
+ private
78
+
79
+ #
80
+ # Print each message (of the given type) for each program.
81
+ #
82
+ # @param io [IO] Usually $stdout or $stderr
83
+ # @param label [String] Top-level for this group of messages
84
+ # @param progs [Array<Huebot::CLI::Program>]
85
+ # @param msg_type [Symbol] name of method that holds the messages (i.e. :errors or :warnings)
86
+ #
87
+ def self.print_messages!(io, label, progs, msg_type)
88
+ io.puts "#{label}:"
89
+ progs.each { |prog|
90
+ io.puts " #{prog.name}:"
91
+ prog.send(msg_type).each_with_index { |msg, i| io.puts " #{i+1}. #{msg}" }
92
+ }
93
+ end
94
+
95
+ def self.option_parser
96
+ options = Options.new([])
97
+ parser = OptionParser.new { |opts|
98
+ opts.banner = %(
99
+ List all lights and groups:
100
+ huebot ls
101
+
102
+ Run program(s):
103
+ huebot run file1.yml [file2.yml [file3.yml ...]] [options]
104
+
105
+ Validate programs and inputs:
106
+ huebot check file1.yml [file2.yml [file3.yml ...]] [options]
107
+
108
+ Options:
109
+ ).strip
110
+ opts.on("-lLIGHT", "--light=LIGHT", "Light ID or name") { |l| options.inputs << LightInput.new(l) }
111
+ opts.on("-gGROUP", "--group=GROUP", "Group ID or name") { |g| options.inputs << GroupInput.new(g) }
112
+ opts.on("--all", "All lights and groups TODO") { $stderr.puts "Not Implemented"; exit 1 }
113
+ opts.on("-h", "--help", "Prints this help") { puts opts; exit }
114
+ }
115
+ return options, parser
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,159 @@
1
+ module Huebot
2
+ class Compiler
3
+ def initialize(device_mapper)
4
+ @device_mapper = device_mapper
5
+ end
6
+
7
+ #
8
+ # Build a huebot program from an intermediate representation (a Hash).
9
+ #
10
+ # @param ir [Hash]
11
+ # @param default_name [String] A name to use if one isn't specified
12
+ # @return [Huebot::Program]
13
+ #
14
+ def build(ir, default_name = nil)
15
+ ir = ir.clone
16
+ prog = Huebot::Program.new
17
+ prog.name = ir.delete("name") || default_name
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
89
+ end
90
+ end
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
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,51 @@
1
+ module Huebot
2
+ class DeviceMapper
3
+ Unmapped = Class.new(StandardError)
4
+
5
+ def initialize(client, inputs = [])
6
+ all_lights, all_groups = client.lights, client.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 }
12
+ @devices_by_var = inputs.each_with_index.reduce({}) { |a, (x, idx)|
13
+ dev = case x
14
+ when LightInput then @lights_by_id[x.val] || @lights_by_name[x.val]
15
+ when GroupInput then @groups_by_id[x.val] || @groups_by_name[x.val]
16
+ else raise "Invalid input: #{x}"
17
+ end || raise(Unmapped, "Could not find #{x.class.name[8..-6].downcase} with id or name '#{x.val}'")
18
+ a["$#{idx + 1}"] = dev
19
+ a
20
+ }
21
+ @all = @devices_by_var.values
22
+ end
23
+
24
+ def light!(id)
25
+ case id
26
+ when Integer
27
+ @lights_by_id[id]
28
+ when String
29
+ @lights_by_name[id]
30
+ end || (raise Unmapped, "Unmapped light '#{id}'")
31
+ end
32
+
33
+ def group!(id)
34
+ case id
35
+ when Integer
36
+ @groups_by_id[id]
37
+ when String
38
+ @groups_by_name[id]
39
+ end || (raise Unmapped, "Unmapped group '#{id}'")
40
+ end
41
+
42
+ def var!(id)
43
+ case id
44
+ when "$all"
45
+ @all
46
+ else
47
+ @devices_by_var[id]
48
+ end || (raise Unmapped, "Unmapped device '#{id}'")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,32 @@
1
+ module Huebot
2
+ class Program
3
+ Transition = Struct.new(:wait, :state, :devices)
4
+ ParallelTransition = Struct.new(:wait, :children)
5
+
6
+ attr_accessor :name
7
+ attr_accessor :initial_state
8
+ attr_accessor :transitions
9
+ attr_accessor :final_state
10
+ attr_accessor :loop
11
+ attr_accessor :loops
12
+ attr_accessor :errors
13
+ attr_accessor :warnings
14
+
15
+ def initialize
16
+ @name = nil
17
+ @initial_state = nil
18
+ @transitions = []
19
+ @final_state = nil
20
+ @loop = false
21
+ @loops = 0
22
+ @errors = []
23
+ @warnings = []
24
+ end
25
+
26
+ def valid?
27
+ errors.empty?
28
+ end
29
+
30
+ alias_method :loop?, :loop
31
+ end
32
+ end
@@ -0,0 +1,4 @@
1
+ module Huebot
2
+ # Gem version
3
+ VERSION = '0.1.0'
4
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: huebot
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jordan Hollinger
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-12-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: hue
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.0
27
+ description: Declare and run YAML programs for Philips Hue devices
28
+ email: jordan.hollinger@gmail.com
29
+ executables:
30
+ - huebot
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - README.md
35
+ - bin/huebot
36
+ - lib/huebot.rb
37
+ - lib/huebot/bot.rb
38
+ - lib/huebot/cli.rb
39
+ - lib/huebot/compiler.rb
40
+ - lib/huebot/device_mapper.rb
41
+ - lib/huebot/program.rb
42
+ - lib/huebot/version.rb
43
+ homepage: https://github.com/jhollinger/huebot
44
+ licenses:
45
+ - MIT
46
+ metadata: {}
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 2.1.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubyforge_project:
63
+ rubygems_version: 2.7.6
64
+ signing_key:
65
+ specification_version: 4
66
+ summary: Orchestration for Hue devices
67
+ test_files: []