├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── ledenet-ufo ├── ledenet_api.gemspec └── lib ├── ledenet ├── api.rb ├── device_discovery.rb ├── function_speed.rb ├── functions.rb ├── packets │ ├── empty_response.rb │ ├── fields │ │ └── checksum.rb │ ├── set_function_request.rb │ ├── set_power_request.rb │ ├── status_request.rb │ ├── status_response.rb │ └── update_color_request.rb └── version.rb └── ledenet_api.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.iml 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in alexa_verifier.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ledenet_api (1.1.0) 5 | bindata (>= 2.3) 6 | ipaddress (>= 0.8) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | bindata (2.3.0) 12 | diff-lcs (1.2.5) 13 | ipaddress (0.8.3) 14 | rake (10.4.2) 15 | rspec (3.3.0) 16 | rspec-core (~> 3.3.0) 17 | rspec-expectations (~> 3.3.0) 18 | rspec-mocks (~> 3.3.0) 19 | rspec-core (3.3.2) 20 | rspec-support (~> 3.3.0) 21 | rspec-expectations (3.3.1) 22 | diff-lcs (>= 1.2.0, < 2.0) 23 | rspec-support (~> 3.3.0) 24 | rspec-mocks (3.3.2) 25 | diff-lcs (>= 1.2.0, < 2.0) 26 | rspec-support (~> 3.3.0) 27 | rspec-support (3.3.0) 28 | 29 | PLATFORMS 30 | ruby 31 | 32 | DEPENDENCIES 33 | bundler (~> 1.10) 34 | ledenet_api! 35 | rake (~> 10.0) 36 | rspec 37 | 38 | BUNDLED WITH 39 | 1.11.2 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Christopher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ledenet_api 2 | An API for the [LEDENET Magic UFO LED WiFi Controller](http://amzn.com/B00MDKOSN0) 3 | 4 | ## What's this? 5 | This RGB LED controller is a relatively cheap (~$30) alternative to something like the Phillips Hue RGB Strip + Hub, which can run you between $100 and $200. 6 | 7 | However, it doesn't come with an open API, and doesn't integrate with smarthome hubs (SmartThings, etc.). I used a [packet capture app](https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture&hl=en) on my phone to reverse engineer how the official app communicated with the controller. 8 | 9 | ## Installing 10 | 11 | ledenet_api is available on [Rubygems](https://rubygems.org). You can install it with: 12 | 13 | ``` 14 | $ gem install ledenet_api 15 | ``` 16 | 17 | You can also add it to your Gemfile: 18 | 19 | ``` 20 | gem 'ledenet_api' 21 | ``` 22 | 23 | ## Using it 24 | 25 | In addition to the Ruby API, this gem also comes bundled with an executable named `ledenet-ufo`. 26 | 27 | ### Commandline 28 | 29 | Here's the `--help` message: 30 | 31 | ``` 32 | $ ledenet-ufo 33 | Usage: ledenet-ufo --list 34 | OR: ledenet-ufo [IP|HW ADDR] [OPTIONS] 35 | 36 | -r, --red [VALUE] Set red to VALUE 37 | -g, --green [VALUE] Set green to VALUE 38 | -b, --blue [VALUE] Set blue to VALUE 39 | -w, --warm-white [VALUE] Set warm white to VALUE 40 | --on Turn on the controller 41 | --off Turn off the controller 42 | -l, --list Prints a list of available devices and exits 43 | -s, --status Prints status as JSON 44 | -h, --help Prints this help message 45 | --function-id [VALUE] Set function id to VALUE 46 | -f, --function [VALUE] Set function to VALUE. 47 | ``` 48 | 49 | When using it, you can specify the IP address, hardware (mac) address, or let `ledenet_api` choose an arbitrary device on the local network (this would work well if you only have one). 50 | 51 | Examples: 52 | 53 | #### List available devices 54 | 55 | ``` 56 | $ ledenet-ufo --list 57 | IP ADDRESS HW ADDRESS Model # 58 | 10.133.8.113 XXXXXXXXXXXX HF-LPB100-ZJ200 59 | ``` 60 | 61 | #### Get current status 62 | 63 | ``` 64 | $ ledenet-ufo --status 65 | {"is_on":true,"red":"255","green":"255","blue":"255","warm_white":"255","running_function?":false,"speed":61,"speed_packet_value":"12","function_name":"NO_FUNCTION","function_id":"97"} 66 | ``` 67 | 68 | #### Turn on, adjust colors 69 | 70 | ``` 71 | $ ledenet-ufo --on -r 200 -g 0 -b 255 --warm-white 0 --status 72 | {"is_on":true,"red":"200","green":"0","blue":"255","warm_white":"0","running_function?":false,"speed":99,"speed_packet_value":"0","function_name":"NO_FUNCTION","function_id":"97"} 73 | ``` 74 | 75 | #### Turn off 76 | 77 | ``` 78 | $ ledenet-ufo --off 79 | ``` 80 | 81 | #### Set function 82 | 83 | ``` 84 | $ ledenet-ufo --speed 60 --function seven_color_cross_fade --status 85 | {"is_on":true,"red":"255","green":"0","blue":"0","warm_white":"255","running_function?":true,"speed":61,"speed_packet_value":"12","function_name":"SEVEN_COLOR_CROSS_FADE","function_id":"37"} 86 | ``` 87 | 88 | ### Ruby API 89 | 90 | #### Device discovery 91 | 92 | These devices implement a service discovery protocol, which allows you to find them on your network without digging for their IP address. To use it: 93 | 94 | ```ruby 95 | require 'ledenet_api' 96 | devices = LEDENET.discover_devices 97 | => [#] 98 | devices.first.ip 99 | => "10.133.8.113" 100 | ``` 101 | 102 | By deafult, `discover_devices` waits for up to 5 seconds for a single device to respond, and returns immediately after finding one. To change this behavior: 103 | 104 | ```ruby 105 | irb(main):005:0> LEDENET.discover_devices(expected_devices: 2, timeout: 1) 106 | => [#] 107 | ``` 108 | 109 | #### API 110 | 111 | To construct an API class, use the following: 112 | 113 | ```ruby 114 | api = LEDENET::Api.new('10.133.8.113') 115 | ``` 116 | 117 | By default, each API call will open a new connection, and close it when it's finished. This is convenient if the API is being used inside of a long-running process (like a web server). If what you're doing is more short-lived, you can reuse the same connection: 118 | 119 | ```ruby 120 | api = LEDENET::Api.new('10.133.8.113', reuse_connection: true) 121 | ``` 122 | 123 | By default, the API will re-try transient-looking failures three times. You can change this behavior with: 124 | 125 | ```ruby 126 | api = LEDENET::Api.new('10.133.8.113', reuse_connection: true, max_retries: 0) 127 | ``` 128 | 129 | #### Status 130 | 131 | To check if the controller is currently on: 132 | 133 | ```ruby 134 | api.on? 135 | => false 136 | ``` 137 | 138 | To turn the controller on and off: 139 | 140 | ```ruby 141 | api.on 142 | api.off 143 | ``` 144 | 145 | #### Color / Warm White 146 | 147 | To get the current color settings: 148 | 149 | ```ruby 150 | api.current_color_data 151 | #=> {:red=>255, :green=>255, :blue=>255, :warm_white=>255} 152 | api.current_rgb 153 | #=> [255, 255, 255] 154 | api.current_warm_white 155 | #=> 255 156 | ``` 157 | 158 | To set the color: 159 | 160 | ```ruby 161 | api.update_rgb(255, 0, 255) 162 | 163 | api.update_warm_white(100) 164 | ``` 165 | 166 | You can also update individual parameters: 167 | 168 | ```ruby 169 | api.update_color_data(red: 100) 170 | 171 | api.update_color_data(blue: 255, warm_white: 0) 172 | ``` 173 | 174 | #### Functions 175 | 176 | The UFO devices ship with 20 pre-programmed lighting functions. ledenet_api has support for these: 177 | 178 | ```ruby 179 | LEDENET::Functions.all_functions 180 | #=> [:SEVEN_COLOR_CROSS_FADE, :RED_GRADUAL_CHANGE, :GREEN_GRADUAL_CHANGE, :BLUE_GRADUAL_CHANGE, :YELLOW_GRADUAL_CHANGE, :CYAN_GRADUAL_CHANGE, :PURPLE_GRADUAL_CHANGE, :WHITE_GRADUAL_CHANGE, :RED_GREEN_CROSS_FADE, :RED_BLUE_CROSS_FADE, :SEVEN_COLOR_STROBE_FLASH, :RED_STROBE_FLASH, :GREEN_STROBE_FLASH, :BLUE_STROBE_FLASH, :YELLOW_STROBE_FLASH, :CYAN_STROBE_FLASH, :PURPLE_STROBE_FLASH, :WHITE_STROBE_FLASH, :SEVEN_COLOR_JUMPING_CHANGE, :GREEN_BLUE_CROSS_FADE] 181 | ``` 182 | 183 | ```ruby 184 | api.update_function(:seven_color_cross_fade) 185 | api.update_function_speed(100) #very fast 186 | 187 | api.update_function_data( 188 | function_id: LEDENET::Functions::BLUE_GREEN_CROSS_FADE, 189 | speed: 50 190 | ) 191 | ``` 192 | 193 | To quit the function and return to a constant color, simply update a color value: 194 | 195 | ```ruby 196 | api.update_color_data(warm_white: 255) 197 | ``` 198 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/ledenet-ufo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'optparse' 4 | require 'json' 5 | 6 | require 'ledenet_api' 7 | require 'ipaddress' 8 | 9 | options = {} 10 | 11 | banner = <<-BANNER 12 | Usage: ledenet-ufo --list 13 | OR: ledenet-ufo [IP|HW ADDR] [OPTIONS] 14 | 15 | BANNER 16 | 17 | if ARGV.count > 0 && !ARGV.first.start_with?('-') 18 | arg = ARGV.shift 19 | 20 | if IPAddress.valid?(arg) 21 | options[:ip] = arg 22 | elsif /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/i.match(arg) 23 | options[:hw_addr] = arg.gsub(':', '').upcase 24 | else 25 | raise "Invalid device speicifier \"#{arg}\". Must be ip or mac address." 26 | end 27 | end 28 | 29 | opts = OptionParser.new do |opts| 30 | opts.banner = banner 31 | 32 | opts.on("-r", "--red [VALUE]", Integer, "Set red to VALUE") do |v| 33 | options[:red] = v 34 | end 35 | 36 | opts.on("-g", "--green [VALUE]", Integer, "Set green to VALUE") do |v| 37 | options[:green] = v 38 | end 39 | 40 | opts.on("-b", "--blue [VALUE]", Integer, "Set blue to VALUE") do |v| 41 | options[:blue] = v 42 | end 43 | 44 | opts.on("-w", "--warm-white [VALUE]", Integer, "Set warm white to VALUE") do |v| 45 | options[:warm_white] = v 46 | end 47 | 48 | opts.on("--on", "Turn on the controller") do |v| 49 | options[:on?] = true 50 | end 51 | 52 | opts.on("--off", "Turn off the controller") do 53 | options[:on?] = false 54 | end 55 | 56 | opts.on("-l", "--list", "Prints a list of available devices and exits") do |v| 57 | options[:list] = true 58 | end 59 | 60 | opts.on("-s", "--status", "Prints status as JSON") do |v| 61 | options[:print_status?] = true 62 | end 63 | 64 | opts.on("-h", "--help", "Prints this help message") do 65 | options[:print_help?] = true 66 | end 67 | 68 | opts.on("--function-id [VALUE]", Integer, "Set function id to VALUE") do |v| 69 | options[:function] = v 70 | end 71 | 72 | opts.on("-f", "--function [VALUE]", "Set function to VALUE.") do |v| 73 | v.upcase! 74 | 75 | if !LEDENET::Functions.const_defined?(v) 76 | warn "Unknown function type: #{v}. Valid function types: \n\t" << 77 | "#{LEDENET::Functions.all_functions.join("\n\t")}" 78 | exit 1 79 | end 80 | 81 | options[:function] = LEDENET::Functions.const_get(v) 82 | end 83 | 84 | opts.on("-p", "--speed [VALUE]", Integer, "Set function speed to VALUE") do |v| 85 | options[:speed] = v 86 | end 87 | end 88 | 89 | opts.parse! 90 | 91 | if options[:print_help?] || options.empty? 92 | puts opts 93 | exit 0 94 | end 95 | 96 | if options[:list] && options.count > 1 97 | warn "--list is incompatible with other options!\n\n" 98 | warn opts 99 | exit 1 100 | end 101 | 102 | begin 103 | if options[:list] 104 | devices = LEDENET.discover_devices(expected_devices: 1000) 105 | 106 | row_format = "%16s %18s %20s\n" 107 | printf row_format, "IP ADDRESS", "HW ADDRESS", "Model #" 108 | 109 | devices.each do |device| 110 | formatted_hwaddr = (0...12).step(2) 111 | .map { |x| device.hw_addr[x, 2] } 112 | .join(':') 113 | .downcase 114 | 115 | printf row_format, device.ip, formatted_hwaddr, device.model 116 | end 117 | else 118 | ip = nil 119 | if options[:ip] 120 | ip = options[:ip] 121 | elsif options[:hw_addr] 122 | r = LEDENET.discover_devices(expected_hw_addrs: [options[:hw_addr]]) 123 | r.select! { |x| x.hw_addr == options[:hw_addr] } 124 | 125 | if r.empty? 126 | raise "Couldn't find device with mac addr: #{options[:hw_addr]}" 127 | end 128 | ip = r.first.ip 129 | else 130 | ip = LEDENET.discover_devices.first.ip 131 | end 132 | 133 | api = LEDENET::Api.new(ip) 134 | 135 | color_params = options.select do |k,_| 136 | %w{red green blue warm_white}.include?(k.to_s) 137 | end 138 | 139 | if !options[:on?].nil? 140 | api.set_power(options[:on?]) 141 | end 142 | 143 | if color_params.any? 144 | api.update_color_data(color_params) 145 | end 146 | 147 | if options[:function] 148 | api.update_function(options[:function]) 149 | end 150 | 151 | if options[:speed] 152 | api.update_function_speed(options[:speed]) 153 | end 154 | 155 | if options[:print_status?] 156 | puts api.status.to_json 157 | end 158 | end 159 | rescue Exception => e 160 | puts "Error: #{e}" 161 | puts e.backtrace.join("\n") 162 | end 163 | -------------------------------------------------------------------------------- /ledenet_api.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | $:.push File.expand_path('../lib', __FILE__) 3 | 4 | require "ledenet/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "ledenet_api" 8 | spec.version = LEDENET::VERSION 9 | spec.authors = ["Christopher Mullins"] 10 | spec.email = ["chris@sidoh.org"] 11 | 12 | spec.summary = %q{An API for the LEDENET Magic UFO LED WiFi Controller} 13 | spec.homepage = "http://www.github.com/sidoh/ledenet_api" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "bin" 18 | spec.executables = ["ledenet-ufo"] 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency 'bindata', '>= 2.3' 22 | spec.add_dependency 'ipaddress', '>= 0.8' 23 | 24 | spec.add_development_dependency "bundler", "~> 1.10" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | spec.add_development_dependency "rspec" 27 | end 28 | -------------------------------------------------------------------------------- /lib/ledenet/api.rb: -------------------------------------------------------------------------------- 1 | require 'ledenet/packets/set_power_request' 2 | require 'ledenet/packets/status_request' 3 | require 'ledenet/packets/update_color_request' 4 | require 'ledenet/packets/set_function_request' 5 | 6 | module LEDENET 7 | class Api 8 | DEFAULT_OPTIONS = { 9 | reuse_connection: false, 10 | max_retries: 3, 11 | port: 5577 12 | } 13 | 14 | def initialize(device_address, options = {}) 15 | @device_address = device_address 16 | @options = DEFAULT_OPTIONS.merge(options) 17 | end 18 | 19 | def status 20 | response = request_status 21 | status = { is_on: on?(response) } 22 | status.merge!(current_color_data(response)) 23 | status.merge!(current_function_data(response)) 24 | end 25 | 26 | def on 27 | send_packet(LEDENET::Packets::SetPowerRequest.on_request) 28 | end 29 | 30 | def off 31 | send_packet(LEDENET::Packets::SetPowerRequest.off_request) 32 | end 33 | 34 | def set_power(v) 35 | v ? on : off 36 | end 37 | 38 | def on?(response = request_status) 39 | response.on? 40 | end 41 | 42 | def update_rgb(r, g, b) 43 | update_color_data(red: r, green: g, blue: b) 44 | end 45 | 46 | def update_warm_white(warm_white) 47 | update_color_data(warm_white: warm_white) 48 | end 49 | 50 | def update_color_data(o) 51 | updated_data = current_color_data.merge(o) 52 | send_packet(LEDENET::Packets::UpdateColorRequest.new(updated_data)) 53 | end 54 | 55 | def current_rgb 56 | current_color_data.values_at(:red, :green, :blue) 57 | end 58 | 59 | def current_warm_white 60 | current_color_data[:warm_white] 61 | end 62 | 63 | def current_color_data(response = request_status) 64 | select_status_keys(response, *%w{red green blue warm_white}) 65 | end 66 | 67 | def update_function(fn) 68 | if fn.is_a?(String) or fn.is_a?(Symbol) 69 | fn = LEDENET::Functions.const_get(fn.upcase) 70 | end 71 | update_function_data(function_id: fn) 72 | end 73 | 74 | def update_function_speed(s) 75 | update_function_data(speed: s) 76 | end 77 | 78 | def update_function_data(o) 79 | o = {}.merge(o) 80 | current_data = current_function_data 81 | updated_data = { 82 | function_id: current_data[:function_id], 83 | speed: current_data[:speed_packet_value] 84 | } 85 | 86 | if o[:speed] 87 | speed = LEDENET::FunctionSpeed.from_value(o.delete(:speed)) 88 | updated_data[:speed] = speed.packet_value 89 | end 90 | updated_data.merge!(o) 91 | 92 | send_packet(LEDENET::Packets::SetFunctionRequest.new(updated_data)) 93 | end 94 | 95 | def current_function_data(response = request_status) 96 | raw_function_data = select_status_keys(response, *%w{mode speed}) 97 | function_data = { 98 | running_function?: raw_function_data[:mode] != LEDENET::Functions::NO_FUNCTION, 99 | speed: FunctionSpeed.from_packet_value(raw_function_data[:speed]).value, 100 | speed_packet_value: raw_function_data[:speed], 101 | function_name: LEDENET::Functions.value_of(raw_function_data[:mode]), 102 | function_id: raw_function_data[:mode] 103 | } 104 | end 105 | 106 | def reconnect! 107 | create_socket 108 | true 109 | end 110 | 111 | def send_packet(packet) 112 | socket_action do 113 | @socket.write(packet.to_binary_s) 114 | 115 | if packet.response_reader != LEDENET::Packets::EmptyResponse 116 | packet.response_reader.read(@socket) 117 | end 118 | end 119 | end 120 | 121 | private 122 | def select_status_keys(status_response, *keys) 123 | color_data = keys.map do |x| 124 | [x.to_sym, status_response.send(x)] 125 | end 126 | Hash[color_data] 127 | end 128 | 129 | def request_status 130 | send_packet(LEDENET::Packets::StatusRequest.new) 131 | end 132 | 133 | def create_socket 134 | @socket.close unless @socket.nil? or @socket.closed? 135 | @socket = TCPSocket.new(@device_address, @options[:port]) 136 | end 137 | 138 | def socket_action 139 | tries = 0 140 | begin 141 | create_socket if @socket.nil? or @socket.closed? 142 | yield 143 | rescue Errno::EPIPE, IOError => e 144 | tries += 1 145 | 146 | if tries <= @options[:max_retries] 147 | reconnect! 148 | retry 149 | else 150 | raise e 151 | end 152 | ensure 153 | if !@socket.nil? && !@socket.closed? && !@options[:reuse_connection] 154 | @socket.close 155 | end 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/ledenet/device_discovery.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'timeout' 3 | require 'set' 4 | 5 | module LEDENET 6 | class Device 7 | attr_reader :ip, :hw_addr, :model 8 | 9 | def initialize(device_str) 10 | parts = device_str.split(',') 11 | 12 | @ip = parts[0] 13 | @hw_addr = parts[1] 14 | @model = parts[2] 15 | end 16 | end 17 | 18 | DEFAULT_OPTIONS = { 19 | expected_devices: 1, 20 | timeout: 5, 21 | expected_models: [], 22 | expected_hw_addrs: [], 23 | udp_port: 48899 24 | } 25 | 26 | # The WiFi controllers these things appear to use support a discovery protocol 27 | # roughly outlined here: http://www.usriot.com/Faq/49.html 28 | # 29 | # A "password" is sent over broadcast port 48899. We can respect replies 30 | # containing IP address, hardware address, and model number. The model number 31 | # appears to correspond to the WiFi controller, and not the LED controller 32 | # itself. 33 | def self.discover_devices(options = {}) 34 | options = DEFAULT_OPTIONS.merge(options) 35 | 36 | send_addr = ['', options[:udp_port]] 37 | send_socket = UDPSocket.new 38 | send_socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true) 39 | send_socket.send('HF-A11ASSISTHREAD', 0, send_addr[0], send_addr[1]) 40 | 41 | discovered_devices = [] 42 | discovered_models = Set.new 43 | discovered_hw_addrs = Set.new 44 | expected_models = Set.new(options[:expected_models]) 45 | expected_hw_addrs = Set.new( 46 | options[:expected_hw_addrs].map { |x| x.gsub(':', '').upcase } 47 | ) 48 | 49 | begin 50 | Timeout::timeout(options[:timeout]) do 51 | while discovered_devices.count < options[:expected_devices] || 52 | !expected_models.subset?(discovered_models) || 53 | !expected_hw_addrs.subset?(discovered_hw_addrs) 54 | data = send_socket.recv(1024) 55 | 56 | device = LEDENET::Device.new(data) 57 | 58 | if device.ip and device.hw_addr and device.model 59 | discovered_devices.push(device) 60 | discovered_models.add(device.model) 61 | discovered_hw_addrs.add(device.hw_addr) 62 | end 63 | end 64 | end 65 | rescue Timeout::Error 66 | # Expected 67 | ensure 68 | send_socket.close 69 | end 70 | 71 | discovered_devices 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/ledenet/function_speed.rb: -------------------------------------------------------------------------------- 1 | module LEDENET 2 | class FunctionSpeed 3 | # Speed range exposed through API 4 | INTERFACE_SPEED_RANGE = (1..100) 5 | 6 | # Speed value is in [0x01, 0x1F], with 0x00 being the fastest. 7 | PACKET_SPEED_RANGE = (0x01..0x1F) 8 | 9 | attr_reader :value 10 | 11 | def initialize(value) 12 | @value = value 13 | end 14 | 15 | def packet_value 16 | FunctionSpeed.convert_range(value, INTERFACE_SPEED_RANGE, PACKET_SPEED_RANGE) 17 | end 18 | 19 | def self.from_value(value) 20 | FunctionSpeed.new(value) 21 | end 22 | 23 | def self.from_packet_value(value) 24 | v = FunctionSpeed.convert_range(value, PACKET_SPEED_RANGE, INTERFACE_SPEED_RANGE) 25 | FunctionSpeed.new(v) 26 | end 27 | 28 | private 29 | def self.convert_range(value, from, to) 30 | scaled_speed = (value / (from.max.to_f / to.max)).round 31 | scaled_speed = [to.min, scaled_speed].max 32 | scaled_speed = [to.max, scaled_speed].min 33 | to.max - scaled_speed 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ledenet/functions.rb: -------------------------------------------------------------------------------- 1 | module LEDENET 2 | module Functions 3 | VALUES = [ 4 | SEVEN_COLOR_CROSS_FADE = 0x25, 5 | RED_GRADUAL_CHANGE = 0x26, 6 | GREEN_GRADUAL_CHANGE = 0x27, 7 | BLUE_GRADUAL_CHANGE = 0x28, 8 | YELLOW_GRADUAL_CHANGE = 0x29, 9 | CYAN_GRADUAL_CHANGE = 0x2A, 10 | PURPLE_GRADUAL_CHANGE = 0x2B, 11 | WHITE_GRADUAL_CHANGE = 0x2C, 12 | RED_GREEN_CROSS_FADE = 0x2D, 13 | RED_BLUE_CROSS_FADE = 0x2E, 14 | GREEN_BLUE_CROSS_FADE = 0x2F, 15 | SEVEN_COLOR_STROBE_FLASH = 0x30, 16 | RED_STROBE_FLASH = 0x31, 17 | GREEN_STROBE_FLASH = 0x32, 18 | BLUE_STROBE_FLASH = 0x33, 19 | YELLOW_STROBE_FLASH = 0x34, 20 | CYAN_STROBE_FLASH = 0x35, 21 | PURPLE_STROBE_FLASH = 0x36, 22 | WHITE_STROBE_FLASH = 0x37, 23 | SEVEN_COLOR_JUMPING_CHANGE = 0x38, 24 | NO_FUNCTION = 0x61 25 | ] 26 | 27 | def self.all_functions 28 | LEDENET::Functions.constants.reject { |x| x == :VALUES } 29 | end 30 | 31 | def self.value_of(i) 32 | all_functions.select { |x| self.const_get(x) == i }.first 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/ledenet/packets/empty_response.rb: -------------------------------------------------------------------------------- 1 | require 'bindata' 2 | 3 | module LEDENET::Packets 4 | class EmptyResponse ; end 5 | end 6 | -------------------------------------------------------------------------------- /lib/ledenet/packets/fields/checksum.rb: -------------------------------------------------------------------------------- 1 | require 'bindata' 2 | 3 | module LEDENET 4 | class Checksum < BinData::Record 5 | mandatory_parameter :packet_data 6 | 7 | uint8 :checksum, :value => ->() { packet_data.values.reduce(&:+) % 0x100 } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/ledenet/packets/set_function_request.rb: -------------------------------------------------------------------------------- 1 | require 'bindata' 2 | 3 | require 'ledenet/packets/fields/checksum' 4 | require 'ledenet/packets/empty_response' 5 | 6 | module LEDENET::Packets 7 | class SetFunctionRequest < BinData::Record 8 | hide :checksum 9 | 10 | uint8 :packet_id, value: 0x61 11 | uint8 :function_id 12 | uint8 :speed 13 | uint8 :remote_or_local, value: 0x0F 14 | checksum :checksum, packet_data: ->() { snapshot } 15 | 16 | def response_reader 17 | LEDENET::Packets::EmptyResponse 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/ledenet/packets/set_power_request.rb: -------------------------------------------------------------------------------- 1 | require 'bindata' 2 | 3 | require 'ledenet/packets/fields/checksum' 4 | require 'ledenet/packets/empty_response' 5 | 6 | module LEDENET::Packets 7 | class SetPowerRequest < BinData::Record 8 | mandatory_parameter :on? 9 | hide :checksum 10 | 11 | uint8 :packet_id, value: 0x71 12 | uint8 :power_status, value: ->() { on? ? 0x23 : 0x24 } 13 | uint8 :remote_or_local, value: 0x0F 14 | 15 | checksum :checksum, packet_data: ->() { snapshot } 16 | 17 | def response_reader 18 | LEDENET::Packets::EmptyResponse 19 | end 20 | 21 | def self.on_request 22 | SetPowerRequest.new(on?: true) 23 | end 24 | 25 | def self.off_request 26 | SetPowerRequest.new(on?: false) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/ledenet/packets/status_request.rb: -------------------------------------------------------------------------------- 1 | require 'bindata' 2 | 3 | require 'ledenet/packets/fields/checksum' 4 | require 'ledenet/packets/status_response' 5 | 6 | module LEDENET::Packets 7 | class StatusRequest < BinData::Record 8 | hide :checksum 9 | 10 | uint8 :packet_id, value: 0x81 11 | uint8 :payload1, value: 0x8A 12 | uint8 :payload2, value: 0x8B 13 | 14 | checksum :checksum, packet_data: ->() { snapshot } 15 | 16 | def response_reader 17 | LEDENET::Packets::StatusResponse 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/ledenet/packets/status_response.rb: -------------------------------------------------------------------------------- 1 | require 'bindata' 2 | 3 | require 'ledenet/packets/fields/checksum' 4 | 5 | module LEDENET::Packets 6 | class StatusResponse < BinData::Record 7 | hide :checksum 8 | 9 | uint8 :packet_id, value: 0x81 10 | uint8 :device_name 11 | uint8 :power_status 12 | 13 | # I'm not sure these are the correct field labels. Basing it off of some 14 | # documentation that looks like it's for a slightly different protocol. 15 | uint8 :mode 16 | uint8 :run_status 17 | uint8 :speed 18 | 19 | uint8 :red 20 | uint8 :green 21 | uint8 :blue 22 | uint8 :warm_white 23 | 24 | uint24be :unused_payload 25 | uint8 :checksum 26 | 27 | def on? 28 | (power_status & 0x01) == 0x01 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/ledenet/packets/update_color_request.rb: -------------------------------------------------------------------------------- 1 | require 'bindata' 2 | 3 | require 'ledenet/packets/fields/checksum' 4 | require 'ledenet/packets/empty_response' 5 | 6 | module LEDENET::Packets 7 | class UpdateColorRequest < BinData::Record 8 | hide :checksum 9 | 10 | uint8 :packet_id, value: 0x31 11 | 12 | uint8 :red 13 | uint8 :green 14 | uint8 :blue 15 | uint8 :warm_white 16 | 17 | uint8 :unused_payload, value: 0 18 | 19 | # Not clear to me what difference this makes. Remote = 0xF0, Local = 0x0F 20 | uint8 :remote_or_local, value: 0x0F 21 | 22 | checksum :checksum, packet_data: ->() { snapshot } 23 | 24 | def response_reader 25 | LEDENET::Packets::EmptyResponse 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/ledenet/version.rb: -------------------------------------------------------------------------------- 1 | module LEDENET 2 | VERSION = '1.4.2' 3 | end 4 | -------------------------------------------------------------------------------- /lib/ledenet_api.rb: -------------------------------------------------------------------------------- 1 | require 'ledenet/version' 2 | require 'ledenet/device_discovery' 3 | require 'ledenet/api' 4 | require 'ledenet/functions' 5 | require 'ledenet/function_speed' 6 | --------------------------------------------------------------------------------