rubydeako 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6d6be18ddcdcb42020b985fd5af245092b6872e0eb5be863b4c4d56f4fa04a79
4
+ data.tar.gz: d371706c18a9f3e7bc861247e41a565edf247341f3b3f2c7fc6a0b779a649304
5
+ SHA512:
6
+ metadata.gz: 75d835d118b5fb7e7274acb20b8b5bf7f3c1bd94ee5b485d6e284c7dbdc16c7610f6e6035b9a3a249c536e03c23fb1dc53e3bf8a26bd697c6b7a1a0ceabf1b5a
7
+ data.tar.gz: 04136d2da692048d5c2f91753b914f68094cc24345d65cc61f4d6ba4bf5dcd50426efb11167452e112ce4a0bef5ac318369a2827bd25f22b659a8604c5e90120
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-07-02
9
+
10
+ ### Added
11
+ - Initial implementation of rubydeako library
12
+ - Device discovery via mDNS/Zeroconf
13
+ - Socket-based communication with Deako switches
14
+ - Device control (power on/off, dimming)
15
+ - Real-time state updates and callbacks
16
+ - Automatic connection management with heartbeat
17
+ - Thread-safe operation
18
+ - Complete test suite
19
+ - Comprehensive documentation
20
+
21
+ ### Features
22
+ - Compatible with Deako smart switches
23
+ - Ruby port of the Python pydeako library
24
+ - JSON-based protocol communication
25
+ - Support for dimmable and non-dimmable switches
26
+ - Automatic device discovery on local network
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Aaron Storrer
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # Rubydeako
2
+
3
+ A Ruby library for interacting with Deako smart switches via local network discovery and control. This is a port of the Python `pydeako` library.
4
+
5
+ ## Features
6
+
7
+ - Device discovery via mDNS/Zeroconf
8
+ - Real-time device state updates
9
+ - Control power and dimming for compatible devices
10
+ - Thread-safe socket communication
11
+ - Automatic connection management with heartbeat monitoring
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'rubydeako'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ $ bundle install
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install rubydeako
28
+
29
+ ## Usage
30
+
31
+ ### Basic Example
32
+
33
+ ```ruby
34
+ require 'rubydeako'
35
+
36
+ # Create a discoverer to find Deako devices on the network
37
+ discoverer = Rubydeako::Discover::DeakoDiscoverer.new
38
+
39
+ # Create the main client
40
+ deako = Rubydeako::Deako.new(
41
+ discoverer.method(:get_address),
42
+ client_name: "MyRubyApp"
43
+ )
44
+
45
+ begin
46
+ # Connect to a device
47
+ deako.connect
48
+
49
+ # Discover all devices
50
+ deako.find_devices
51
+
52
+ # List discovered devices
53
+ deako.devices.each do |uuid, device|
54
+ puts "Device: #{device['name']} (#{uuid})"
55
+ puts " Power: #{device['state']['power']}"
56
+ puts " Dimmable: #{device['dimmable']}"
57
+ end
58
+
59
+ # Control a device
60
+ if deako.devices.any?
61
+ first_uuid = deako.devices.keys.first
62
+
63
+ # Turn on
64
+ deako.control_device(first_uuid, true)
65
+
66
+ # Dim to 50% (if dimmable)
67
+ if deako.dimmable?(first_uuid)
68
+ deako.control_device(first_uuid, true, dim: 50)
69
+ end
70
+
71
+ # Turn off
72
+ deako.control_device(first_uuid, false)
73
+ end
74
+
75
+ ensure
76
+ deako.disconnect
77
+ discoverer.stop
78
+ end
79
+ ```
80
+
81
+ ### Device State Callbacks
82
+
83
+ ```ruby
84
+ # Set up a callback for state changes
85
+ deako.set_state_callback(uuid) do
86
+ puts "Device #{uuid} state changed!"
87
+ state = deako.get_state(uuid)
88
+ puts "Power: #{state['power']}, Dim: #{state['dim']}"
89
+ end
90
+ ```
91
+
92
+ ## API Reference
93
+
94
+ ### Main Classes
95
+
96
+ #### `Rubydeako::Deako`
97
+
98
+ The main client class for controlling Deako devices.
99
+
100
+ **Methods:**
101
+ - `connect` - Establish connection to devices
102
+ - `disconnect` - Close all connections
103
+ - `find_devices(timeout: 10)` - Discover devices on network
104
+ - `control_device(uuid, power, dim: nil)` - Control device state
105
+ - `get_name(uuid)` - Get device name
106
+ - `get_state(uuid)` - Get device current state
107
+ - `dimmable?(uuid)` - Check if device supports dimming
108
+ - `set_state_callback(uuid, callback)` - Set state change callback
109
+
110
+ #### `Rubydeako::Discover::DeakoDiscoverer`
111
+
112
+ Handles mDNS discovery of Deako devices.
113
+
114
+ **Methods:**
115
+ - `start` - Begin device discovery
116
+ - `stop` - Stop discovery
117
+ - `get_address` - Get discovered device address
118
+
119
+ ## Dependencies
120
+
121
+ - `dnssd` - mDNS/Zeroconf service discovery
122
+ - `async` - Asynchronous I/O operations
123
+ - `concurrent-ruby` - Thread-safe collections
124
+
125
+ ## Development
126
+
127
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.
128
+
129
+ ## Contributing
130
+
131
+ Bug reports and pull requests are welcome on GitHub.
132
+
133
+ ## License
134
+
135
+ The gem is available as open source under the [MIT License](https://opensource.org/licenses/MIT).
136
+
137
+ ## Acknowledgments
138
+
139
+ This library is a Ruby port of the Python [pydeako](https://github.com/DeakoLights/pydeako) library.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require_relative 'manager'
5
+ require_relative 'models/constants'
6
+
7
+ module Rubydeako
8
+ class FindDevicesError < StandardError
9
+ attr_reader :reason
10
+
11
+ def initialize(reason = 'unknown')
12
+ @reason = reason
13
+ super("Failed to find devices: #{reason}")
14
+ end
15
+ end
16
+
17
+ class Deako
18
+ DEVICE_FOUND_TIME_FACTOR_S = 2
19
+ DEFAULT_DEVICE_LIST_TIMEOUT_S = 10
20
+ DEVICE_LIST_POLLING_INTERVAL_S = 1
21
+ DEVICE_FOUND_POLLING_INTERVAL_S = 1
22
+ CAPABILITY_DIMMABLE = 'dim'
23
+
24
+ attr_reader :devices, :expected_devices
25
+
26
+ def initialize(get_address_proc, client_name: nil)
27
+ @connection_manager = Manager.new(
28
+ get_address_proc,
29
+ method(:incoming_json),
30
+ client_name: client_name
31
+ )
32
+ @devices = {}
33
+ @expected_devices = 0
34
+ @logger = Logger.new($stdout)
35
+ @logger.level = Logger::DEBUG
36
+ end
37
+
38
+ def connect
39
+ @connection_manager.init_connection
40
+ end
41
+
42
+ def disconnect
43
+ @connection_manager.close
44
+ end
45
+
46
+ def find_devices(timeout: DEFAULT_DEVICE_LIST_TIMEOUT_S)
47
+ @logger.info('Finding devices')
48
+ success = @connection_manager.send_get_device_list
49
+
50
+ raise FindDevicesError, 'Failed to send device list request' unless success
51
+
52
+ remaining = timeout
53
+ # Wait for device list
54
+ while @expected_devices == 0 && remaining > 0
55
+ @logger.debug("waiting for device list... time remaining: #{remaining}s")
56
+ sleep(DEVICE_LIST_POLLING_INTERVAL_S)
57
+ remaining -= DEVICE_LIST_POLLING_INTERVAL_S
58
+ end
59
+
60
+ # If we get a response, expected_devices will be at least 1
61
+ raise FindDevicesError, 'Timed out waiting for device list response' if @expected_devices == 0
62
+
63
+ remaining = @expected_devices * DEVICE_FOUND_TIME_FACTOR_S
64
+ while @devices.length != @expected_devices && remaining > 0
65
+ @logger.debug(
66
+ "waiting for devices... expected: #{@expected_devices}, " \
67
+ "received: #{@devices.length}, time remaining: #{remaining}s"
68
+ )
69
+ sleep(DEVICE_FOUND_POLLING_INTERVAL_S)
70
+ remaining -= DEVICE_FOUND_POLLING_INTERVAL_S
71
+ end
72
+
73
+ @logger.debug("found #{@devices.length} devices")
74
+
75
+ return unless @devices.length != @expected_devices && remaining == 0
76
+
77
+ raise FindDevicesError,
78
+ 'Timed out waiting for devices to be found. Expected ' \
79
+ "#{@expected_devices} devices but only found #{@devices.length}"
80
+ end
81
+
82
+ def control_device(uuid, power, dim: nil)
83
+ completed_callback = proc { update_state(uuid, power, dim) }
84
+ @connection_manager.send_state_change(
85
+ uuid, power, dim: dim, completed_callback: completed_callback
86
+ )
87
+ end
88
+
89
+ def get_name(uuid)
90
+ device_data = @devices[uuid]
91
+ return nil if device_data.nil?
92
+
93
+ device_data['name']
94
+ end
95
+
96
+ def get_state(uuid)
97
+ device_data = @devices[uuid]
98
+ return nil if device_data.nil?
99
+
100
+ device_data['state']
101
+ end
102
+
103
+ def dimmable?(uuid)
104
+ device_data = @devices[uuid]
105
+ return nil if device_data.nil?
106
+
107
+ device_data['dimmable']
108
+ end
109
+
110
+ def set_state_callback(uuid, callback)
111
+ return unless @devices.key?(uuid)
112
+
113
+ @devices[uuid]['callback'] = callback
114
+ end
115
+
116
+ def update_state(uuid, power, dim = nil)
117
+ return unless @devices.key?(uuid)
118
+
119
+ @devices[uuid]['state']['power'] = power
120
+ # Dimmables don't always send dim
121
+ @devices[uuid]['state']['dim'] = dim || @devices[uuid]['state']['dim']
122
+
123
+ @devices[uuid]['callback']&.call
124
+ end
125
+
126
+ private
127
+
128
+ def incoming_json(in_data)
129
+ case in_data['type']
130
+ when Models::ResponseType::DEVICE_LIST
131
+ subdata = in_data['data']
132
+ @expected_devices = subdata['number_of_devices']
133
+
134
+ when Models::ResponseType::DEVICE_FOUND
135
+ subdata = in_data['data']
136
+ state = subdata['state']
137
+
138
+ dimmable = if subdata['capabilities']
139
+ subdata['capabilities'].include?(CAPABILITY_DIMMABLE)
140
+ else
141
+ # Support older local API versions
142
+ !state['dim'].nil?
143
+ end
144
+
145
+ record_device(
146
+ subdata['name'],
147
+ subdata['uuid'],
148
+ dimmable,
149
+ state['power'],
150
+ state['dim']
151
+ )
152
+
153
+ when Models::ResponseType::EVENT
154
+ subdata = in_data['data']
155
+ state = subdata['state']
156
+ update_state(subdata['target'], state['power'], state['dim'])
157
+ end
158
+ rescue StandardError => e
159
+ @logger.error("Failed to parse #{in_data}: #{e}")
160
+ end
161
+
162
+ def record_device(name, uuid, dimmable, power, dim = nil)
163
+ @devices[uuid] ||= { 'state' => {} }
164
+ @devices[uuid]['name'] = name
165
+ @devices[uuid]['uuid'] = uuid
166
+ @devices[uuid]['dimmable'] = dimmable
167
+ @devices[uuid]['state']['power'] = power
168
+ @devices[uuid]['state']['dim'] = dim
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+
5
+ module Rubydeako
6
+ module Discover
7
+ class EmptyAddressPool < StandardError; end
8
+
9
+ class AddressPool
10
+ def initialize
11
+ @addresses = Concurrent::Map.new
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def available_addresses
16
+ @addresses.size
17
+ end
18
+
19
+ def add_address(address, name)
20
+ @mutex.synchronize do
21
+ @addresses[address] = name
22
+ end
23
+ end
24
+
25
+ def get_address
26
+ @mutex.synchronize do
27
+ raise EmptyAddressPool if @addresses.empty?
28
+
29
+ # Get first key-value pair from Concurrent::Map
30
+ address = nil
31
+ name = nil
32
+ @addresses.each_pair do |addr, n|
33
+ address = addr
34
+ name = n
35
+ break
36
+ end
37
+
38
+ @addresses.delete(address)
39
+ [address, name]
40
+ end
41
+ end
42
+
43
+ def remove_address(address)
44
+ @mutex.synchronize do
45
+ @addresses.delete(address)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dnssd'
4
+ require 'socket'
5
+ require 'logger'
6
+ require_relative 'address_pool'
7
+
8
+ module Rubydeako
9
+ module Discover
10
+ DEAKO_TYPE = '_deako._tcp'
11
+ TIMEOUT_S = 10
12
+ POLLING_INTERVAL_S = 0.5
13
+
14
+ class DevicesNotFoundException < StandardError
15
+ def initialize(msg = "No devices found via #{DEAKO_TYPE}")
16
+ super(msg)
17
+ end
18
+ end
19
+
20
+ class DeakoDiscoverer
21
+ attr_reader :address_pool
22
+
23
+ def initialize
24
+ @address_pool = AddressPool.new
25
+ @logger = Logger.new($stdout)
26
+ @logger.level = Logger::DEBUG
27
+ @running = false
28
+ @browse_thread = nil
29
+ end
30
+
31
+ def start
32
+ return if @running
33
+
34
+ @running = true
35
+ @browse_thread = Thread.new { browse_services }
36
+ end
37
+
38
+ def stop
39
+ @running = false
40
+ @browse_thread&.join
41
+ end
42
+
43
+ def get_address
44
+ start unless @running
45
+
46
+ total_time = 0.0
47
+ while total_time < TIMEOUT_S && @address_pool.available_addresses < 1
48
+ sleep(POLLING_INTERVAL_S)
49
+ total_time += POLLING_INTERVAL_S
50
+ end
51
+
52
+ raise DevicesNotFoundException if @address_pool.available_addresses == 0
53
+
54
+ address, name = @address_pool.get_address
55
+ @logger.debug("Got address #{address}, with device name #{name}")
56
+ [address, name]
57
+ end
58
+
59
+ private
60
+
61
+ def browse_services
62
+ DNSSD.browse(DEAKO_TYPE) do |reply|
63
+ next unless @running
64
+
65
+ begin
66
+ resolve_service(reply.name, reply.type, reply.domain)
67
+ rescue StandardError => e
68
+ @logger.error("Error resolving service: #{e}")
69
+ end
70
+ end
71
+ rescue StandardError => e
72
+ @logger.error("Error browsing services: #{e}")
73
+ end
74
+
75
+ def resolve_service(name, type, domain)
76
+ DNSSD.resolve(name, type, domain) do |resolve_reply|
77
+ get_address_info(resolve_reply.target, resolve_reply.port, name)
78
+ end
79
+ end
80
+
81
+ def get_address_info(target, port, name)
82
+ addrinfo = Addrinfo.getaddrinfo(target, port, Socket::AF_INET, Socket::SOCK_STREAM)
83
+ addrinfo.each do |addr|
84
+ address = "#{addr.ip_address}:#{port}"
85
+ @address_pool.add_address(address, name)
86
+ @logger.debug("Add service with address #{address}, name #{name}")
87
+ end
88
+ rescue StandardError => e
89
+ @logger.error("Error getting address info for #{target}:#{port}: #{e}")
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require_relative 'utils/connection'
5
+ require_relative 'request'
6
+ require_relative 'models/request'
7
+ require_relative 'models/constants'
8
+ require_relative 'discover/discoverer'
9
+
10
+ module Rubydeako
11
+ class Manager
12
+ CONNECTED_POLLING_INTERVAL_S = 1
13
+ CONNECTION_TIMEOUT_S = 10
14
+ WORKER_WAIT_S = 0.5
15
+ PING_WORKER_WAIT_S = 10
16
+
17
+ attr_reader :connection
18
+
19
+ def initialize(get_address_proc, incoming_json_callback, client_name: nil)
20
+ @get_address_proc = get_address_proc
21
+ @incoming_json_callback = incoming_json_callback
22
+ @client_name = client_name
23
+ @connection = nil
24
+ @logger = Logger.new($stdout)
25
+ @logger.level = Logger::DEBUG
26
+ @connecting = false
27
+ @canceled = false
28
+ @pong_received = false
29
+ @maintain_thread = nil
30
+ @connection_thread = nil
31
+ end
32
+
33
+ def init_connection
34
+ return if @connecting
35
+
36
+ @connecting = true
37
+ @connection_thread = Thread.new do
38
+ address, name = @get_address_proc.call
39
+ connection = Utils::Connection.new(address, name, method(:incoming_json))
40
+ connection.start
41
+
42
+ timeout = 0
43
+ while !connection.connected? && timeout < CONNECTION_TIMEOUT_S
44
+ sleep(CONNECTED_POLLING_INTERVAL_S)
45
+ timeout += CONNECTED_POLLING_INTERVAL_S
46
+ end
47
+
48
+ if timeout >= CONNECTION_TIMEOUT_S
49
+ @logger.error('Timeout attempting to connect. Trying again')
50
+ connection.stop
51
+ @connecting = false
52
+ init_connection
53
+ return
54
+ end
55
+
56
+ @connection = connection
57
+ start_maintain_worker
58
+ @connecting = false
59
+ rescue Discover::DevicesNotFoundException
60
+ @logger.warn('No devices to connect to')
61
+ @connecting = false
62
+ sleep(5) # Wait before retrying
63
+ init_connection
64
+ rescue StandardError => e
65
+ @logger.error("Error during connection initialization: #{e}")
66
+ @connecting = false
67
+ end
68
+ end
69
+
70
+ def close
71
+ @logger.debug('Closing connection and canceling workers')
72
+ @canceled = true
73
+
74
+ @maintain_thread&.kill
75
+ @connection_thread&.kill
76
+ @connection&.stop
77
+ @connection = nil
78
+ end
79
+
80
+ def send_get_device_list
81
+ request = Request.new(
82
+ Models::Request.device_list_request(source: @client_name)
83
+ )
84
+ send_request(request)
85
+ end
86
+
87
+ def send_state_change(uuid, power, dim: nil, completed_callback: nil)
88
+ request = Request.new(
89
+ Models::Request.state_change_request(
90
+ device_uuid: uuid,
91
+ power: power,
92
+ dim: dim,
93
+ source: @client_name
94
+ ),
95
+ completed_callback: completed_callback
96
+ )
97
+ send_request(request)
98
+ end
99
+
100
+ def send_request(request)
101
+ if @connection&.connected?
102
+ @connection.send_data("#{request.body_str}\r\n")
103
+ request.complete_callback
104
+ true
105
+ else
106
+ @logger.warn('No connection to send data to')
107
+ false
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def start_maintain_worker
114
+ return if @maintain_thread&.alive?
115
+
116
+ @maintain_thread = Thread.new do
117
+ sleep(PING_WORKER_WAIT_S)
118
+
119
+ until @canceled
120
+ @pong_received = false
121
+ @logger.debug('Pinging for responsiveness')
122
+
123
+ ping_request = Request.new(
124
+ Models::Request.device_ping_request(source: @client_name)
125
+ )
126
+ send_request(ping_request)
127
+
128
+ sleep(PING_WORKER_WAIT_S)
129
+
130
+ if @pong_received
131
+ @logger.debug('Pong received')
132
+ else
133
+ @logger.warn('Never received pong! Dumping this connection')
134
+ close
135
+ init_connection
136
+ break
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ def incoming_json(json_data)
143
+ response_type = json_data['type']
144
+ if response_type == Models::ResponseType::PONG
145
+ @pong_received = true
146
+ else
147
+ @incoming_json_callback.call(json_data)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydeako
4
+ module Models
5
+ SOURCE = 'rubydeako_default'
6
+ DESTINATION = 'deako'
7
+
8
+ module RequestType
9
+ CONTROL = 'CONTROL'
10
+ DEVICE_LIST = 'DEVICE_LIST'
11
+ PING = 'PING'
12
+ end
13
+
14
+ module ResponseType
15
+ DEVICE_FOUND = 'DEVICE_FOUND'
16
+ DEVICE_LIST = 'DEVICE_LIST'
17
+ EVENT = 'EVENT'
18
+ PONG = 'PING'
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'constants'
5
+
6
+ module Rubydeako
7
+ module Models
8
+ module Request
9
+ module_function
10
+
11
+ def base_request(destination: nil, source: nil, transaction_id: nil)
12
+ {
13
+ 'transactionId' => transaction_id || SecureRandom.uuid,
14
+ 'dst' => destination || DESTINATION,
15
+ 'src' => source || SOURCE
16
+ }
17
+ end
18
+
19
+ def device_ping_request(**kwargs)
20
+ base_request(**kwargs).merge(
21
+ 'type' => RequestType::PING
22
+ )
23
+ end
24
+
25
+ def device_list_request(**kwargs)
26
+ base_request(**kwargs).merge(
27
+ 'type' => RequestType::DEVICE_LIST
28
+ )
29
+ end
30
+
31
+ def state_change_request(device_uuid:, power:, dim: nil, **kwargs)
32
+ state = { 'power' => power }
33
+ state['dim'] = dim unless dim.nil?
34
+
35
+ base_request(**kwargs).merge(
36
+ 'type' => RequestType::CONTROL,
37
+ 'data' => {
38
+ 'target' => device_uuid,
39
+ 'state' => state
40
+ }
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Rubydeako
6
+ class Request
7
+ attr_reader :body, :completed_callback
8
+
9
+ def initialize(body, completed_callback: nil)
10
+ @body = body
11
+ @completed_callback = completed_callback
12
+ end
13
+
14
+ def body_str
15
+ JSON.generate(@body)
16
+ end
17
+
18
+ def type
19
+ @body['type']
20
+ end
21
+
22
+ def complete_callback
23
+ @completed_callback&.call
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'logger'
5
+ require_relative 'socket'
6
+
7
+ module Rubydeako
8
+ module Utils
9
+ class UnknownStateException < StandardError; end
10
+
11
+ module ConnectionState
12
+ UNKNOWN = -1
13
+ NOT_STARTED = 0
14
+ CONNECTED = 1
15
+ ERROR = 2
16
+ CLOSED = 3
17
+ end
18
+
19
+ class Connection
20
+ attr_reader :address, :name, :state
21
+
22
+ def initialize(address, name, on_data_callback)
23
+ @address = address
24
+ @name = name
25
+ @on_data_callback = on_data_callback
26
+ @state = ConnectionState::NOT_STARTED
27
+ @message_buffer = ''
28
+ @socket = SocketConnection.new(address)
29
+ @logger = Logger.new($stdout)
30
+ @logger.level = Logger::DEBUG
31
+ @running = false
32
+ @thread = nil
33
+ end
34
+
35
+ def start
36
+ return if @running
37
+
38
+ @running = true
39
+ @thread = Thread.new { run }
40
+ end
41
+
42
+ def stop
43
+ @running = false
44
+ close
45
+ @thread&.join
46
+ end
47
+
48
+ def send_data(data_to_send)
49
+ @logger.debug("[#{@address}] Sending data: #{data_to_send}")
50
+ begin
51
+ @socket.send_bytes(data_to_send.encode('utf-8'))
52
+ rescue StandardError => e
53
+ @logger.error("Error sending data: #{e}")
54
+ @state = ConnectionState::ERROR
55
+ end
56
+ end
57
+
58
+ def connected?
59
+ @state == ConnectionState::CONNECTED
60
+ end
61
+
62
+ def format_name
63
+ "#{@name}@#{@address}"
64
+ end
65
+
66
+ private
67
+
68
+ def run
69
+ while @running
70
+ case @state
71
+ when ConnectionState::NOT_STARTED
72
+ begin
73
+ @socket.connect_socket
74
+ @state = ConnectionState::CONNECTED
75
+ @logger.info("Connected to Deako local integrations with #{format_name}")
76
+ rescue StandardError => e
77
+ @logger.error("Failed to connect #{format_name} because #{e}")
78
+ @state = ConnectionState::ERROR
79
+ end
80
+
81
+ when ConnectionState::CONNECTED
82
+ begin
83
+ read_socket
84
+ rescue StandardError => e
85
+ @logger.error("Failed to read socket #{format_name} because #{e}")
86
+ @state = ConnectionState::ERROR
87
+ end
88
+
89
+ when ConnectionState::ERROR
90
+ begin
91
+ close
92
+ @state = ConnectionState::CLOSED
93
+ rescue StandardError => e
94
+ @logger.error("Failed to close socket #{format_name} because #{e}")
95
+ @state = ConnectionState::CLOSED
96
+ end
97
+
98
+ when ConnectionState::CLOSED
99
+ break
100
+
101
+ else
102
+ error_msg = "Unknown state: #{@state}"
103
+ @logger.error(error_msg)
104
+ raise UnknownStateException, error_msg
105
+ end
106
+
107
+ sleep(0.1) # Small delay to prevent busy waiting
108
+ end
109
+ end
110
+
111
+ def read_socket
112
+ data = @socket.read_bytes
113
+ parse_data(data)
114
+ rescue StandardError => e
115
+ @logger.error("Error receiving data: #{e}")
116
+ @state = ConnectionState::ERROR
117
+ end
118
+
119
+ def parse_data(data)
120
+ raw_string = data.force_encoding('utf-8')
121
+ @logger.debug("[#{format_name}] Raw message received: #{raw_string}")
122
+
123
+ messages = raw_string.strip.split("\r\n")
124
+ messages.each do |message_str|
125
+ @message_buffer += message_str
126
+ begin
127
+ message_json = JSON.parse(@message_buffer)
128
+ @on_data_callback.call(message_json)
129
+ @message_buffer = ''
130
+ rescue JSON::ParserError
131
+ @logger.debug("Got partial message: #{@message_buffer}")
132
+ end
133
+ end
134
+ end
135
+
136
+ def close
137
+ @socket.close_socket
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'logger'
5
+
6
+ module Rubydeako
7
+ module Utils
8
+ class NoSocketException < StandardError; end
9
+
10
+ class SocketConnection
11
+ MAX_PACKET_SIZE_BYTES = 2048
12
+
13
+ attr_reader :address
14
+
15
+ def initialize(address)
16
+ @address = address
17
+ @socket = nil
18
+ @logger = Logger.new($stdout)
19
+ @logger.level = Logger::DEBUG
20
+ end
21
+
22
+ def connect_socket
23
+ @socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM)
24
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
25
+
26
+ @logger.info("Connecting to #{@address}")
27
+ host, port = @address.split(':')
28
+ @socket.connect(Socket.sockaddr_in(port.to_i, host))
29
+ end
30
+
31
+ def close_socket
32
+ return unless @socket
33
+
34
+ @socket.close
35
+ @socket = nil
36
+ end
37
+
38
+ def send_bytes(data)
39
+ raise NoSocketException, 'No socket to send data to' unless @socket
40
+
41
+ @socket.write(data)
42
+ end
43
+
44
+ def read_bytes
45
+ raise NoSocketException, 'No socket to read' unless @socket
46
+
47
+ @socket.read_nonblock(MAX_PACKET_SIZE_BYTES)
48
+ rescue IO::WaitReadable
49
+ IO.select([@socket])
50
+ retry
51
+ end
52
+
53
+ def connected?
54
+ !@socket.nil? && !@socket.closed?
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubydeako
4
+ VERSION = '0.1.0'
5
+ end
data/lib/rubydeako.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rubydeako/version'
4
+ require_relative 'rubydeako/models/constants'
5
+ require_relative 'rubydeako/models/request'
6
+ require_relative 'rubydeako/discover/discoverer'
7
+ require_relative 'rubydeako/discover/address_pool'
8
+ require_relative 'rubydeako/utils/connection'
9
+ require_relative 'rubydeako/utils/socket'
10
+ require_relative 'rubydeako/request'
11
+ require_relative 'rubydeako/manager'
12
+ require_relative 'rubydeako/deako'
13
+
14
+ module Rubydeako
15
+ class Error < StandardError; end
16
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubydeako
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aaron
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-07-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: async
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '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: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: async-io
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: concurrent-ruby
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: dnssd
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: json
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
83
+ description: A Ruby implementation of the pydeako library for controlling Deako smart
84
+ switches via local network discovery and control
85
+ email:
86
+ - aaron@storrer.net
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CHANGELOG.md
92
+ - LICENSE
93
+ - README.md
94
+ - lib/rubydeako.rb
95
+ - lib/rubydeako/deako.rb
96
+ - lib/rubydeako/discover/address_pool.rb
97
+ - lib/rubydeako/discover/discoverer.rb
98
+ - lib/rubydeako/manager.rb
99
+ - lib/rubydeako/models/constants.rb
100
+ - lib/rubydeako/models/request.rb
101
+ - lib/rubydeako/request.rb
102
+ - lib/rubydeako/utils/connection.rb
103
+ - lib/rubydeako/utils/socket.rb
104
+ - lib/rubydeako/version.rb
105
+ homepage: https://github.com/astorrer/rubydeako
106
+ licenses:
107
+ - MIT
108
+ metadata:
109
+ source_code_uri: https://github.com/astorrer/rubydeako
110
+ changelog_uri: https://github.com/astorrer/rubydeako/blob/main/CHANGELOG.md
111
+ documentation_uri: https://github.com/astorrer/rubydeako/blob/main/README.md
112
+ bug_tracker_uri: https://github.com/astorrer/rubydeako/issues
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: 3.0.0
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.5.22
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: Ruby library for interacting with Deako smart switches
132
+ test_files: []