hue.rb (5400B)
1 # frozen_string_literal: true 2 3 require 'net/http' 4 require 'json' 5 require 'securerandom' 6 require 'logger' 7 require 'fileutils' 8 require 'socket' 9 require 'timeout' 10 11 module Hue 12 DEVICE_TYPE = 'hue-cli' 13 DEFAULT_UDP_TIMEOUT = 5 14 ERROR_DEFAULT_EXISTS = 'Default application already registered.' 15 ERROR_NO_BRIDGE_FOUND = 'No bridge found.' 16 17 def self.device_type 18 DEVICE_TYPE 19 end 20 21 def self.one_time_uuid 22 SecureRandom.hex(16) 23 end 24 25 def self.register_default 26 raise Hue::Error, ERROR_DEFAULT_EXISTS if begin 27 Hue::Config::Application.default 28 rescue StandardError 29 nil 30 end 31 32 bridge_config = register_bridges.values.first # Assuming one bridge for now 33 puts 'Registering new app...' 34 instance = Hue::Bridge.register(bridge_config.uri) 35 app_config = Hue::Config::Application.new(bridge_config.id, instance.application_id) 36 app_config.write 37 instance 38 end 39 40 def self.application 41 application_config = Hue::Config::Application.default 42 bridge_id = application_config.bridge_id 43 bridge_config = Hue::Config::Bridge.find(bridge_id) || register_bridges[bridge_id] 44 45 raise Error, "Unable to find bridge: #{bridge_id}" unless bridge_config 46 47 Hue::Bridge.new(application_config.id, bridge_config.uri) 48 end 49 50 def self.remove_default 51 instance = application 52 instance.unregister 53 Hue::Config::Application.default.delete 54 true 55 end 56 57 def self.discover 58 bridges = {} 59 udp_discover(bridges) 60 nupnp_discover(bridges) 61 bridges 62 end 63 64 UDP_SSDP_PAYLOAD = <<~PAYLOAD 65 M-SEARCH * HTTP/1.1 66 ST: ssdp:all 67 MX: 10 68 MAN: ssdp:discover 69 HOST: 239.255.255.250:1900 70 PAYLOAD 71 UDP_SSDP_ADDRESS = '239.255.255.250' 72 UDP_SSDP_PORT = 1900 73 74 def self.udp_discover(bridges) 75 log = Hue.logger 76 log.info('Bridge UDP Discovery') 77 socket = UDPSocket.new(Socket::AF_INET) 78 socket.send(UDP_SSDP_PAYLOAD, 0, UDP_SSDP_ADDRESS, UDP_SSDP_PORT) 79 Timeout.timeout(DEFAULT_UDP_TIMEOUT, Hue::Error) { listen_for_bridges(socket, bridges, log) } 80 rescue Hue::Error => e 81 log.warn(e) 82 log.info('UDPSocket timed out.') 83 end 84 85 def self.listen_for_bridges(socket, bridges, log) 86 loop do 87 message, (_, port, hostname, ip_add) = socket.recvfrom(1024) 88 if message =~ /IpBridge/ && /LOCATION: (.*)$/.match(message) 89 uuid_match = /uuid:(.{36})/.match(message) 90 next unless uuid_match 91 92 uuid = uuid_match.captures.first 93 log.info("Found bridge (#{hostname}:#{port}) with uuid: #{uuid}") unless bridges[uuid] 94 bridges[uuid] = "http://#{ip_add}/api" 95 else 96 log.debug("Found #{hostname}:#{port}: #{message}") 97 end 98 end 99 end 100 101 def self.nupnp_discover(bridges) 102 return unless bridges.empty? 103 104 Hue.logger.info('Bridge NUPNP Discovery') 105 response = Net::HTTP.get_response(URI('https://www.meethue.com/api/nupnp')) 106 json = begin 107 JSON.parse(response.body) 108 rescue StandardError 109 nil 110 end 111 return unless json 112 113 json.each do |bridge| 114 uuid = bridge['id'] 115 ip_add = bridge['internalipaddress'] 116 bridges[uuid] = "http://#{ip_add}/api" if uuid && ip_add 117 end 118 end 119 120 def self.register_bridges 121 bridges = discover 122 raise Error, ERROR_NO_BRIDGE_FOUND if bridges.empty? 123 124 bridges.each_with_object({}) do |(id, ip), hash| 125 config = Hue::Config::Bridge.new(id, ip) 126 config.write 127 hash[id] = config 128 end 129 end 130 131 class Error < StandardError 132 attr_accessor :original_error 133 134 def initialize(message, original_error = nil) 135 super(message) 136 @original_error = original_error 137 end 138 139 def to_s 140 @original_error ? "#{super}\nCause: #{@original_error}" : super 141 end 142 end 143 144 module API 145 class Error < ::Hue::Error 146 def initialize(api_error) 147 @type = api_error['type'] 148 @address = api_error['address'] 149 super(api_error['description']) 150 end 151 end 152 end 153 154 def self.logger 155 @logger ||= begin 156 log_dir_path = ensure_log_dir 157 log_file_path = File.join(log_dir_path, 'hue.log') 158 log_file = File.new(log_file_path, File::WRONLY | File::APPEND | File::CREAT) 159 logger = Logger.new(log_file) 160 logger.level = Logger::INFO 161 logger 162 end 163 end 164 165 def self.ensure_log_dir 166 home = Dir.home 167 [File.join(home, '.hue'), File.join(home, ".#{device_type}")].each do |path| 168 FileUtils.mkdir_p(path) 169 return path 170 rescue Errno::EACCES 171 next 172 end 173 raise Error, 'Unable to create log directory' 174 end 175 176 def self.percent_to_unit_interval(value) 177 if (percent = /(\d+)%/.match(value.to_s)) 178 percent.captures.first.to_i / 100.0 179 end 180 end 181 end 182 183 # Core library components 184 require_relative 'hue/config/abstract' 185 require_relative 'hue/config/application' 186 require_relative 'hue/config/bridge' 187 require_relative 'hue/bridge' 188 require_relative 'hue/colors' 189 require_relative 'hue/bulb' 190 191 # CLI components (utilities must come before extensions) 192 require_relative 'hue/utilities/indentation' 193 require_relative 'hue/cli' 194 require_relative 'hue/cli/command' 195 require_relative 'hue/cli/processors/light_state_alias' 196 require_relative 'hue/cli/commands/register' 197 require_relative 'hue/cli/commands/delete' 198 require_relative 'hue/cli/commands/light' 199 require_relative 'hue/cli/commands/lights' 200 require_relative 'hue/extensions/hue' 201 require_relative 'hue/extensions/bridge' 202 require_relative 'hue/extensions/bulb'