balboa_worldwide_app 1.0.0

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: 7daff60d63c09a23f5628f69102fa722745c343d5c3641d7d87f09e47be7549a
4
+ data.tar.gz: b70859344d26e0f7a54b3a8ffea6389637683c5b5f31de5a0defb8bb52176c0a
5
+ SHA512:
6
+ metadata.gz: '085e33a524d9410cbd069592804a716448031eece734bf5d2209318ef387ef9b2cda6fe941f4956681015889ca70f6f4429805eabfbc56e0bd23322262c3f850'
7
+ data.tar.gz: 749b99bc33e6df19dec5f276206a62556310be025daec5b8c71f061f8fd45510d7a6f58f201655f8b9cfc0bfaf826cf7c7b5b3d5b43755bbedc44ed87bdd70b5
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bwa/client'
4
+ require 'bwa/discovery'
5
+
6
+ def watch(spa)
7
+ loop do
8
+ begin
9
+ message = spa.poll
10
+ next if message.is_a?(BWA::Messages::Ready)
11
+ puts message.raw_data.unpack("H*").first.scan(/[0-9a-f]{2}/).join(' ')
12
+ puts message.inspect
13
+ if block_given?
14
+ break if yield
15
+ end
16
+ rescue BWA::InvalidMessage => e
17
+ puts e.message
18
+ puts e.raw_data.unpack("H*").first.scan(/[0-9a-f]{2}/).join(' ')
19
+ break
20
+ end
21
+ end
22
+ end
23
+
24
+ if ARGV.empty?
25
+ spas = BWA::Discovery.discover
26
+ if spas.empty?
27
+ $stderr.puts "Could not find spa!"
28
+ exit 1
29
+ end
30
+ spa_ip = spas.first.first
31
+ else
32
+ spa_ip = ARGV[0]
33
+ end
34
+
35
+ spa = BWA::Client.new(spa_ip)
36
+
37
+ spa.request_configuration
38
+ spa.request_control_info
39
+ watch(spa) do
40
+ spa.last_status
41
+ end
42
+
43
+ watch(spa)
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'mqtt'
4
+
5
+ require 'bwa/client'
6
+ require 'bwa/discovery'
7
+
8
+ class MQTTBridge
9
+ def initialize(mqtt_uri, bwa, device_id: "bwa", base_topic: "homie")
10
+ @base_topic = "#{base_topic}/#{device_id}"
11
+ @mqtt = MQTT::Client.new(mqtt_uri)
12
+ @mqtt.set_will("#{@base_topic}/$state", "lost", true)
13
+ @mqtt.connect
14
+ @bwa = bwa
15
+ @attributes = {}
16
+
17
+ publish_basic_attributes
18
+
19
+ bwa_thread = Thread.new do
20
+ loop do
21
+ begin
22
+ message = @bwa.poll
23
+ next if message.is_a?(BWA::Messages::Ready)
24
+
25
+ case message
26
+ when BWA::Messages::Status
27
+ # make sure time is in sync
28
+ now = Time.now
29
+ if message.hour != now.hour || message.minute != now.min
30
+ @bwa.set_time(now.hour, now.min, message.twenty_four_hour_time)
31
+ end
32
+ publish_attribute("spa/priming", message.priming)
33
+ publish_attribute("spa/heatingmode", message.heating_mode)
34
+ publish_attribute("spa/temperaturescale", message.temperature_scale)
35
+ publish_attribute("spa/24htime", message.twenty_four_hour_time)
36
+ publish_attribute("spa/heating", message.heating)
37
+ publish_attribute("spa/temperaturerange", message.temperature_range)
38
+ publish_attribute("spa/circpump", message.circ_pump)
39
+ publish_attribute("spa/pump1", message.pump1)
40
+ publish_attribute("spa/pump2", message.pump2)
41
+ publish_attribute("spa/light1", message.light1)
42
+ publish_attribute("spa/currenttemperature", message.current_temperature)
43
+ publish_attribute("spa/currenttemperature/$unit", "º#{message.temperature_scale.to_s[0].upcase}")
44
+ publish_attribute("spa/settemperature", message.set_temperature)
45
+ publish_attribute("spa/settemperature/$unit", "º#{message.temperature_scale.to_s[0].upcase}")
46
+ if message.temperature_scale == :celsius
47
+ publish_attribute("spa/currenttemperature/$format", message.temperature_range == :high ? "26:40" : "10:26")
48
+ publish_attribute("spa/settemperature/$format", message.temperature_range == :high ? "26:40" : "10:26")
49
+ else
50
+ publish_attribute("spa/currenttemperature/$format", message.temperature_range == :high ? "80:104" : "26:40")
51
+ publish_attribute("spa/settemperature/$format", message.temperature_range == :high ? "80:104" : "26:40")
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ @mqtt.get do |topic, value|
59
+ puts "got #{value.inspect} at #{topic}"
60
+ case topic[@base_topic.length + 1..-1]
61
+ when "heatingmode"
62
+ next unless %w{ready rest ready_in_rest}.include?(value)
63
+ # @bwa.set_heating_mode(value.to_sym)
64
+ when "temperaturescale"
65
+ next unless %w{fahrenheit celsius}.include?(value)
66
+ # @bwa.set_temperature_scale(value.to_sym)
67
+ when "spa/24htime/set"
68
+ next unless %w{true false}.include?(value)
69
+ now = Time.now
70
+ @bwa.set_time(now.hour, now.min, value == 'true')
71
+ when "temperaturerange"
72
+ next unless %w{low high}.include?(value)
73
+ # @bwa.set_temperature_range(value.to_sym)
74
+ when %r{^spa/(pump[12])/set}
75
+ @bwa.send(:"set_#{$1}", value.to_i)
76
+ when "spa/light1/set"
77
+ next unless %w{true false}.include?(value)
78
+ @bwa.set_light1(value == 'true')
79
+ when "spa/settemperature/set"
80
+ @bwa.set_temperature(value.to_i)
81
+ end
82
+ end
83
+ end
84
+
85
+ def publish(topic, value)
86
+ @mqtt.publish("#{@base_topic}/#{topic}", value, true)
87
+ end
88
+
89
+ def publish_attribute(attr, value)
90
+ if !@attributes.key?(attr) || @attributes[attr] != value
91
+ publish(attr, value.to_s)
92
+ @attributes[attr] = value
93
+ end
94
+ end
95
+
96
+ def subscribe(topic)
97
+ @mqtt.subscribe("#{@base_topic}/#{topic}")
98
+ end
99
+
100
+ def publish_basic_attributes
101
+ publish("$homie", "v4.0.0")
102
+ publish("$name", "BWA Spa")
103
+ publish("$state", "init")
104
+ publish("$nodes", "spa")
105
+
106
+ publish("spa/$name", "BWA Spa")
107
+ publish("spa/$type", "spa")
108
+ publish("spa/$properties", "priming,heatingmode,temperaturescale,24htime,heating,temperaturerange,circpump,pump1,pump2,light1,currenttemperature,settemperature")
109
+
110
+ publish("spa/priming/$name", "Is the pump priming")
111
+ publish("spa/priming/$datatype", "boolean")
112
+
113
+ publish("spa/heatingmode/$name", "Current heating mode")
114
+ publish("spa/heatingmode/$datatype", "enum")
115
+ publish("spa/heatingmode/$format", "ready,rest,ready_in_rest")
116
+ publish("spa/heatingmode/$settable", "true")
117
+ subscribe("spa/heatingmode/set")
118
+
119
+ publish("spa/temperaturescale/$name", "Temperature scale")
120
+ publish("spa/temperaturescale/$datatype", "enum")
121
+ publish("spa/temperaturescale/$format", "fahrenheit,celsius")
122
+ publish("spa/temperaturescale/$settable", "true")
123
+ subscribe("spa/temperaturescale/set")
124
+
125
+ publish("spa/24htime/$name", "Clock is 24 hour time")
126
+ publish("spa/24htime/$datatype", "boolean")
127
+ publish("spa/24htime/$settable", "true")
128
+ subscribe("spa/24htime/set")
129
+
130
+ publish("spa/heating/$name", "Heater is currently running")
131
+ publish("spa/heating/$datatype", "boolean")
132
+
133
+ publish("spa/temperaturerange/$name", "Current temperature range")
134
+ publish("spa/temperaturerange/$datatype", "enum")
135
+ publish("spa/temperaturerange/$format", "high,low")
136
+ publish("spa/temperaturerange/$settable", "true")
137
+ subscribe("spa/temperaturerange/set")
138
+
139
+ publish("spa/circpump/$name", "Circ pump is currently running")
140
+ publish("spa/circpump/$datatype", "boolean")
141
+
142
+ publish("spa/pump1/$name", "Pump 1 speed")
143
+ publish("spa/pump1/$datatype", "integer")
144
+ publish("spa/pump1/$format", "0:2")
145
+ publish("spa/pump1/$settable", "true")
146
+ subscribe("spa/pump1/set")
147
+
148
+ publish("spa/pump2/$name", "Pump 2 speed")
149
+ publish("spa/pump2/$datatype", "integer")
150
+ publish("spa/pump2/$format", "0:2")
151
+ publish("spa/pump2/$settable", "true")
152
+ subscribe("spa/pump2/set")
153
+
154
+ publish("spa/light1/$name", "Light 1")
155
+ publish("spa/light1/$datatype", "boolean")
156
+ publish("spa/light1/$settable", "true")
157
+ subscribe("spa/light1/set")
158
+
159
+ publish("spa/currenttemperature/$name", "Current temperature")
160
+ publish("spa/currenttemperature/$datatype", "integer")
161
+
162
+ publish("spa/settemperature/$name", "Set Temperature")
163
+ publish("spa/settemperature/$datatype", "integer")
164
+ publish("spa/settemperature/$settable", "true")
165
+ subscribe("spa/settemperature/set")
166
+
167
+ publish("$state", "ready")
168
+ end
169
+ end
170
+
171
+ mqtt_uri = ARGV.shift
172
+
173
+ if ARGV.empty?
174
+ spas = BWA::Discovery.discover
175
+ if spas.empty?
176
+ $stderr.puts "Could not find spa!"
177
+ exit 1
178
+ end
179
+ spa_ip = spas.first.first
180
+ else
181
+ spa_ip = ARGV[0]
182
+ end
183
+
184
+ spa = BWA::Client.new(spa_ip)
185
+
186
+ spa.request_configuration
187
+
188
+ MQTTBridge.new(mqtt_uri, spa)
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bwa/discovery'
4
+ require 'bwa/proxy'
5
+
6
+ Thread.new do
7
+ BWA::Discovery.advertise
8
+ end
9
+
10
+ proxy = BWA::Proxy.new(ARGV[0])
11
+
12
+ loop do
13
+ proxy.run
14
+ end
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bwa/discovery'
4
+ require 'bwa/server'
5
+
6
+ Thread.new do
7
+ BWA::Discovery.advertise
8
+ end
9
+
10
+ server = BWA::Server.new
11
+
12
+ loop do
13
+ server.run
14
+ end
@@ -0,0 +1 @@
1
+ require 'bwa/client'
@@ -0,0 +1,117 @@
1
+ require 'bwa/message'
2
+
3
+ module BWA
4
+ class Client
5
+ attr_reader :last_status, :last_filter_configuration
6
+
7
+ def initialize(host, port = 4257)
8
+ if host =~ %r{^/dev}
9
+ require 'serialport'
10
+ @io = SerialPort.open(host, "baud" => 115200)
11
+ @queue = []
12
+ else
13
+ require 'socket'
14
+ @io = TCPSocket.new(host, port)
15
+ end
16
+ end
17
+
18
+ def poll
19
+ message = nil
20
+ while message.nil?
21
+ begin
22
+ message = Message.parse(@io)
23
+ if message.is_a?(Messages::Ready) && (msg = @queue.shift)
24
+ @io.write(msg)
25
+ end
26
+ rescue BWA::InvalidMessage => e
27
+ unless e.message =~ /Incorrect data length/
28
+ puts e.message
29
+ puts e.raw_data.unpack("H*").first.scan(/[0-9a-f]{2}/).join(' ')
30
+ end
31
+ end
32
+ end
33
+ @last_status = message.dup if message.is_a?(Messages::Status)
34
+ @last_filter_configuration = message.dup if message.is_a?(Messages::FilterCycles)
35
+ message
36
+ end
37
+
38
+ def messages_pending?
39
+ !!IO.select([@io], nil, nil, 0)
40
+ end
41
+
42
+ def drain_message_queue
43
+ poll while messages_pending?
44
+ end
45
+
46
+ def send_message(message)
47
+ length = message.length + 2
48
+ full_message = "#{length.chr}#{message}".force_encoding(Encoding::ASCII_8BIT)
49
+ checksum = CRC.checksum(full_message)
50
+ full_message = "\x7e#{full_message}#{checksum.chr}\x7e".force_encoding(Encoding::ASCII_8BIT)
51
+ if @queue
52
+ @queue.push(full_message)
53
+ else
54
+ @io.write(full_message)
55
+ end
56
+ end
57
+
58
+ def request_configuration
59
+ send_message("\x0a\xbf\x04")
60
+ end
61
+
62
+ def request_control_info
63
+ send_message("\x0a\xbf\x22\x02\x00\x00")
64
+ end
65
+
66
+ def request_filter_configuration
67
+ send_message("\x0a\xbf\x22\x01\x00\x00")
68
+ end
69
+
70
+ def toggle_item(args)
71
+ send_message("\x0a\xbf\x11#{args}")
72
+ end
73
+
74
+ def toggle_light1
75
+ toggle_item("\x11\x00")
76
+ end
77
+
78
+ def toggle_pump1
79
+ toggle_item("\x04\x00")
80
+ end
81
+
82
+ def toggle_pump2
83
+ toggle_item("\x05\x00")
84
+ end
85
+
86
+ (1..2).each do |i|
87
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
88
+ def set_pump#{i}(desired)
89
+ return unless last_status
90
+ times = (desired - last_status.pump#{i}) % 3
91
+ times.times do
92
+ toggle_pump#{i}
93
+ sleep(0.1)
94
+ end
95
+ end
96
+ RUBY
97
+ end
98
+
99
+ def set_light1(desired)
100
+ return unless last_status
101
+ return if last_status.light1 == desired
102
+ toggle_light1
103
+ end
104
+
105
+ # high range is 80-104 for F, 26-40 for C (by 0.5)
106
+ # low range is 50-80 for F, 10-26 for C (by 0.5)
107
+ def set_temperature(desired)
108
+ desired *= 2 if last_status && last_status.temperature_scale == :celsius || desired < 50
109
+ send_message("\x0a\xbf\x20#{desired.chr}")
110
+ end
111
+
112
+ def set_time(hour, minute, twenty_four_hour_time = false)
113
+ hour |= 0x80 if twenty_four_hour_time
114
+ send_message("\x0a\xbf\x21".force_encoding(Encoding::ASCII_8BIT) + hour.chr + minute.chr)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,8 @@
1
+ require 'digest/crc'
2
+
3
+ module BWA
4
+ class CRC < Digest::CRC8
5
+ INIT_CRC = 0x02
6
+ XOR_MASK = 0x02
7
+ end
8
+ end
@@ -0,0 +1,43 @@
1
+ require 'socket'
2
+
3
+ module BWA
4
+ class Discovery
5
+ class << self
6
+ def discover(timeout = 5, exhaustive = false)
7
+ socket = UDPSocket.new
8
+ socket.bind("0.0.0.0", 0)
9
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
10
+ socket.sendmsg("Discovery: Who is out there?", 0, Socket.sockaddr_in(30303, '255.255.255.255'))
11
+ spas = {}
12
+ loop do
13
+ if IO.select([socket], nil, nil, timeout)
14
+ msg, ip = socket.recvfrom(64)
15
+ ip = ip[2]
16
+ name, mac = msg.split("\r\n")
17
+ name.strip!
18
+ if mac.start_with?("00-15-27-")
19
+ spas[ip] = name
20
+ break unless exhaustive
21
+ end
22
+ else
23
+ break
24
+ end
25
+ end
26
+ spas
27
+ end
28
+
29
+ def advertise
30
+ socket = UDPSocket.new
31
+ socket.bind("0.0.0.0", 30303)
32
+ msg = "BWGSPA\r\n00-15-27-00-00-01\r\n"
33
+ loop do
34
+ data, addr = socket.recvfrom(32)
35
+ next unless data == 'Discovery: Who is out there?'
36
+ ip = addr.last
37
+ puts "Advertising to #{ip}"
38
+ socket.sendmsg(msg, 0, Socket.sockaddr_in(addr[1], ip))
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,119 @@
1
+ require 'bwa/crc'
2
+
3
+ module BWA
4
+ class InvalidMessage < RuntimeError
5
+ attr_reader :raw_data
6
+
7
+ def initialize(message, data)
8
+ @raw_data = data
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ class Message
14
+ class << self
15
+ def inherited(klass)
16
+ @messages ||= []
17
+ @messages << klass
18
+ end
19
+
20
+ def parse(io)
21
+ io = StringIO.new(io) if io.is_a?(String)
22
+ data = ''
23
+ # skim through until a start-of-message indicator
24
+ until data[0] == '~'
25
+ data = io.read(1)
26
+ end
27
+
28
+ data.concat(io.read(1))
29
+ length = data[-1].ord
30
+
31
+ if length < 5
32
+ bytes = data.bytes
33
+ bytes.shift
34
+ bytes.reverse.each { |byte| io.ungetbyte(byte) }
35
+ raise InvalidMessage.new("Message has bogus length: #{length}", data)
36
+ end
37
+
38
+ data.concat(io.read(length))
39
+ if data.length != length + 2
40
+ data.bytes.reverse.each { |b| io.ungetbyte(b) }
41
+ raise InvalidMessage.new("Incorrect data length (received #{data.length - 2}, expected #{length})", data)
42
+ end
43
+
44
+ message_type = data[2..4]
45
+ klass = @messages.find { |k| k::MESSAGE_TYPE == message_type }
46
+
47
+ unless data[-1] == '~'
48
+ bytes = data.bytes
49
+ bytes.shift
50
+ bytes.reverse.each { |b| io.ungetbyte(b) }
51
+ raise InvalidMessage.new("Missing trailing message indicator", data)
52
+ end
53
+
54
+ unless CRC.checksum(data[1...-2]) == data[-2].ord
55
+ bytes = data.bytes
56
+ bytes.shift
57
+ bytes.reverse.each { |b| io.ungetbyte(b) }
58
+ raise InvalidMessage.new("Invalid checksum", data)
59
+ end
60
+
61
+ return nil if [
62
+ "\xfe\xbf\x00".force_encoding(Encoding::ASCII_8BIT),
63
+ "\x10\xbf\xe1".force_encoding(Encoding::ASCII_8BIT),
64
+ "\x10\xbf\x07".force_encoding(Encoding::ASCII_8BIT)].include?(message_type)
65
+
66
+ raise InvalidMessage.new("Unrecognized message #{message_type.unpack("H*").first}", data) unless klass
67
+ raise InvalidMessage.new("Unrecognized data length (#{length}) for message #{klass}", data) unless length - 5 == klass::MESSAGE_LENGTH
68
+
69
+ message = klass.new
70
+ message.parse(data[5..-2])
71
+ message.instance_variable_set(:@raw_data, data)
72
+ message
73
+ end
74
+
75
+ def format_time(hour, minute, twenty_four_hour_time = true)
76
+ if twenty_four_hour_time
77
+ print_hour = "%02d" % hour
78
+ else
79
+ print_hour = hour % 12
80
+ print_hour = 12 if print_hour == 0
81
+ am_pm = (hour >= 12 ? "PM" : "AM")
82
+ end
83
+ "#{print_hour}:#{"%02d" % minute}#{am_pm}"
84
+ end
85
+
86
+ def format_duration(hours, minutes)
87
+ "#{hours}:#{"%02d" % minutes}"
88
+ end
89
+ end
90
+
91
+ attr_reader :raw_data
92
+
93
+ def parse(_data)
94
+ end
95
+
96
+ def serialize(message = "")
97
+ length = message.length + 5
98
+ full_message = "#{length.chr}#{self.class::MESSAGE_TYPE}#{message}".force_encoding(Encoding::ASCII_8BIT)
99
+ checksum = CRC.checksum(full_message)
100
+ "\x7e#{full_message}#{checksum.chr}\x7e".force_encoding(Encoding::ASCII_8BIT)
101
+ end
102
+
103
+ def inspect
104
+ "#<#{self.class.name} #{raw_data.unpack("H*").first}>"
105
+ end
106
+ end
107
+ end
108
+
109
+ require 'bwa/messages/configuration'
110
+ require 'bwa/messages/configuration_request'
111
+ require 'bwa/messages/control_configuration'
112
+ require 'bwa/messages/control_configuration_request'
113
+ require 'bwa/messages/filter_cycles'
114
+ require 'bwa/messages/ready'
115
+ require 'bwa/messages/set_temperature'
116
+ require 'bwa/messages/set_temperature_scale'
117
+ require 'bwa/messages/set_time'
118
+ require 'bwa/messages/status'
119
+ require 'bwa/messages/toggle_item'
@@ -0,0 +1,8 @@
1
+ module BWA
2
+ module Messages
3
+ class Configuration < Message
4
+ MESSAGE_TYPE = "\x0a\xbf\x94".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 25
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ module BWA
2
+ module Messages
3
+ class ConfigurationRequest < Message
4
+ MESSAGE_TYPE = "\x0a\xbf\x04".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 0
6
+
7
+ def inspect
8
+ "#<BWA::Messages::ConfigurationRequest>"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ module BWA
2
+ module Messages
3
+ class ControlConfiguration < Message
4
+ MESSAGE_TYPE = "\x0a\xbf\x24".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 21
6
+ end
7
+ end
8
+ end
9
+
10
+ module BWA
11
+ module Messages
12
+ class ControlConfiguration2 < Message
13
+ MESSAGE_TYPE = "\x0a\xbf\x2e".force_encoding(Encoding::ASCII_8BIT)
14
+ MESSAGE_LENGTH = 6
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ module BWA
2
+ module Messages
3
+ class ControlConfigurationRequest < Message
4
+ MESSAGE_TYPE = "\x0a\xbf\x22".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 3
6
+
7
+ attr_accessor :type
8
+
9
+ def initialize(type = 1)
10
+ self.type = type
11
+ end
12
+
13
+ def parse(data)
14
+ self.type = data == "\x02\x00\x00" ? 1 : 2
15
+ end
16
+
17
+
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,42 @@
1
+ module BWA
2
+ module Messages
3
+ class FilterCycles < Message
4
+ attr_reader :filter1_hour, :filter1_minute, :filter1_duration_hours, :filter1_duration_minutes,
5
+ :filter2_enabled,
6
+ :filter2_hour, :filter2_minute, :filter2_duration_hours, :filter2_duration_minutes
7
+
8
+ MESSAGE_TYPE = "\x0a\xbf\x23".force_encoding(Encoding::ASCII_8BIT)
9
+ MESSAGE_LENGTH = 8
10
+
11
+ def parse(data)
12
+ @filter1_hour = data[0].ord
13
+ @filter1_minute = data[1].ord
14
+ @filter1_duration_hours = data[2].ord
15
+ @filter1_duration_minutes = data[3].ord
16
+
17
+ f2_hour = data[4].ord
18
+ @filter2_enabled = !!(f2_hour & 0x80 == 0x80)
19
+ @filter2_hour = f2_hour & 0x7f
20
+ @filter2_minute = data[5].ord
21
+ @filter2_duration_hours = data[6].ord
22
+ @filter2_duration_minutes = data[7].ord
23
+ end
24
+
25
+ def inspect
26
+ result = "#<BWA::Messages::FilterCycles "
27
+
28
+ result << "filter1 "
29
+ result << self.class.format_duration(filter1_duration_hours, filter1_duration_minutes)
30
+ result << "@"
31
+ result << self.class.format_time(filter1_hour, filter1_minute)
32
+
33
+ result << " filter2(#{@filter2_enabled ? 'enabled' : 'disabled'}) "
34
+ result << self.class.format_duration(filter2_duration_hours, filter2_duration_minutes)
35
+ result << "@"
36
+ result << self.class.format_time(filter2_hour, filter2_minute)
37
+
38
+ result << ">"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,8 @@
1
+ module BWA
2
+ module Messages
3
+ class Ready < Message
4
+ MESSAGE_TYPE = "\x10\xbf\06".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 0
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,26 @@
1
+ module BWA
2
+ module Messages
3
+ class SetTemperature < Message
4
+ MESSAGE_TYPE = "\x0a\xbf\x20".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 1
6
+
7
+ attr_accessor :temperature
8
+
9
+ def initialize(temperature = nil)
10
+ self.temperature = temperature
11
+ end
12
+
13
+ def parse(data)
14
+ self.temperature = data[0].ord
15
+ end
16
+
17
+ def serialize
18
+ super(temperature.chr)
19
+ end
20
+
21
+ def inspect
22
+ "#<BWA::Messages::SetTemperature #{temperature}º>"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,28 @@
1
+ module BWA
2
+ module Messages
3
+ class SetTemperatureScale < Message
4
+ MESSAGE_TYPE = "\x0a\xbf\x27".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 2
6
+
7
+ attr_accessor :scale
8
+
9
+ def initialize(scale = nil)
10
+ self.scale = scale
11
+ end
12
+
13
+ def parse(data)
14
+ self.scale = data[1].ord == 0x00 ? :fahrenheit : :celsius
15
+ end
16
+
17
+ def serialize
18
+ data = "\x01\x00"
19
+ data[1] = (scale == :fahrenheit ? 0x00 : 0x01).chr
20
+ super(data)
21
+ end
22
+
23
+ def inspect
24
+ "#<BWA::Messages::SetTemperatureScale º#{scale.to_s[0].upcase}>"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ module BWA
2
+ module Messages
3
+ class SetTime < Message
4
+ MESSAGE_TYPE = "\x0a\xbf\x21".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 2
6
+
7
+ attr_accessor :hour, :minute, :twenty_four_hour_time
8
+
9
+ def initialize(hour = nil, minute = nil, twenty_four_hour_time = nil)
10
+ self.hour, self.minute, self.twenty_four_hour_time = hour, minute, twenty_four_hour_time
11
+ end
12
+
13
+ def parse(data)
14
+ self.hour = data[0].ord & 0x7f
15
+ self.minute = data[1].ord
16
+ self.twenty_four_hour_time = !!(data[0].ord & 0x80)
17
+ end
18
+
19
+ def serialize
20
+ hour_encoded = hour
21
+ hour_encoded |= 0x80 if twenty_four_hour_time
22
+ super("#{hour_encoded.chr}#{minute.chr}")
23
+ end
24
+
25
+ def inspect
26
+ "#<BWA::Messages::SetTime #{Status.format_time(hour, minute, twenty_four_hour_time)}>"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,149 @@
1
+ module BWA
2
+ module Messages
3
+ class Status < Message
4
+ attr_accessor :priming,
5
+ :heating_mode,
6
+ :temperature_scale,
7
+ :twenty_four_hour_time,
8
+ :heating,
9
+ :temperature_range,
10
+ :hour, :minute,
11
+ :circ_pump,
12
+ :pump1, :pump2,
13
+ :light1,
14
+ :current_temperature, :set_temperature
15
+
16
+ MESSAGE_TYPE = "\xff\xaf\x13".force_encoding(Encoding::ASCII_8BIT)
17
+ MESSAGE_LENGTH = 24
18
+
19
+ def initialize
20
+ self.priming = false
21
+ self.heating_mode = :ready
22
+ @temperature_scale = :fahrenheit
23
+ self.twenty_four_hour_time = false
24
+ self.heating = false
25
+ self.temperature_range = :high
26
+ self.hour = self.minute = 0
27
+ self.circ_pump = false
28
+ self.pump1 = self.pump2 = 0
29
+ self.light1 = false
30
+ self.set_temperature = 100
31
+ end
32
+
33
+ def parse(data)
34
+ flags = data[1].ord
35
+ self.priming = (flags & 0x01 == 0x01)
36
+ flags = data[5].ord
37
+ self.heating_mode = case flags & 0x03
38
+ when 0x00; :ready
39
+ when 0x01; :rest
40
+ when 0x02; :ready_in_rest
41
+ end
42
+ flags = data[9].ord
43
+ self.temperature_scale = (flags & 0x01 == 0x01) ? :celsius : :fahrenheit
44
+ self.twenty_four_hour_time = (flags & 0x02 == 0x02)
45
+ flags = data[10].ord
46
+ self.heating = (flags & 0x30 != 0)
47
+ self.temperature_range = (flags & 0x04 == 0x04) ? :high : :low
48
+ flags = data[11].ord
49
+ self.pump1 = flags & 0x03
50
+ self.pump2 = (flags / 4) & 0x03
51
+ flags = data[13].ord
52
+ self.circ_pump = (flags & 0x02 == 0x02)
53
+ flags = data[14].ord
54
+ self.light1 = (flags & 0x03 == 0x03)
55
+ self.hour = data[3].ord
56
+ self.minute = data[4].ord
57
+ self.current_temperature = data[2].ord
58
+ self.current_temperature = nil if self.current_temperature == 0xff
59
+ self.set_temperature = data[20].ord
60
+ if temperature_scale == :celsius
61
+ self.current_temperature /= 2.0
62
+ self.set_temperature /= 2.0
63
+ end
64
+ end
65
+
66
+ def serialize
67
+ data = "\x00" * 24
68
+ data[1] = (priming ? 0x01 : 0x00).chr
69
+ data[5] = (case heating_mode
70
+ when :ready; 0x00
71
+ when :rest; 0x01
72
+ when :ready_in_rest; 0x02
73
+ end).chr
74
+ flags = 0
75
+ flags |= 0x01 if temperature_scale == :celsius
76
+ flags |= 0x02 if twenty_four_hour_time
77
+ data[9] = flags.chr
78
+ flags = 0
79
+ flags |= 0x30 if heating
80
+ flags |= 0x04 if temperature_range == :high
81
+ data[10] = flags.chr
82
+ flags = 0
83
+ flags |= pump1
84
+ flags |= pump2 * 4
85
+ data[11] = flags.chr
86
+ flags = 0
87
+ flags |= 0x02 if circ_pump
88
+ data[13] = flags.chr
89
+ flags = 0
90
+ flags |= 0x03 if light1
91
+ data[14] = flags.chr
92
+ data[3] = hour.chr
93
+ data[4] = minute.chr
94
+ if temperature_scale == :celsius
95
+ data[2] = (current_temperature ? (current_temperature * 2).to_i : 0xff).chr
96
+ data[20] = (set_temperature * 2).to_i.chr
97
+ else
98
+ data[2] = (current_temperature&.to_i || 0xff).chr
99
+ data[20] = set_temperature.to_i.chr
100
+ end
101
+
102
+ super(data)
103
+ end
104
+
105
+ def temperature_scale=(value)
106
+ if value != @temperature_scale
107
+ if value == :fahrenheit
108
+ if current_temperature
109
+ self.current_temperature *= 9.0/5
110
+ self.current_temperature += 32
111
+ self.current_temperature = current_temperature.round
112
+ end
113
+ self.set_temperature *= 9.0/5
114
+ self.set_temperature += 32
115
+ self.set_temperature = set_temperature.round
116
+ else
117
+ if current_temperature
118
+ self.current_temperature -= 32
119
+ self.current_temperature *= 5.0/90
120
+ self.current_temperature = (current_temperature * 2).round / 2.0
121
+ end
122
+ self.set_temperature -= 32
123
+ self.set_temperature *= 5.0/9
124
+ self.set_temperature = (set_temperature * 2).round / 2.0
125
+ end
126
+ end
127
+ @temperature_scale = value
128
+ end
129
+
130
+ def inspect
131
+ result = "#<BWA::Messages::Status "
132
+ items = []
133
+
134
+ items << "priming" if priming
135
+ items << self.class.format_time(hour, minute, twenty_four_hour_time)
136
+ items << "#{current_temperature || '--'}/#{set_temperature}º#{temperature_scale.to_s[0].upcase}"
137
+ items << heating_mode
138
+ items << "heating" if heating
139
+ items << temperature_range
140
+ items << "circ_pump" if circ_pump
141
+ items << "pump1=#{pump1}" unless pump1 == 0
142
+ items << "pump2=#{pump2}" unless pump2 == 0
143
+ items << "light1" if light1
144
+
145
+ result << items.join(' ') << ">"
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,40 @@
1
+ module BWA
2
+ module Messages
3
+ class ToggleItem < Message
4
+ MESSAGE_TYPE = "\x0a\xbf\x11".force_encoding(Encoding::ASCII_8BIT)
5
+ MESSAGE_LENGTH = 2
6
+
7
+ attr_accessor :item
8
+
9
+ def initialize(item = nil)
10
+ self.item = item
11
+ end
12
+
13
+ def parse(data)
14
+ self.item = case data[0].ord
15
+ when 0x04; :pump1
16
+ when 0x05; :pump2
17
+ when 0x11; :light1
18
+ when 0x50; :temperature_range
19
+ when 0x51; :heating_mode
20
+ end
21
+ end
22
+
23
+ def serialize
24
+ data = "\x00\x00"
25
+ data[0] = (case setting
26
+ when :pump1; 0x04
27
+ when :pump2; 0x05
28
+ when :light1; 0x11
29
+ when :temperature_range; 0x50
30
+ when :heating_mode; 0x51
31
+ end).chr
32
+ super(data)
33
+ end
34
+
35
+ def inspect
36
+ "#<BWA::Messages::ToggleItem #{item}>"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,55 @@
1
+ require 'socket'
2
+ require 'bwa/message'
3
+
4
+ module BWA
5
+ class Proxy
6
+ def initialize(host, port: 4257, listen_port: 4257)
7
+ @host, @port = host, port
8
+ @listen_socket = TCPServer.open(port)
9
+ end
10
+
11
+ def run
12
+ loop do
13
+ client_socket = @listen_socket.accept
14
+ server_socket = TCPSocket.new(@host, @port)
15
+ t1 = Thread.new do
16
+ shuffle_messages(client_socket, server_socket, "Client")
17
+ end
18
+ t2 = Thread.new do
19
+ shuffle_messages(server_socket, client_socket, "Server")
20
+ end
21
+ t1.join
22
+ t2.join
23
+ break
24
+ end
25
+ end
26
+
27
+ def shuffle_messages(socket1, socket2, tag)
28
+ leftover_data = "".force_encoding(Encoding::ASCII_8BIT)
29
+ loop do
30
+ if leftover_data.length < 2 || leftover_data.length < leftover_data[1].ord + 2
31
+ begin
32
+ leftover_data += socket1.recv(128)
33
+ rescue Errno::EBADF
34
+ # we closed it on ourselves
35
+ break
36
+ end
37
+ end
38
+ if leftover_data.empty?
39
+ socket2.close
40
+ break
41
+ end
42
+ data_length = leftover_data[1].ord
43
+ data = leftover_data[0...(data_length + 2)]
44
+ leftover_data = leftover_data[(data_length + 2)..-1] || ''
45
+ begin
46
+ message = Message.parse(data)
47
+ puts "#{tag}: #{message.inspect}"
48
+ rescue InvalidMessage => e
49
+ puts "#{tag}: #{e}"
50
+ end
51
+ socket2.send(data, 0)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,93 @@
1
+ require 'socket'
2
+ require 'bwa/message'
3
+
4
+ module BWA
5
+ class Server
6
+ def initialize(port = 4257)
7
+ @listen_socket = TCPServer.open(port)
8
+ @status = Messages::Status.new
9
+ end
10
+
11
+ def run
12
+ loop do
13
+ socket = @listen_socket.accept
14
+ #Thread.new do
15
+ run_client(socket)
16
+ #end
17
+ break
18
+ end
19
+ end
20
+
21
+ def send_message(socket, message)
22
+ length = message.length + 2
23
+ full_message = "#{length.chr}#{message}".force_encoding(Encoding::ASCII_8BIT)
24
+ checksum = CRC.checksum(full_message)
25
+ socket.send("\x7e#{full_message}#{checksum.chr}\x7e".force_encoding(Encoding::ASCII_8BIT), 0)
26
+ end
27
+
28
+ def run_client(socket)
29
+ puts "Received connection from #{socket.remote_address.inspect}"
30
+
31
+ send_status(socket)
32
+ loop do
33
+ if IO.select([socket], nil, nil, 1)
34
+ data = socket.recv(128)
35
+ break if data.empty?
36
+ begin
37
+ message = Message.parse(data)
38
+ puts message.raw_data.unpack("H*").first.scan(/[0-9a-f]{2}/).join(' ')
39
+ puts message.inspect
40
+
41
+ case message
42
+ when Messages::ConfigurationRequest
43
+ send_configuration(socket)
44
+ when Messages::ControlConfigurationRequest
45
+ message.type == 1 ? send_control_configuration(socket) : send_control_configuration2(socket)
46
+ when Messages::SetTemperature
47
+ temperature = message.temperature
48
+ temperature /= 2.0 if @status.temperature_scale == :celsius
49
+ @status.set_temperature = temperature
50
+ when Messages::SetTemperatureScale
51
+ @status.temperature_scale = message.scale
52
+ when Messages::ToggleItem
53
+ case message.item
54
+ when :heating_mode
55
+ @status.heating_mode = (@status.heating_mode == :rest ? :ready : :rest)
56
+ when :temperature_range
57
+ @status.temperature_range = (@status.temperature_range == :low ? :high : :low)
58
+ when :pump1
59
+ @status.pump1 = (@status.pump1 + 1) % 3
60
+ when :pump2
61
+ @status.pump2 = (@status.pump2 + 1) % 3
62
+ when :light1
63
+ @status.light1 = !@status.light1
64
+ end
65
+ end
66
+ rescue BWA::InvalidMessage => e
67
+ puts e.message
68
+ puts e.raw_data.unpack("H*").first.scan(/[0-9a-f]{2}/).join(' ')
69
+ end
70
+ else
71
+ send_status(socket)
72
+ end
73
+ end
74
+ end
75
+
76
+ def send_status(socket)
77
+ puts "sending #{@status.inspect}"
78
+ socket.send(@status.serialize, 0)
79
+ end
80
+
81
+ def send_configuration(socket)
82
+ send_message(socket, "\x0a\xbf\x94\x02\x02\x80\x00\x15\x27\x10\xab\xd2\x00\x00\x00\x00\x00\x00\x00\x00\x00\x15\x27\xff\xff\x10\xab\xd2")
83
+ end
84
+
85
+ def send_control_configuration(socket)
86
+ send_message(socket, "\x0a\xbf\x24\x64\xdc\x11\x00\x42\x46\x42\x50\x32\x30\x20\x20\x01\x3d\x12\x38\x2e\x01\x0a\x04\x00")
87
+ end
88
+
89
+ def send_control_configuration2(socket)
90
+ send_message(socket, "\x0a\xbf\x2e\x0a\x00\x01\xd0\x00\x44")
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,3 @@
1
+ module BWA
2
+ VERSION = '1.0.0'
3
+ end
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: balboa_worldwide_app
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Cody Cutrer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-05-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: digest-crc
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: serialport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.3.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.3.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: mqtt
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.5.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.5.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: byebug
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '9.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '9.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ description:
84
+ email: cody@cutrer.com'
85
+ executables:
86
+ - bwa_mqtt_bridge
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - bin/bwa_client
91
+ - bin/bwa_mqtt_bridge
92
+ - bin/bwa_proxy
93
+ - bin/bwa_server
94
+ - lib/balboa_worldwide_app.rb
95
+ - lib/bwa/client.rb
96
+ - lib/bwa/crc.rb
97
+ - lib/bwa/discovery.rb
98
+ - lib/bwa/message.rb
99
+ - lib/bwa/messages/configuration.rb
100
+ - lib/bwa/messages/configuration_request.rb
101
+ - lib/bwa/messages/control_configuration.rb
102
+ - lib/bwa/messages/control_configuration_request.rb
103
+ - lib/bwa/messages/filter_cycles.rb
104
+ - lib/bwa/messages/ready.rb
105
+ - lib/bwa/messages/set_temperature.rb
106
+ - lib/bwa/messages/set_temperature_scale.rb
107
+ - lib/bwa/messages/set_time.rb
108
+ - lib/bwa/messages/status.rb
109
+ - lib/bwa/messages/toggle_item.rb
110
+ - lib/bwa/proxy.rb
111
+ - lib/bwa/server.rb
112
+ - lib/bwa/version.rb
113
+ homepage: https://github.com/ccutrer/bwa
114
+ licenses:
115
+ - MIT
116
+ metadata: {}
117
+ post_install_message:
118
+ rdoc_options: []
119
+ require_paths:
120
+ - lib
121
+ required_ruby_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ required_rubygems_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ requirements: []
132
+ rubygems_version: 3.0.3
133
+ signing_key:
134
+ specification_version: 4
135
+ summary: Library for communication with Balboa Water Group's WiFi module or RS-485
136
+ test_files: []