intesisbox 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []