diff --git a/bin/hued b/bin/hued index 7c4cea2..560aaba 100755 --- a/bin/hued +++ b/bin/hued @@ -10,327 +10,7 @@ # Free Software Foundation; either version 2 of the License, or (at your # option) any later version. -require "chronic" -require "eventmachine" -require "huey" -require "logger" -require "optparse" -require "pp" - -# The main engine class -class Hued - - # Scenes - Scenes = {} - - # Program version - VERSION = "0.0.1" - - # The rule class - class Rule - - attr_reader :name, :conditions, :tigger, :priority, :events, :scene - - def initialize(name, log, entry) - @name = name - @log = log - @validity = false - @conditions = entry["conditions"] || [] - @trigger = entry["trigger"].nil? ? true : entry["trigger"] - @triggered = false - @priority = entry["priority"] || 0 - @events = [] - - if entry["events"] - entry["events"].each do |ev_name| - event = Huey::Event.find(ev_name) - if event.nil? - @log.warn "Could not find event \"#{ev_name}\" for rule \"#{name}\", ignoring!" - else - @events << event - end - end - elsif entry["scene"] - @scene = entry["scene"] if Scenes.has_key? entry["scene"] - if @scene.nil? - @log.warn "Could not find scene \"#{entry["scene"]}\" for rule \"#{name}\", ignoring!" - end - else - raise ArgumentError, "You must supply either an even or scene name" - end - end - - def valid? - # Determine validity - prev_validity = @validity - @validity = test_conditions - - # Reset the triggered flag if the this is a trigger rule, but it is - # no longer valid - @triggered = false if @trigger and prev_validity and !@validity - - @validity - end - - def triggered? - @triggered - end - - def trigger? - @trigger - end - - def execute - @triggered = true - events = if @scene - @log.info "Executing scene: #{@scene}" - events = Scenes[@scene] - elsif @events - @events - else - @log.info "No scene or events found, skipping execution" - [] - end - - events.each_with_index do |event, idx| - if event.name - @log.info "Executing event: #{event.name}!" - else - @log.info "Executing event #{idx}" - end - retry_count = 0 - begin - event.execute - rescue Huey::Errors::BulbOff - if retry_count > 4 - @log.warn "One of the lights is still off, ignoring event" - else - @log.warn "One of the lights was off, retrying..." - event.group.bulbs.each { |bulb| bulb.on = false } - retry_count += 1 - retry - end - rescue Huey::Errors::Error => e - @log.error "Error while executing event (#{e.class}): #{e.message}" - end - end - end - - ####### - private - - def test_conditions - # If there are no conditions, the rule is always valid - return true if @conditions.empty? - - @conditions.map do |cond| - cond_negate = false - res = if cond.is_a? Hash - cond_name, cond_value = cond.to_a.first - if cond_name[0] == "^" - cond_negate = true - cond_name = cond_name[1..-1] - end - case cond_name - when "from" - Time.now >= Chronic.parse(cond_value) - when "until" - Time.now <= Chronic.parse(cond_value) - when "found host" - system("ping -W3 -c1 -q #{cond_value} > /dev/null") - end - else - @log.warn "Unknown condition type/form #{cond.inspect}" - end - cond_negate ? !res : res - end.all? - end - - end # class Hued::Rule - - def initialize(options = {}) - @options = options - @ctime = Hash.new(Time.now) - - # Set up the logger - @log = Logger.new($stdout) - @log.progname = "hued" - @log.level = options[:debug] ? Logger::DEBUG : Logger::INFO - @log.formatter = proc do |severity, datetime, progname, msg| - "#{datetime.strftime('%b %d %X')} #{progname}[#{$$}]: #{msg}\n" - end - - configure - discover - load - @log.info "Started successfully!" - end - - def configure - @log.info "Starting..." - bridge_cfg = File.open("bridge.yml") { |file| YAML.load(file) } - Huey.configure do |cfg| - cfg.hue_ip = bridge_cfg["ip"] - cfg.uuid = bridge_cfg["user"] - if @options[:hue_debug] - cfg.logger = @log - else - # Use the default logger and make it shut up - cfg.logger.level = Logger::FATAL - end - end - @log.info "Configured bridge connection" - end - - def discover - @log.info "Discovering lights..." - @lights = Huey::Bulb.all - @lights.each do |light| - @log.info "* Found light #{light.id}: #{light.name}" - light.alert! if @options[:blink] - end - @log.info "Found #{@lights.count} light#{"s" unless @lights.count == 1}" - - @log.info "Discovering groups..." - @groups = Huey::Group.all - @groups.each do |group| - @log.info "* Found group #{group.id}: #{group.name} with " \ - "lights #{group.bulbs.map(&:id).join(", ")}" - end - @log.info "Found #{@groups.count} group#{@groups.count != 1 || "s"}" - end - - def refresh! - @log.debug "Refreshing lights..." - @lights.each { |light| light.reload } - @log.debug "Refreshed #{@lights.count} light#{"s" unless @lights.count == 1}" - end - - def load - [:events, :scenes].each do |items| - if File.exist? "#{items}.yml" - @log.info "Loading #{items}..." - send("load_#{items}") - end - end - - # Treat rules separately, we cannot start without it - if File.exist? "rules.yml" - @log.info "Loading rules" - load_rules - else - @log.error "Cannot find required file: rules.yml, aborting!" - exit 1 - end - end - - def reload - @log.debug "Checking if events/scenes/rules need to be reloaded..." - @reload_rules = false - [:events, :scenes].each do |items| - if File.exist?("#{items}.yml") and - File.ctime("#{items}.yml") > @ctime[items] - @log.info "Reloading events..." - send("load_#{items}") - # Rules may depend on events/scenes, reload the rules too! - @reload_rules = true - end - end - - if File.exist?("rules.yml") and - (@reload_rules or File.ctime("rules.yml") > @ctime[:rules]) - @log.info "Reloading rules..." - send("load_rules") - end - end - - def execute - @log.debug "Looking for active (and valid) rules..." - valid_rules = @rules.select(&:valid?) - if valid_rules.empty? - @log.debug "No valid rules found" - return - else - @log.debug "There #{valid_rules.count == 1 ? "is" : "are"} " \ - "#{valid_rules.count} valid " \ - "rule#{"s" unless valid_rules.count == 1}" - end - - prio_map = valid_rules.group_by(&:priority) - prios = prio_map.keys.sort - prios.each do |prio| - prio_rules = prio_map[prio] - @log.debug "* Rule#{"s" unless prio_rules.count == 1} with prioity #{prio}: " + - prio_rules.map(&:name).join(", ") - end - active_rules = prio_map[prios.last] - if valid_rules != active_rules - @log.debug "There #{active_rules.count == 1 ? "is" : "are"} " \ - "only #{active_rules.count} active " \ - "rule#{"s" unless active_rules.count == 1}" - "(i.e. with priority #{prios.last})" - end - active_rules.each do |rule| - if rule.trigger? - if rule.triggered? - @log.info "Rule \"#{rule.name}\" is active, but has already been triggered" - else - @log.info "Rule \"#{rule.name}\" is active and should be triggered" - rule.execute - end - else - @log.info "Rule \"#{rule.name}\" is active and should be triggered (again)" - rule.execute - end - end - end - - def shutdown - @log.info "Shutting down..." - end - - ####### - private - - def load_events - @ctime[:events] = File.ctime("events.yml") - @events = Huey::Event.import("events.yml") - @events.each do |event| - event.actions["on"] = true if event.actions["on"].nil? - @log.info "* Loaded event: #{event.name}" - end - @log.info "Loaded #{@events.count} event#{"s" unless @events.count == 1}" - end - - def load_scenes - @ctime[:scenes] = File.ctime "scenes.yml" - @scenes = {} - YAML.load_file("scenes.yml").each do |name, entry| - @scenes[name] = entry.map do |ev_options| - # Keys should be symbols - options = ev_options.inject({}) { |opts, (k, v)| opts[k.to_sym] = v; opts } - event = Huey::Event.new(options) - event.actions["on"] = true if event.actions["on"].nil? - event - end - Scenes[name] = @scenes[name] - @log.info "* Loaded scene: #{name}" - end - @log.info "Loaded #{@scenes.count} scene#{"s" unless @scenes.count == 1}" - end - - def load_rules - @ctime[:rules] = File.ctime "rules.yml" - @rules = YAML.load_file("rules.yml").map do |name, entry| - Rule.new(name, @log, entry) - end - @rules.each do |rule| - @log.info "* Loaded rule: #{rule.name}" - end - @log.info "Loaded #{@rules.count} rule#{"s" unless @rules.count == 1}" - end - -end # class Hued +require "hued" # Option parsing options = {blink: true} @@ -369,7 +49,8 @@ rescue OptionParser::InvalidOption => e end # Create the main engine -hued = Hued.new(options) +Hued.configure(options) +engine = Hued::Engine.new # Handle signals Signal.trap("INT") { EM.stop } @@ -377,7 +58,7 @@ Signal.trap("TERM") { EM.stop } # Trigger rule execution and light status refreshing periodically EM.run do - EM.add_periodic_timer(10) { hued.reload; hued.execute } - EM.add_periodic_timer(300) { hued.refresh! } + EM.add_periodic_timer(10) { engine.reload; engine.execute } + EM.add_periodic_timer(300) { engine.refresh! } end -hued.shutdown +engine.shutdown diff --git a/lib/hued.rb b/lib/hued.rb new file mode 100644 index 0000000..a082c2e --- /dev/null +++ b/lib/hued.rb @@ -0,0 +1,40 @@ +# encoding: utf-8 + +require "chronic" +require "eventmachine" +require "huey" +require "logger" +require "optparse" +require "pp" + +require "hued/version" + +require "hued/engine" +require "hued/rule" + +module Hued + extend self + + # Daemon configuration + attr_reader :config + + # Daemon log + attr_reader :log + + # Loaded scenes + # FIXME: load scenes as Hued::Scene classes + Scenes = {} + + def configure(options) + @config = options + + # Set up the logger + @log = Logger.new($stdout) + @log.progname = "hued" + @log.level = options[:debug] ? Logger::DEBUG : Logger::INFO + @log.formatter = proc do |severity, datetime, progname, msg| + "#{datetime.strftime('%b %d %X')} #{progname}[#{$$}]: #{msg}\n" + end + end + +end diff --git a/lib/hued/engine.rb b/lib/hued/engine.rb new file mode 100644 index 0000000..3ba63f7 --- /dev/null +++ b/lib/hued/engine.rb @@ -0,0 +1,192 @@ +# encoding: utf-8 + +module Hued + + # The main engine class + class Engine + + def initialize(options = {}) + @options = Hued.config + @log = Hued.log + @ctime = Hash.new(Time.now) + + configure + discover + load + @log.info "Started successfully!" + end + + def configure + @log.info "Starting..." + bridge_cfg = File.open("bridge.yml") { |file| YAML.load(file) } + Huey.configure do |cfg| + cfg.hue_ip = bridge_cfg["ip"] + cfg.uuid = bridge_cfg["user"] + if @options[:hue_debug] + cfg.logger = @log + else + # Use the default logger and make it shut up + cfg.logger.level = Logger::FATAL + end + end + @log.info "Configured bridge connection" + end + + def discover + @log.info "Discovering lights..." + @lights = Huey::Bulb.all + @lights.each do |light| + @log.info "* Found light #{light.id}: #{light.name}" + light.alert! if @options[:blink] + end + @log.info "Found #{@lights.count} light#{"s" unless @lights.count == 1}" + + @log.info "Discovering groups..." + @groups = Huey::Group.all + @groups.each do |group| + @log.info "* Found group #{group.id}: #{group.name} with " \ + "lights #{group.bulbs.map(&:id).join(", ")}" + end + @log.info "Found #{@groups.count} group#{@groups.count != 1 || "s"}" + rescue + @lights = [] + @groups = [] + end + + def refresh! + @log.debug "Refreshing lights..." + @lights.each { |light| light.reload } + @log.debug "Refreshed #{@lights.count} light#{"s" unless @lights.count == 1}" + end + + def load + [:events, :scenes].each do |items| + if File.exist? "#{items}.yml" + @log.info "Loading #{items}..." + send("load_#{items}") + end + end + + # Treat rules separately, we cannot start without it + if File.exist? "rules.yml" + @log.info "Loading rules" + load_rules + else + @log.error "Cannot find required file: rules.yml, aborting!" + exit 1 + end + end + + def reload + @log.debug "Checking if events/scenes/rules need to be reloaded..." + @reload_rules = false + [:events, :scenes].each do |items| + if File.exist?("#{items}.yml") and + File.ctime("#{items}.yml") > @ctime[items] + @log.info "Reloading events..." + send("load_#{items}") + # Rules may depend on events/scenes, reload the rules too! + @reload_rules = true + end + end + + if File.exist?("rules.yml") and + (@reload_rules or File.ctime("rules.yml") > @ctime[:rules]) + @log.info "Reloading rules..." + send("load_rules") + end + end + + def execute + @log.debug "Looking for active (and valid) rules..." + valid_rules = @rules.select(&:valid?) + if valid_rules.empty? + @log.debug "No valid rules found" + return + else + @log.debug "There #{valid_rules.count == 1 ? "is" : "are"} " \ + "#{valid_rules.count} valid " \ + "rule#{"s" unless valid_rules.count == 1}" + end + + prio_map = valid_rules.group_by(&:priority) + prios = prio_map.keys.sort + prios.each do |prio| + prio_rules = prio_map[prio] + @log.debug "* Rule#{"s" unless prio_rules.count == 1} with prioity #{prio}: " + + prio_rules.map(&:name).join(", ") + end + active_rules = prio_map[prios.last] + if valid_rules != active_rules + @log.debug "There #{active_rules.count == 1 ? "is" : "are"} " \ + "only #{active_rules.count} active " \ + "rule#{"s" unless active_rules.count == 1}" + "(i.e. with priority #{prios.last})" + end + active_rules.each do |rule| + if rule.trigger? + if rule.triggered? + @log.info "Rule \"#{rule.name}\" is active, but has already been triggered" + else + @log.info "Rule \"#{rule.name}\" is active and should be triggered" + rule.execute + end + else + @log.info "Rule \"#{rule.name}\" is active and should be triggered (again)" + rule.execute + end + end + end + + def shutdown + @log.info "Shutting down..." + end + + ####### + private + + def load_events + @ctime[:events] = File.ctime("events.yml") + @events = Huey::Event.import("events.yml") + @events.each do |event| + event.actions["on"] = true if event.actions["on"].nil? + @log.info "* Loaded event: #{event.name}" + end + @log.info "Loaded #{@events.count} event#{"s" unless @events.count == 1}" + rescue + @events= [] + end + + def load_scenes + @ctime[:scenes] = File.ctime "scenes.yml" + @scenes = {} + YAML.load_file("scenes.yml").each do |name, entry| + @scenes[name] = entry.map do |ev_options| + # Keys should be symbols + options = ev_options.inject({}) { |opts, (k, v)| opts[k.to_sym] = v; opts } + event = Huey::Event.new(options) + event.actions["on"] = true if event.actions["on"].nil? + event + end + Scenes[name] = @scenes[name] + @log.info "* Loaded scene: #{name}" + end + @log.info "Loaded #{@scenes.count} scene#{"s" unless @scenes.count == 1}" + rescue + @scenes = {} + end + + def load_rules + @ctime[:rules] = File.ctime "rules.yml" + @rules = YAML.load_file("rules.yml").map do |name, entry| + Rule.new(name, @log, entry) + end + @rules.each do |rule| + @log.info "* Loaded rule: #{rule.name}" + end + @log.info "Loaded #{@rules.count} rule#{"s" unless @rules.count == 1}" + end + + end # class Hued::Engine + +end # module Hued diff --git a/lib/hued/rule.rb b/lib/hued/rule.rb new file mode 100644 index 0000000..b050e82 --- /dev/null +++ b/lib/hued/rule.rb @@ -0,0 +1,127 @@ +# encoding: utf-8 + +module Hued + + # The rule class + class Rule + + attr_reader :name, :conditions, :tigger, :priority, :events, :scene + + def initialize(name, log, entry) + @name = name + @log = log + @validity = false + @conditions = entry["conditions"] || [] + @trigger = entry["trigger"].nil? ? true : entry["trigger"] + @triggered = false + @priority = entry["priority"] || 0 + @events = [] + + if entry["events"] + entry["events"].each do |ev_name| + event = Huey::Event.find(ev_name) + if event.nil? + @log.warn "Could not find event \"#{ev_name}\" for rule \"#{name}\", ignoring!" + else + @events << event + end + end + elsif entry["scene"] + @scene = entry["scene"] if Scenes.has_key? entry["scene"] + if @scene.nil? + @log.warn "Could not find scene \"#{entry["scene"]}\" for rule \"#{name}\", ignoring!" + end + else + raise ArgumentError, "You must supply either an even or scene name" + end + end + + def valid? + # Determine validity + prev_validity = @validity + @validity = test_conditions + + # Reset the triggered flag if the this is a trigger rule, but it is + # no longer valid + @triggered = false if @trigger and prev_validity and !@validity + + @validity + end + + def triggered? + @triggered + end + + def trigger? + @trigger + end + + def execute + @triggered = true + events = if @scene + @log.info "Executing scene: #{@scene}" + events = Scenes[@scene] + elsif @events + @events + else + @log.info "No scene or events found, skipping execution" + [] + end + + events.each_with_index do |event, idx| + if event.name + @log.info "Executing event: #{event.name}!" + else + @log.info "Executing event #{idx}" + end + retry_count = 0 + begin + event.execute + rescue Huey::Errors::BulbOff + if retry_count > 4 + @log.warn "One of the lights is still off, ignoring event" + else + @log.warn "One of the lights was off, retrying..." + event.group.bulbs.each { |bulb| bulb.on = false } + retry_count += 1 + retry + end + rescue Huey::Errors::Error => e + @log.error "Error while executing event (#{e.class}): #{e.message}" + end + end + end + + ####### + private + + def test_conditions + # If there are no conditions, the rule is always valid + return true if @conditions.empty? + + @conditions.map do |cond| + cond_negate = false + res = if cond.is_a? Hash + cond_name, cond_value = cond.to_a.first + if cond_name[0] == "^" + cond_negate = true + cond_name = cond_name[1..-1] + end + case cond_name + when "from" + Time.now >= Chronic.parse(cond_value) + when "until" + Time.now <= Chronic.parse(cond_value) + when "found host" + system("ping -W3 -c1 -q #{cond_value} > /dev/null 2>&1") + end + else + @log.warn "Unknown condition type/form #{cond.inspect}" + end + cond_negate ? !res : res + end.all? + end + + end # class Hued::Rule + +end # module Hued diff --git a/lib/hued/version.rb b/lib/hued/version.rb new file mode 100644 index 0000000..d805b91 --- /dev/null +++ b/lib/hued/version.rb @@ -0,0 +1,6 @@ +# encoding: utf-8 + +module Hued + # Daemon version + VERSION = "0.0.1" +end