balboa_worldwide_app 1.0.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.
@@ -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: []