intesisbox 0.0.2

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1fd75a5326195c3ac8d0b6d7dc51730e23d6f3ce6c26a09b7e7b2bb2dad45369
4
+ data.tar.gz: 3c130bac7dbbca03827c9e8835daf58cb8fb54a5236ee0f6d7473af05060eb96
5
+ SHA512:
6
+ metadata.gz: 15aee2ce1fb63c3dd5ef737eb6bb270fc6388f275c1cd28c79ded5a3e0678d2aa62490113ef98ccf3456cd7e8d51659317ec3f10e7c9301434585d04901600cc
7
+ data.tar.gz: 7104c211dbec8652c6b84216f509c3dbdca9695648e671ca2c67a3e0e8d4909f384e202ceb632caa1a5f737a989b531a3ee46143da835343724043c0c5ced49b
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'intesisbox'
4
+ require 'homie-mqtt'
5
+
6
+ class MQTTBridge
7
+ def initialize(mqtt_uri, root_topic: nil)
8
+ @base_topic = "homie/intesisbox"
9
+
10
+ bridge, discovery_property, units_property = nil
11
+ @device = MQTT::Homie::Device.new("intesisbox", "IntesisBox", root_topic: root_topic, mqtt: mqtt_uri) do |topic, value|
12
+ if topic == discovery_property.topic
13
+ @device.mqtt.unsubscribe(topic)
14
+ discovery_property.set(value) unless @got_discovery
15
+ @got_discovery = true
16
+ elsif topic == units_property.topic
17
+ @device.mqtt.unsubscribe(topic)
18
+ units_property.set(value) unless @got_units
19
+ @got_units = true
20
+ end
21
+ end
22
+
23
+ @wmps = {}
24
+ @discovery = 300
25
+ @units = :C
26
+
27
+ bridge = nil
28
+ @device.node("bridge", "Bridge", "Bridge") do |node|
29
+ (bridge = node).property("discovery", "Auto-Discovery Interval", :integer, format: "0:86400", unit: "s") do |prop, value|
30
+ next unless value =~ /^\d+$/
31
+ value = value.to_i
32
+ next if value > 86400
33
+ old_value = @discovery
34
+ prop.value = value
35
+ @discovery = value
36
+ if value < old_value || old_value == 0
37
+ @discovery_thread.kill
38
+ start_discovery_thread
39
+ end
40
+ end.
41
+ property("units", "Units", :enum, format: "C,F") do |prop, value|
42
+ next unless %w{C F}.include?(value)
43
+ if prop.value != value
44
+ prop.value = value
45
+ @units = value
46
+ next if @wmps.empty?
47
+ @device.init do
48
+ @wmps.each do |(mac, wmp)|
49
+ node = @device.nodes[mac]
50
+ setptemp = node.properties['setptemp']
51
+ setptemp.value = convert_units(wmp.setptemp)
52
+ setptemp.unit = "º#{@units}"
53
+ setptemp.format = wmp.limits[:setptemp].map { |lim| convert_units(lim) }.join(":")
54
+ if wmp.ambtemp
55
+ ambtemp = node.properties['ambtemp']
56
+ ambtemp.value = convert_units(wmp.ambtemp)
57
+ ambtemp.unit = "º#{@units}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ discovery_property = bridge.properties['discovery']
65
+ units_property = bridge.properties['units']
66
+ @device.publish
67
+ # use MQTT itself as our state store
68
+ @device.mqtt.subscribe("#{discovery_property.topic}")
69
+ @device.mqtt.subscribe("#{units_property.topic}")
70
+
71
+ setup_discovery
72
+ start_discovery_thread
73
+
74
+ @device.join
75
+ end
76
+
77
+ def start_discovery_thread
78
+ @discovery_thread = Thread.new do
79
+ @discovery_object.discover
80
+ loop do
81
+ break if @discovery == 0
82
+ sleep(@discovery)
83
+ @discovery_object.discover
84
+ end
85
+ end
86
+ end
87
+
88
+ def setup_discovery
89
+ @discovery_object = IntesisBox::Discovery.new do |details|
90
+ # force a reconnect to WMPs that have moved
91
+ mac = MQTT::Homie.escape_id(details[:mac])
92
+ known_wmp = @wmps[mac]
93
+ if known_wmp && known_wmp.ip != details[:ip]
94
+ @wmps.delete(mac)
95
+ end
96
+
97
+ next true if @wmps.key?(mac)
98
+
99
+ wmp = IntesisBox::Client.new(details[:ip])
100
+
101
+ loop do
102
+ break if wmp.mac
103
+ unless wmp.poll(1)
104
+ puts "unable to talk to #{wmp.mac}"
105
+ break
106
+ end
107
+ end
108
+ next true unless wmp.mac
109
+
110
+ puts "Found new WMP #{wmp.mac} (#{wmp.devicename})"
111
+ @wmps[MQTT::Homie.escape_id(wmp.mac)] = wmp
112
+ node = publish_wmp(wmp)
113
+
114
+ Thread.new do
115
+ iter = 0
116
+ loop do
117
+ next wmp.ping unless wmp.poll
118
+
119
+ node.name = wmp.devicename
120
+ node.properties['ip'].value = wmp.ip
121
+ node.properties['onoff'].value = wmp.onoff
122
+ node.properties['mode'].value = wmp.mode if wmp.limits[:mode]&.length.to_i > 0
123
+ node.properties['fansp'].value = wmp.fansp if wmp.limits[:fansp]&.length.to_i > 0
124
+ node.properties['vaneud'].value = wmp.vaneud if wmp.limits[:vaneud]&.length.to_i > 0
125
+ node.properties['vanelr'].value = wmp.vanelr if wmp.limits[:vanelr]&.length.to_i > 0
126
+ node.properties['setptemp'].value = convert_units(wmp.setptemp) if wmp.setptemp
127
+ node.properties['ambtemp'].value = convert_units(wmp.ambtemp) if wmp.ambtemp
128
+ node.properties['errstatus'].value = wmp.errstatus
129
+ node.properties['errcode'].value = wmp.errcode
130
+ node.properties['devicename'].value = wmp.devicename
131
+ end
132
+ rescue => e
133
+ puts "Lost connection to #{wmp.mac} (#{wmp.devicename}): #{e}"
134
+ remove_wmp(wmp)
135
+ end
136
+ true
137
+ end
138
+ end
139
+
140
+ def publish_wmp(wmp)
141
+ generic_property = ->(prop, value) do
142
+ wmp = @wmps[prop.node.id]
143
+ wmp.send("#{prop.id}=", value)
144
+ end
145
+
146
+ node = nil
147
+ @device.node(MQTT::Homie.escape_id(wmp.mac), wmp.devicename, wmp.model) do |n|
148
+ node = n
149
+ node.property("devicename", "Device Name", :string, &generic_property).
150
+ property("ip", "IP Address", "string").
151
+ property("onoff", "AC unit On or Off", "boolean") do |prop, value|
152
+ wmp = @wmps[prop.node.id]
153
+ wmp.onoff = value == 'true'
154
+ end
155
+
156
+ if wmp.limits[:mode]&.length.to_i > 0
157
+ node.property("mode", "Mode (heat, cool, fan, dry or auto)", :enum, format: wmp.limits[:mode].join(","), &generic_property)
158
+ end
159
+
160
+ if wmp.limits[:fansp]&.length.to_i > 0
161
+ node.property("fansp", "Fan speed", :enum, format: wmp.limits[:fansp].join(","), &generic_property)
162
+ end
163
+
164
+ if wmp.limits[:vaneud]&.length.to_i > 0
165
+ node.property("vaneud", "Up/Down vane position", :enum, format: wmp.limits[:vaneud].join(","), &generic_property)
166
+ end
167
+
168
+ if wmp.limits[:vanelr]&.length.to_i > 0
169
+ node.property("vanelr", "Left/Right vane position", :enum, format: wmp.limits[:vanelr].join(","), &generic_property)
170
+ end
171
+
172
+ node.property("setptemp", "Set point temperature", :float, unit: "º#{@units}", format: wmp.limits[:setptemp].map { |lim| convert_units(lim) }.join(":")) do |prop, value|
173
+ wmp = @mps[prop.node.id]
174
+ wmp.setptemp = convert_units_set(value.to_f)
175
+ end.
176
+ property("ambtemp", "Ambient temperature", :float, unit: "º#{@units}").
177
+ property("errstatus", "Shows if any error occurs", :string).
178
+ property("errcode", "Error code", :integer)
179
+ end
180
+ node
181
+ end
182
+
183
+ def remove_wmp(wmp)
184
+ mac = MQTT::Homie.escape_id(wmp.mac)
185
+ @wmps.delete(mac)
186
+ @device.remove_node(mac)
187
+ end
188
+
189
+ def convert_units(value)
190
+ return value if @units == :C
191
+ value * 9/5.0 + 32
192
+ end
193
+
194
+ def convert_units_set(value)
195
+ return value if @units == :C
196
+ (value - 32) * 5/9.0
197
+ end
198
+ end
199
+
200
+ mqtt_uri = ARGV[0]
201
+ MQTTBridge.new(mqtt_uri)
@@ -0,0 +1,2 @@
1
+ require 'intesis_box/client'
2
+ require 'intesis_box/discovery'
@@ -0,0 +1,86 @@
1
+ require 'socket'
2
+
3
+ module IntesisBox
4
+ class Client
5
+ attr_reader :ip, :model, :mac, :version, :rssi
6
+ attr_reader :limits
7
+ attr_reader :devicename
8
+ attr_reader :onoff, :mode, :fansp, :vaneud, :vanelr, :setptemp, :ambtemp, :errstatus, :errcode
9
+
10
+ def initialize(ip, port = 3310)
11
+ @limits = {}
12
+ @ip = ip
13
+ @io = TCPSocket.new(ip, port)
14
+
15
+ @io.puts("LIMITS:*")
16
+ poll(1)
17
+ @io.puts("CFG:DEVICENAME")
18
+ poll(1)
19
+ @io.puts("GET,1:*")
20
+ poll(1)
21
+ # this is purposely last, since mac is what we check for it being ready
22
+ @io.puts("ID")
23
+ poll(1)
24
+ end
25
+
26
+ def poll(timeout = 30)
27
+ return false if @io.wait_readable(timeout).nil?
28
+
29
+ loop do
30
+ line = @io.readline.strip
31
+ cmd, args = line.split(':', 2)
32
+ case cmd
33
+ when "ID"
34
+ @model, @mac, _ip, _protocol, @version, @rssi = args.split(',')
35
+ when "LIMITS"
36
+ function, limits = args.split(",",2)
37
+ limits = limits[1...-1].split(",")
38
+ next if function == 'ONOFF'
39
+ limits.map! { |l| l.to_f / 10 } if %w{SETPTEMP AMBTEMP}.include?(function)
40
+ @limits[function.downcase.to_sym] = limits
41
+ when "CHN,1"
42
+ function, value = args.split(",")
43
+ value = value == 'ON' if function == 'ONOFF'
44
+ value = value.to_f / 10 if %w{SETPTEMP AMBTEMP}.include?(function)
45
+ value = value.to_i if function == 'ERRCODE'
46
+ value = nil if value == -3276.8
47
+ instance_variable_set(:"@#{function.downcase}", value)
48
+ when "CFG"
49
+ function, value = args.split(",", 2)
50
+ @devicename = value if function == 'DEVICENAME'
51
+ end
52
+ break unless @io.ready?
53
+ end
54
+ true
55
+ end
56
+
57
+ def ping
58
+ @io.puts("PING")
59
+ end
60
+
61
+ def onoff=(value)
62
+ @io.puts("SET,1:ONOFF,#{value ? 'ON' : 'OFF'}")
63
+ end
64
+
65
+ %w{mode fansp vaneud vanelr}.each do |function|
66
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
67
+ def #{function}=(value)
68
+ return if limits[:#{function}] && !limits[:#{function}].include?(value.to_s)
69
+ @io.puts("SET,1:#{function.upcase},\#{value}")
70
+ end
71
+ RUBY
72
+ end
73
+
74
+ def setptemp=(value)
75
+ value = value.round
76
+ return if limits[:setptemp] && (value < limits[:setptemp].first || value > limits[:setptemp].last)
77
+ @io.puts("SET,1:SETPTEMP,#{value * 10}")
78
+ end
79
+
80
+ def devicename=(value)
81
+ @io.puts("CFG:DEVICENAME,#{value}")
82
+ # have to re-query to ensure it got the new value
83
+ @io.puts("CFG:DEVICENAME")
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,69 @@
1
+ require 'socket'
2
+
3
+ module IntesisBox
4
+ class Discovery
5
+ class << self
6
+ def discover(timeout: 1, expected_count: nil, expect: nil)
7
+ wmps = {}
8
+ discovery = new(timeout: timeout) do |wmp|
9
+ wmps[wmp[:mac]] = wmp
10
+ next false if wmps.length == expected_count
11
+ next false if expect && wmp[:mac] == expect
12
+ true
13
+ end
14
+ wmps
15
+ ensure
16
+ discovery&.close
17
+ end
18
+ end
19
+
20
+ def initialize(timeout: nil)
21
+ @socket = UDPSocket.new
22
+ @socket.bind("0.0.0.0", 3310)
23
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
24
+ @found = []
25
+
26
+ receive_lambda = -> do
27
+ loop do
28
+ if IO.select([@socket], nil, nil, timeout)
29
+ @socket.wait_readable
30
+ msg, _ = @socket.recvfrom(128)
31
+ next unless msg.start_with?("DISCOVER:")
32
+ msg = msg[9..-1]
33
+
34
+ model, mac, ip, protocol, version, rssi, name, _, _ = msg.split(",")
35
+ wmp = { mac: mac, model: model, ip: ip, protocol: protocol, version: version, rssi: rssi, name: name }
36
+ if block_given?
37
+ break unless yield wmp
38
+ else
39
+ @found << wmp
40
+ end
41
+ else
42
+ break
43
+ end
44
+ end
45
+ end
46
+
47
+ if timeout
48
+ discover
49
+ receive_lambda.call
50
+ else
51
+ @receive_thread = Thread.new(&receive_lambda)
52
+ end
53
+ end
54
+
55
+ def close
56
+ @receive_thread&.kill
57
+ @socket.close
58
+ end
59
+
60
+ def discover
61
+ @socket.sendmsg("DISCOVER\r\n", 0, Socket.sockaddr_in(3310, '255.255.255.255'))
62
+ end
63
+
64
+ def pending_discoveries
65
+ result, @found = @found, []
66
+ result
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,3 @@
1
+ module IntesisBox
2
+ VERSION = '0.0.2'
3
+ end
@@ -0,0 +1 @@
1
+ require 'intesis_box'
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: intesisbox
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Cody Cutrer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-12-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: homie-mqtt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.0.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: byebug
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '9.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '9.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ description:
56
+ email: cody@cutrer.com'
57
+ executables:
58
+ - intesisbox_mqtt_bridge
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - bin/intesisbox_mqtt_bridge
63
+ - lib/intesis_box.rb
64
+ - lib/intesis_box/client.rb
65
+ - lib/intesis_box/discovery.rb
66
+ - lib/intesis_box/version.rb
67
+ - lib/intesisbox.rb
68
+ homepage: https://github.com/ccutrer/intesisbox
69
+ licenses:
70
+ - MIT
71
+ metadata: {}
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.1.4
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Library for communication with IntesisBox
91
+ test_files: []