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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/lib/rubydeako/deako.rb +171 -0
- data/lib/rubydeako/discover/address_pool.rb +50 -0
- data/lib/rubydeako/discover/discoverer.rb +93 -0
- data/lib/rubydeako/manager.rb +151 -0
- data/lib/rubydeako/models/constants.rb +21 -0
- data/lib/rubydeako/models/request.rb +45 -0
- data/lib/rubydeako/request.rb +26 -0
- data/lib/rubydeako/utils/connection.rb +141 -0
- data/lib/rubydeako/utils/socket.rb +58 -0
- data/lib/rubydeako/version.rb +5 -0
- data/lib/rubydeako.rb +16 -0
- metadata +132 -0
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
|
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: []
|