waterfurnace_aurora 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/aurora.rb ADDED
@@ -0,0 +1,15 @@
1
+ require 'rmodbus'
2
+
3
+ require 'aurora/abc_client'
4
+ require 'aurora/iz2_zone'
5
+ require 'aurora/modbus/server'
6
+ require 'aurora/modbus/slave'
7
+ require 'aurora/registers'
8
+
9
+ # extend ModBus for WaterFurnace's custom functions
10
+ ModBus::RTUServer.include(Aurora::ModBus::Server)
11
+ ModBus::Client::Slave.prepend(Aurora::ModBus::Slave)
12
+ ModBus::RTUSlave.prepend(Aurora::ModBus::RTU)
13
+
14
+ module Aurora
15
+ end
@@ -0,0 +1,82 @@
1
+ module Aurora
2
+ class ABCClient
3
+ attr_reader :modbus_slave,
4
+ :iz2_zones,
5
+ :current_mode,
6
+ :fan_speed,
7
+ :entering_air_temperature,
8
+ :relative_humidity,
9
+ :leaving_air_temperature,
10
+ :leaving_water_temperature,
11
+ :entering_water_temperature,
12
+ :dhw_water_temperature,
13
+ :waterflow,
14
+ :compressor_speed,
15
+ :outdoor_temperature,
16
+ :fp1,
17
+ :fp2
18
+
19
+ def initialize(modbus_slave)
20
+ @modbus_slave = modbus_slave
21
+ @modbus_slave.read_retry_timeout = 15
22
+ @modbus_slave.read_retries = 2
23
+ iz2_zone_count = @modbus_slave.holding_registers[483]
24
+ @iz2_zones = (0...iz2_zone_count).map { |i| IZ2Zone.new(self, i + 1) }
25
+ end
26
+
27
+ def refresh
28
+ registers_to_read = [19..20, 30, 344, 740..741, 900, 1110..1111, 1114, 1117, 3027, 31003]
29
+ # IZ2 zones
30
+ iz2_zones.each_with_index do |_z, i|
31
+ base1 = 21203 + i * 9
32
+ base2 = 31007 + i * 3
33
+ base3 = 31200 + i * 3
34
+ registers_to_read << (base1..(base1 + 1))
35
+ registers_to_read << (base2..(base2 + 2))
36
+ registers_to_read << base3
37
+ end
38
+
39
+ registers = @modbus_slave.holding_registers[*registers_to_read]
40
+ Aurora.transform_registers(registers)
41
+
42
+ @fan_speed = registers[344]
43
+ @entering_air_temperature = registers[740]
44
+ @relative_humidity = registers[741]
45
+ @leaving_air_temperature = registers[900]
46
+ @leaving_water_temperature = registers[1110]
47
+ @entering_water_temperature = registers[1111]
48
+ @dhw_water_temperature = registers[1114]
49
+ @waterflow = registers[1117]
50
+ @compressor_speed = registers[3027]
51
+ @outdoor_temperature = registers[31003]
52
+ @fp1 = registers[19]
53
+ @fp2 = registers[20]
54
+ @locked_out = registers[1117]
55
+
56
+ outputs = registers[30]
57
+ if outputs.include?(:lockout)
58
+ @current_mode = :lockout
59
+ elsif outputs.include?(:cc2)
60
+ @current_mode = outputs.include?(:rv) ? :c2 : :h2
61
+ elsif outputs.include?(:cc)
62
+ @current_mode = outputs.include?(:rv) ? :c1 : :h1
63
+ elsif outputs.include?(:eh2)
64
+ @current_mode = :eh2
65
+ elsif outputs.include?(:eh1)
66
+ @current_mode = :eh1
67
+ elsif outputs.include?(:blower)
68
+ @current_mode = :blower
69
+ else
70
+ @current_mode = :standby
71
+ end
72
+
73
+ iz2_zones.each do |z|
74
+ z.refresh(registers)
75
+ end
76
+ end
77
+
78
+ def inspect
79
+ "#<Aurora::ABCClient #{(instance_variables - [:@modbus_slave]).map { |iv| "#{iv}=#{instance_variable_get(iv).inspect}" }.join(', ')}>"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,99 @@
1
+ module Aurora
2
+ class IZ2Zone
3
+ attr_reader :zone_number,
4
+ :target_mode,
5
+ :current_mode,
6
+ :target_fan_mode,
7
+ :current_fan_mode,
8
+ :fan_intermittent_on,
9
+ :fan_intermittent_off,
10
+ :priority,
11
+ :size, :normalized_size,
12
+ :ambient_temperature,
13
+ :cooling_target_temperature,
14
+ :heating_target_temperature
15
+
16
+ def initialize(abc, zone_number)
17
+ @abc = abc
18
+ @zone_number = zone_number
19
+ end
20
+
21
+ def refresh(registers)
22
+ @ambient_temperature = registers[31007 + (zone_number - 1) * 3]
23
+
24
+ config1 = registers[31008 + (zone_number - 1) * 3]
25
+ config2 = registers[31009 + (zone_number - 1) * 3]
26
+ config3 = registers[31200 + (zone_number - 1) * 3]
27
+
28
+ @target_fan_mode = config1[:fan]
29
+ @fan_intermittent_on = config1[:on_time]
30
+ @fan_intermittent_off = config1[:off_time]
31
+ @cooling_target_temperature = config1[:cooling_target_temperature]
32
+ @heating_target_temperature = config2[:heating_target_temperature]
33
+ @target_mode = config2[:mode]
34
+ @current_mode = config2[:call]
35
+ @current_fan_mode = config2[:damper] == :open
36
+
37
+ @priority = config3[:zone_priority]
38
+ @size = config3[:zone_size]
39
+ @normalized_size = config3[:normalized_size]
40
+ end
41
+
42
+ def target_mode=(value)
43
+ value = Aurora::HEATING_MODE.invert[value]
44
+ return unless value
45
+ @abc.modbus_slave.holding_registers[21202 + (zone_number - 1) * 9] = value
46
+ @target_mode = Aurora::HEATING_MODE[@abc.modbus_slave.holding_registers[21202 + (zone_number - 1) * 9]]
47
+ end
48
+
49
+ def target_fan_mode=(value)
50
+ value = Aurora::FAN_MODE.invert[value]
51
+ return unless value
52
+ @abc.modbus_slave.holding_registers[21205 + (zone_number - 1) * 9] = value
53
+ registers = @abc.modbus_slave.read_multiple_holding_registers(31008 + (zone_number - 1) * 3)
54
+ Aurora.transform_registers(registers)
55
+ @target_fan_mode = registers.first.last[:fan]
56
+ end
57
+
58
+ def fan_intermittent_on=(value)
59
+ return unless value >= 0 && value <= 25 && value % 5 == 0
60
+ @abc.modbus_slave.holding_registers[21206 + (zone_number - 1) * 9] = value
61
+ registers = @abc.modbus_slave.read_multiple_holding_registers(31008 + (zone_number - 1) * 3)
62
+ Aurora.transform_registers(registers)
63
+ @fan_intermittent_on = registers.first.last[:on_time]
64
+ end
65
+
66
+ def fan_intermittent_off=(value)
67
+ return unless value >= 0 && value <= 40 && value % 5 == 0
68
+ @abc.modbus_slave.holding_registers[21207 + (zone_number - 1) * 9] = value
69
+ registers = @abc.modbus_slave.read_multiple_holding_registers(31008 + (zone_number - 1) * 3)
70
+ Aurora.transform_registers(registers)
71
+ @fan_intermittent_on = registers.first.last[:off_time]
72
+ end
73
+
74
+ def heating_target_temperature=(value)
75
+ return unless value >= 40 && value <= 90
76
+ value = (value * 10).to_i
77
+ @abc.modbus_slave.holding_registers[21203 + (zone_number - 1) * 9] = value
78
+
79
+ base = 31008 + (zone_number - 1) * 3
80
+ registers = @abc.modbus_slave.read_multiple_holding_registers(base..(base + 1))
81
+ Aurora.transform_registers(registers)
82
+ registers[base + 1][:heating_target_temperature]
83
+ end
84
+
85
+ def cooling_target_temperature=(value)
86
+ return unless value >= 54 && value <= 99
87
+ value = (value * 10).to_i
88
+ @abc.modbus_slave.holding_registers[21204 + (zone_number - 1) * 9] = value
89
+
90
+ registers = @abc.modbus_slave.read_multiple_holding_registers(31008 + (zone_number - 1) * 3)
91
+ Aurora.transform_registers(registers)
92
+ registers.first.last[:cooling_target_temperature]
93
+ end
94
+
95
+ def inspect
96
+ "#<Aurora::IZ2Zone #{(instance_variables - [:@abc]).map { |iv| "#{iv}=#{instance_variable_get(iv).inspect}" }.join(', ')}>"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,72 @@
1
+ module Aurora
2
+ module ModBus
3
+ module Server
4
+ def parse_request(func, req)
5
+ case func
6
+ when 65
7
+ # 1 function register, a multiple of two words
8
+ return unless (req.length - 1) % 4 == 0
9
+ params = []
10
+ req[1..-1].unpack("n*").each_slice(2) do |(addr, quant)|
11
+ params << { addr: addr, quant: quant }
12
+ end
13
+ params
14
+ when 66
15
+ return unless (req.length - 1) % 2 == 0
16
+ req[1..-1].unpack("n*")
17
+ when 67
18
+ # 1 function register, a multiple of two words
19
+ return unless (req.length - 1) % 4 == 0
20
+ params = []
21
+ req[1..-1].unpack("n*").each_slice(2) do |(addr, val)|
22
+ params << { addr: addr, val: val }
23
+ end
24
+ params
25
+ when 68
26
+ return unless req.length == 5
27
+ { noidea1: req[1,2].unpack("n"), noidea2: req[3,2].unpack("n") }
28
+ else
29
+ super
30
+ end
31
+ end
32
+
33
+ def parse_response(func, res)
34
+ return {} if func == 67 && res.length == 1
35
+ return { noidea: res[-1].ord } if func == 68 && res.length == 2
36
+ func = 3 if func == 65 || func == 66
37
+ super
38
+ end
39
+
40
+ def process_func(func, slave, req, params)
41
+ case func
42
+ when 65
43
+ pdu = ""
44
+ params.each do |param|
45
+ if (err = validate_read_func(param, slave.holding_registers))
46
+ return (func | 0x80).chr + err.chr
47
+ end
48
+
49
+ pdu += slave.holding_registers[param[:addr],param[:quant]].pack('n*')
50
+ end
51
+ pdu = func.chr + pdu.length.chr + pdu
52
+ pdu
53
+ when 66
54
+ pdu = params.map { |addr| slave.holding_registers[addr] }.pack('n*')
55
+ pdu = func.chr + pdu.length.chr + pdu
56
+ pdu
57
+ when 67
58
+ slave.holding_registers[param[:addr]] = param[:val]
59
+ pdu = req[0,2]
60
+ else
61
+ super
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # 65 => read multiple discontiguous register ranges (command is a list of pairs of addr and quant)
69
+ # 66 => read multiple discontiguous registers (command is a list of addrs)
70
+ # 67 => write multiple discontiguous registers (command is a list of pairs of addr and value; response has no content)
71
+ # 68 => ?? request has 4 bytes, response has 1 byte that seems to be 0 (for success?)
72
+ ModBus::Server::Funcs.concat([65, 66, 67, 68])
@@ -0,0 +1,52 @@
1
+ module Aurora
2
+ module ModBus
3
+ module Slave
4
+ def read_multiple_holding_registers(*ranges)
5
+ values = if ranges.any? { |r| r.is_a?(Range) }
6
+ addrs_and_lengths = ranges.map { |r| r = Array(r); [r.first, r.last - r.first + 1] }.flatten
7
+ query("\x41" + addrs_and_lengths.pack("n*")).unpack("n*")
8
+ else
9
+ query("\x42" + ranges.pack("n*")).unpack("n*")
10
+ end
11
+ ranges.map { |r| Array(r) }.flatten.zip(values).to_h
12
+ end
13
+
14
+ def holding_registers
15
+ WFProxy.new(self, :holding_register)
16
+ end
17
+ end
18
+
19
+ class WFProxy < ::ModBus::ReadWriteProxy
20
+ def [](*keys)
21
+ return super if keys.length == 1
22
+ @slave.read_multiple_holding_registers(*keys)
23
+ end
24
+ end
25
+
26
+ module RTU
27
+ def read_rtu_response(io)
28
+ # Read the slave_id and function code
29
+ msg = read(io, 2)
30
+ log logging_bytes(msg)
31
+
32
+ function_code = msg.getbyte(1)
33
+ case function_code
34
+ when 1,2,3,4,65,66 then
35
+ # read the third byte to find out how much more
36
+ # we need to read + CRC
37
+ msg += read(io, 1)
38
+ msg += read(io, msg.getbyte(2)+2)
39
+ when 5,6,15,16 then
40
+ # We just read in an additional 6 bytes
41
+ msg += read(io, 6)
42
+ when 22 then
43
+ msg += read(io, 8)
44
+ when 0x80..0xff then
45
+ msg += read(io, 3)
46
+ else
47
+ raise ModBus::Errors::IllegalFunction, "Illegal function: #{function_code}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,660 @@
1
+ module Aurora
2
+ extend self
3
+
4
+ def normalize_ranges(ranges)
5
+ registers = ranges.map { |r| Array(r) }.flatten.sort.uniq
6
+ result = []
7
+ totals = []
8
+ run_start = nil
9
+ count = 0
10
+ registers.each_with_index do |r, i|
11
+ run_start ||= r
12
+ if i + 1 == registers.length || r + 1 != registers[i + 1]
13
+ if r == run_start
14
+ result << r
15
+ if (count += 1) == 100
16
+ totals << result
17
+ result = []
18
+ count = 0
19
+ end
20
+ else
21
+ range = run_start..r
22
+ if count + range.count > 100
23
+ totals << result
24
+ result = []
25
+ count = 0
26
+ end
27
+ count += range.count
28
+ result << range
29
+ end
30
+ run_start = nil
31
+ end
32
+ end
33
+ totals << result unless result.empty?
34
+ totals
35
+ end
36
+
37
+ TO_HUNDREDTHS = ->(v) { v.to_f / 100 }
38
+ TO_TENTHS = ->(v) { v.to_f / 10 }
39
+ TO_LAST_LOCKOUT = ->(v) { v & 0x8000 == 0x8000 ? v & 0x7fff : nil }
40
+ NEGATABLE = ->(v) { v & 0x8000 == 0x8000 ? v - 0x10000 : v }
41
+
42
+ def from_bitmask(v, flags)
43
+ result = []
44
+ flags.each do |(bit, flag)|
45
+ result << flag if v & bit == bit
46
+ v &= ~bit
47
+ end
48
+ result << "0x%04x" % v if v != 0
49
+ result
50
+ end
51
+
52
+ def to_string(registers, idx, length)
53
+ puts "converting #{idx} of length #{length}"
54
+ (idx...(idx + length)).map do |i|
55
+ (registers[i] >> 8).chr + (registers[i] & 0xff).chr
56
+ end.join.sub(/[ \0]+$/, '')
57
+ end
58
+
59
+ FAULTS = {
60
+ 1 => "Input Flt Limit",
61
+ 2 => "High Pressure",
62
+ 3 => "Low Pressure",
63
+ 4 => "FP2",
64
+ 5 => "FP1",
65
+ 7 => "Condensate Limit",
66
+ 8 => "Over/Under Voltage",
67
+ 9 => "AirF/RPM",
68
+ 10 => "CompMon",
69
+ 11 => "FP1/2 Snr Limit",
70
+ 12 => "RefPerfrm Limit",
71
+ 13 => "NCrtAxbSr Limit",
72
+ 14 => "CrtAxbSnr Limit",
73
+ 15 => "HW Limit",
74
+ 16 => "VSPumpFlt Limit",
75
+ 17 => "CommTStat Limit",
76
+ 18 => "NCritComm Limit",
77
+ 19 => "Crit Comm Limit",
78
+ 21 => "Low Loop Pressure",
79
+ 22 => "ComEcmErr Limit",
80
+ 23 => "HAGenAlrm1 Limit",
81
+ 24 => "HAGenAlrm2 Limit",
82
+ 25 => "AxbEevErr Limit",
83
+ 41 => "High Drive Temp Limit",
84
+ 42 => "High Discharge Temp Limit",
85
+ 43 => "Low Suction Pressure Limit",
86
+ 44 => "Low Con Pressure Limit",
87
+ 45 => "High Con Pressure Limit",
88
+ 46 => "Out Power Limit",
89
+ 47 => "EevIDComm Limit",
90
+ 48 => "EevODComm Limit",
91
+ 49 => "CabTmpSnr Limit",
92
+ 51 => "Discharge Temp Sensor Limit",
93
+ 52 => "Suction Presure Sensor Limit",
94
+ 53 => "Con Pressure Sensor Limit",
95
+ 54 => "Low Supply Voltage Limit",
96
+ 55 => "OutEnvelp Limit",
97
+ 56 => "Suction Pressure Sensor Limit",
98
+ 57 => "Drive Over/Under Voltage Limit",
99
+ 58 => "High Drive Temp Limit",
100
+ 59 => "Internal Drive Error Limit",
101
+ 61 => "MultSafm",
102
+ 71 => "ChrgLoss",
103
+ 72 => "Suction Temp Sensor Limit",
104
+ 73 => "Leaving Air Temp Sensor Limit",
105
+ 74 => "Maximum Operating Pressure Limit",
106
+ 75 => "Charge Loss",
107
+ 76 => "Suction Temperatur Sensor Limit",
108
+ 77 => "Leaving Air Temperature Sensor Limit",
109
+ 78 => "Maximum Operating Pressure Limit",
110
+ }
111
+
112
+ AR_SETTINGS = {
113
+ 0 => "Cycle with Compressor",
114
+ 1 => "Cycle with Thermostat Humidification Call",
115
+ 2 => "Slow Opening Water Valve",
116
+ 3 => "Cycle with Blower"
117
+ }
118
+
119
+ def dipswitch_settings(v)
120
+ return :manual if v == 0x7fff
121
+ {
122
+ fp1: v & 0x01 == 0x01 ? "30ºF" : "15ºF",
123
+ fp2: v & 0x02 == 0x02 ? "30ºF" : "15ºF",
124
+ rv: v & 0x04 == 0x04 ? "O" : "B",
125
+ ar: AR_SETTINGS[(v >> 3) & 0x7],
126
+ cc: v & 0x20 == 0x20 ? "Single Stage" : "Dual Stage",
127
+ lo: v & 0x40 == 0x40 ? "Continouous" : "Pulse",
128
+ dh_rh: v & 0x80 == 0x80 ? "Dehumdifier On" : "Reheat On",
129
+ }
130
+ end
131
+
132
+ SYSTEM_OUTPUTS = {
133
+ 0x01 => :cc, # compressor stage 1
134
+ 0x02 => :cc2, # compressor stage 2
135
+ 0x04 => :rv, # reversing valve (cool instead of heat)
136
+ 0x08 => :blower,
137
+ 0x10 => :eh1,
138
+ 0x20 => :eh2,
139
+ 0x200 => :accessory,
140
+ 0x400 => :lockout,
141
+ 0x800 => :alarm,
142
+ }
143
+
144
+ SYSTEM_INPUTS = {
145
+ 0x01 => "Y1",
146
+ 0x02 => "Y2",
147
+ 0x04 => "W",
148
+ 0x08 => "O",
149
+ 0x10 => "G",
150
+ 0x20 => "Dehumidifer",
151
+ 0x40 => "Emergency Shutdown",
152
+ 0x200 => "Load Shed",
153
+ }
154
+
155
+ def status(v)
156
+ result = {
157
+ lps: v & 0x80 == 0x80 ? :closed : :open,
158
+ hps: v & 0x100 == 0x100 ? :closed : :open,
159
+ }
160
+ result[:load_shed] = true if v & 0x0200 == 0x0200
161
+ result[:emergency_shutdown] = true if v & 0x0040 == 0x0040
162
+ leftover = v & ~0x03c0
163
+ result[:unknown] = "0x%04x" % leftover unless leftover == 0
164
+ result
165
+ end
166
+
167
+ VS_DRIVE_DERATE = {
168
+ 0x01 => "Drive Over Temp",
169
+ 0x04 => "Low Suction Pressure",
170
+ 0x10 => "Low Discharge Pressure",
171
+ 0x20 => "High Discharge Pressure",
172
+ 0x40 => "Output Power Limit",
173
+ }
174
+
175
+ VS_SAFE_MODE = {
176
+ 0x01 => "EEV Indoor Failed",
177
+ 0x02 => "EEV Outdoor Failed",
178
+ 0x04 => "Invalid Ambient Temp",
179
+ }
180
+
181
+ VS_ALARM1 = {
182
+ 0x8000 => "Internal Error",
183
+ }
184
+
185
+ VS_ALARM2 = {
186
+ 0x0001 => "Multi Safe Modes",
187
+ 0x0002 => "Out of Envelope",
188
+ 0x0004 => "Over Current",
189
+ 0x0008 => "Over Voltage",
190
+ 0x0010 => "Drive Over Temp",
191
+ 0x0020 => "Under Voltage",
192
+ 0x0040 => "High Discharge Temp",
193
+ 0x0080 => "Invalid Discharge Temp",
194
+ 0x0100 => "OEM Communications Timeout",
195
+ 0x0200 => "MOC Safety",
196
+ 0x0400 => "DC Under Voltage",
197
+ 0x0800 => "Invalid Suction Pressure",
198
+ 0x1000 => "Invalid Discharge Pressure",
199
+ 0x2000 => "Low Discharge Pressure",
200
+ }
201
+
202
+ VS_EEV2 = {
203
+ 0x0010 => "Invalid Suction Temperature",
204
+ 0x0020 => "Invalid Leaving Air Temperature",
205
+ 0x0040 => "Invalid Suction Pressure",
206
+
207
+ }
208
+
209
+ AXB_INPUTS = {
210
+ }
211
+
212
+ AXB_OUTPUTS = {
213
+ 0x10 => "Accessory 2",
214
+ 0x02 => "Loop Pump",
215
+ 0x01 => "DHW"
216
+ }
217
+
218
+ HEATING_MODE = {
219
+ 0 => :off,
220
+ 1 => :auto,
221
+ 2 => :cool,
222
+ 3 => :heat,
223
+ 4 => :eheat
224
+ }
225
+
226
+ FAN_MODE = {
227
+ 0 => :auto,
228
+ 1 => :continuous,
229
+ 2 => :intermittent
230
+ }
231
+
232
+ HUMIDIFIER_SETTINGS = {
233
+ 0x4000 => :auto_dehumidification,
234
+ 0x8000 => :auto_humidification,
235
+ }
236
+
237
+ INVERSE_HUMIDIFIER_SETTINGS = {
238
+ 0x4000 => :manual_dehumidification,
239
+ 0x8000 => :manual_humidification,
240
+ }
241
+
242
+ ZONE_SIZES = {
243
+ 0 => 0,
244
+ 1 => 25,
245
+ 2 => 45,
246
+ 3 => 70,
247
+ }
248
+
249
+ CALLS = {
250
+ 0x0 => :standby,
251
+ 0x1 => :unknown1,
252
+ 0x2 => :h1,
253
+ 0x3 => :h2,
254
+ 0x4 => :h3,
255
+ 0x5 => :c1,
256
+ 0x6 => :c2,
257
+ 0x7 => :unknown7,
258
+ }
259
+
260
+ def iz2_demand(v)
261
+ {
262
+ fan_demand: v >> 8,
263
+ unit_demand: v & 0xff,
264
+ }
265
+ end
266
+
267
+ def zone_configuration1(v)
268
+ fan = if v & 0x80 == 0x80
269
+ :continuous
270
+ elsif v & 0x100 == 0x100
271
+ :intermittent
272
+ else
273
+ :auto
274
+ end
275
+ result = {
276
+ fan: fan,
277
+ on_time: ((v >> 9) & 0x7) * 5,
278
+ off_time: (((v >> 12) & 0x7) + 1) * 5,
279
+ cooling_target_temperature: ((v & 0x7e) >> 1) + 36,
280
+ heating_target_temperature_carry: v & 01
281
+ }
282
+ leftover = v & ~0x7fff
283
+ result[:unknown] = "0x%04x" % leftover unless leftover == 0
284
+ result
285
+ end
286
+
287
+ def zone_configuration2(registers, k)
288
+ prior_v = registers[k - 1] if registers.key?(k - 1)
289
+ v = registers[k]
290
+ result = {
291
+ call: CALLS[(v >> 1) & 0x7],
292
+ mode: HEATING_MODE[(v >> 8) & 0x03],
293
+ damper: v & 0x10 == 0x10 ? :open : :closed
294
+ }
295
+ if prior_v
296
+ carry = prior_v.is_a?(Hash) ? prior_v[:heating_target_temperature_carry] : v & 0x01
297
+ result[:heating_target_temperature] = ((carry << 5) | ((v & 0xf800) >> 11)) + 36
298
+ end
299
+ leftover = v & ~0xfb1e
300
+ result[:unknown] = "0x%04x" % leftover unless leftover == 0
301
+ result
302
+ end
303
+
304
+ # hi order byte is normalized zone size
305
+ def zone_configuration3(v)
306
+ size = (v >> 3 ) & 0x3
307
+ result = {
308
+ zone_priority: (v & 0x20) == 0x20 ? :economy : :comfort,
309
+ zone_size: ZONE_SIZES[size],
310
+ normalized_size: v >> 8,
311
+ }
312
+ leftover = v & ~0xff38
313
+ result[:unknown] = "0x%04x" % leftover unless leftover == 0
314
+ result
315
+ end
316
+
317
+ # intermittent on time allowed: 0, 5, 10, 15, 20
318
+ # intermittent off time allowed: 5, 10, 15, 20, 25, 30, 35, 40
319
+
320
+ REGISTER_CONVERTERS = {
321
+ TO_HUNDREDTHS => [2, 3, 807, 813, 816, 817, 819, 820, 825, 828],
322
+ method(:dipswitch_settings) => [4, 33],
323
+ TO_TENTHS => [19, 20, 401, 567, 740, 745, 746, 900, 1105, 1106, 1107, 1108, 1110, 1111, 1114, 1117, 1134, 1136,
324
+ 21203, 21204,
325
+ 21212, 21213,
326
+ 21221, 21222,
327
+ 21230, 22131,
328
+ 21239, 21240,
329
+ 21248, 21249,
330
+ 31003,
331
+ 31007, 31010, 31013, 31016, 31019, 31022],
332
+ TO_LAST_LOCKOUT => [26],
333
+ ->(v) { from_bitmask(v, SYSTEM_OUTPUTS) } => [27, 30],
334
+ ->(v) { from_bitmask(v, SYSTEM_INPUTS) } => [28],
335
+ method(:status) => [31],
336
+ ->(registers, idx) { to_string(registers, idx, 4) } => [88],
337
+ ->(registers, idx) { to_string(registers, idx, 12) } => [92],
338
+ ->(registers, idx) { to_string(registers, idx, 5) } => [105],
339
+ ->(v) { from_bitmask(v, VS_DRIVE_DERATE) } => [214],
340
+ ->(v) { from_bitmask(v, VS_SAFE_MODE) } => [216],
341
+ ->(v) { from_bitmask(v, VS_ALARM1) } => [217],
342
+ ->(v) { from_bitmask(v, VS_ALARM2) } => [218],
343
+ ->(v) { from_bitmask(v, VS_EEV2) } => [280],
344
+ NEGATABLE => [346, 1146],
345
+ ->(v) { from_bitmask(v, AXB_INPUTS) } => [1103],
346
+ ->(v) { from_bitmask(v, AXB_OUTPUTS) } => [1104],
347
+ ->(v) { TO_TENTHS.call(NEGATABLE.call(v)) } => [1136],
348
+ ->(v) { HEATING_MODE[v] } => [21202, 21211, 21220, 21229, 21238, 21247],
349
+ ->(v) { FAN_MODE[v] } => [21205, 21214, 21223, 21232, 21241, 21250],
350
+ ->(v) { from_bitmask(v, HUMIDIFIER_SETTINGS) } => [31109],
351
+ ->(v) { { humidification_target: v >> 8, dehumidification_target: v & 0xff } } => [31110],
352
+ method(:iz2_demand) => [31005],
353
+ method(:zone_configuration1) => [31008, 31011, 31014, 31017, 31020, 31023],
354
+ method(:zone_configuration2) => [31009, 31012, 31015, 31018, 31021, 31024],
355
+ method(:zone_configuration3) => [31200, 31203, 31206, 31209, 31212, 31215],
356
+ ->(registers, idx) { to_string(registers, idx, 13) } => [31400],
357
+ ->(registers, idx) { to_string(registers, idx, 8) } => [31413],
358
+ ->(registers, idx) { to_string(registers, idx, 13) } => [31421],
359
+ ->(registers, idx) { to_string(registers, idx, 13) } => [31434],
360
+ ->(registers, idx) { to_string(registers, idx, 13) } => [31447],
361
+ ->(registers, idx) { to_string(registers, idx, 13) } => [31460],
362
+ }
363
+
364
+ REGISTER_FORMATS = {
365
+ "%ds" => [1, 6, 9, 15, 84, 85],
366
+ "%dV" => [16, 112],
367
+ "%0.1fºF" => [19, 20, 401, 567, 740, 745, 746, 900, 1110, 1111, 1114, 1134, 1136,
368
+ 21203, 21204,
369
+ 21212, 21213,
370
+ 21221, 21222,
371
+ 21230, 21231,
372
+ 21239, 21240,
373
+ 21248, 21249,
374
+ 31003,
375
+ 31007, 31010, 31013, 31016, 31019, 31022],
376
+ "E%d" => [25, 26],
377
+ "%d%%" => [282, 321, 322, 346, 565, 741],
378
+ "%0.1fA" => [1105, 1106, 1107, 1108],
379
+ "%0.1fgpm" => [1117],
380
+ "%dW" => [1147, 1149, 1151, 1153, 1165],
381
+ "%dBtuh" => [1157],
382
+ }
383
+
384
+ def ignore(range)
385
+ range.zip(Array.new(range.count)).to_h
386
+ end
387
+
388
+ def faults(range)
389
+ range.map { |i| [i, "E#{i % 100}"] }.to_h
390
+ end
391
+
392
+ def zone_registers
393
+ (1..6).map do |i|
394
+ base1 = 21202 + (i - 1) * 9
395
+ base2 = 31007 + (i - 1) * 3
396
+ base3 = 31200 + (i - 1) * 3
397
+ {
398
+ base1 => "Zone #{i} Heating Mode",
399
+ (base1 + 1) => "Zone #{i} Heating Setpoint (write)",
400
+ (base1 + 2) => "Zone #{i} Cooling Setpoint (write)",
401
+ (base1 + 3) => "Zone #{i} Fan Mode (write)",
402
+ (base1 + 4) => "Zone #{i} Intermittent Fan On Time (write)",
403
+ (base1 + 5) => "Zone #{i} Intermittent Fan Off Time (write)",
404
+ base2 => "Zone #{i} Ambient Temperature",
405
+ (base2 + 1) => "Zone #{i} Configuration 1",
406
+ (base2 + 2) => "Zone #{i} Configuration 2",
407
+ base3 => "Zone #{i} Configuration 3",
408
+ }
409
+ end.inject({}, &:merge)
410
+ end
411
+
412
+ WRITEABLE = [112, 340, 341, 342, 346, 347]
413
+
414
+ # these are the valid ranges (i.e. the ABC will return _some_ value)
415
+ # * means 6 sequential ranges of equal size (i.e. must be repeated for each
416
+ # IZ2 zone)
417
+ # ==================================================================
418
+ REGISTER_RANGES = [
419
+ 0..155,
420
+ 170..253,
421
+ 260..260,
422
+ 280..288,
423
+ 300..301,
424
+ 320..326,
425
+ 340..348,
426
+ 360..368,
427
+ 400..419,
428
+ 440..516,
429
+ 550..573,
430
+ 600..749,
431
+ 800..913,
432
+ 1090..1165,
433
+ 1200..1263,
434
+ 2000..2026,
435
+ 2100..2129,
436
+ 2800..2849,
437
+ 2900..2915,
438
+ 2950..2959,
439
+ 3000..3003,
440
+ 3020..3030,
441
+ 3040..3049,
442
+ 3060..3063,
443
+ 3100..3105,
444
+ 3108..3115,
445
+ 3118..3119,
446
+ 3200..3253,
447
+ 3300..3332,
448
+ 3400..3431,
449
+ 3500..3524,
450
+ 3600..3609,
451
+ 3618..3634,
452
+ 3700..3714,
453
+ 3800..3809,
454
+ 3818..3834,
455
+ 3900..3914,
456
+ 12000..12019,
457
+ 12098..12099,
458
+ 12100..12119,
459
+ 12200..12239,
460
+ 12300..12319,
461
+ 12400..12569,
462
+ 12600..12639,
463
+ 12700..12799,
464
+ 20000..20099,
465
+ 21100..21136,
466
+ 21200..21265,
467
+ 21400..21472,
468
+ 21500..21589,
469
+ 22100..22162, # *
470
+ 22200..22262, # *
471
+ 22300..22362, # *
472
+ 22400..22462, # *
473
+ 22500..22562, # *
474
+ 22600..22662, # *
475
+ 30000..30099,
476
+ 31000..31034,
477
+ 31100..31129,
478
+ 31200..31229,
479
+ 31300..31329,
480
+ 31400..31472,
481
+ 32100..32162, # *
482
+ 32200..32262, # *
483
+ 32300..32362, # *
484
+ 32400..32462, # *
485
+ 32500..32562, # *
486
+ 32600..32662, # *
487
+ 60050..60053,
488
+ 60100..60109,
489
+ 60200..60200,
490
+ 61000..61009
491
+ ]
492
+
493
+ def read_all_registers(modbus_slave)
494
+ result = []
495
+ REGISTER_RANGES.each do |range|
496
+ # read at most 100 at a time
497
+ range.each_slice(100) do |keys|
498
+ result.concat(modbus_slave.holding_registers[keys.first..keys.last])
499
+ end
500
+ end
501
+ REGISTER_RANGES.map(&:to_a).flatten.zip(result).to_h
502
+ end
503
+
504
+ def diff_registers(r1, r2)
505
+ diff = {}
506
+ r1.each_key do |k|
507
+ diff[k] = [r1[k], r2[k]] if r1[k] != r2[k]
508
+ end
509
+ diff
510
+ end
511
+
512
+ REGISTER_NAMES = {
513
+ 1 => "Random Start Delay",
514
+ 2 => "ABC Program Version",
515
+ 3 => "IZ2 Version?",
516
+ 4 => "DIP Switch Override",
517
+ 6 => "Compressor Anti-Short Cycle Delay",
518
+ 8 => "Unit Type?",
519
+ 9 => "Compressor Minimum Run Time",
520
+ 15 => "Blower Off Delay",
521
+ 16 => "Line Voltage",
522
+ 19 => "FP1",
523
+ 20 => "FP2",
524
+ 21 => "Condensate", # >= 270 normal, otherwise fault
525
+ 25 => "Last Fault Number",
526
+ 26 => "Last Lockout",
527
+ 27 => "System Outputs (At Last Lockout)",
528
+ 28 => "System Inputs (At Last Lockout)",
529
+ 30 => "System Outputs",
530
+ 31 => "Status",
531
+ 33 => "DIP Switch Status",
532
+ 50 => "ECM Speed Low (== 5)",
533
+ 51 => "ECM Speed Med (== 5)",
534
+ 52 => "ECM Speed High (== 5)",
535
+ 54 => "ECM Speed Actual",
536
+ 84 => "Slow Opening Water Valve Delay",
537
+ 85 => "Test Mode Timer",
538
+ 88 => "ABC Program",
539
+ 92 => "Model Number",
540
+ 105 => "Serial Number",
541
+ 112 => "Setup Line Voltage",
542
+ 201 => "Discharge Pressure", # I can't figure out how this number is represented;
543
+ 203 => "Suction Pressure",
544
+ 205 => "Discharge Temperature",
545
+ 207 => "Loop Entering Water Temperature",
546
+ 209 => "Compressor Ambient Temperature",
547
+ 211 => "VS Drive Details (General 1)",
548
+ 212 => "VS Drive Details (General 2)",
549
+ 213 => "VS Drive Details (Derate 1)",
550
+ 214 => "VS Drive Details (Derate 2)",
551
+ 215 => "VS Drive Details (Safemode 1)",
552
+ 216 => "VS Drive Details (Safemode 2)",
553
+ 217 => "VS Drive Details (Alarm 1)",
554
+ 218 => "VS Drive Details (Alarm 2)",
555
+ 280 => "EEV2 Ctl",
556
+ 281 => "EEV Superheat", # ?? data format
557
+ 282 => "EEV Open %",
558
+ 283 => "Suction Temperature", ## ?? data format
559
+ 284 => "Saturated Suction Temperature", ## ?? data format
560
+ 321 => "VS Pump Min",
561
+ 322 => "VS Pump Max",
562
+ 340 => "Blower Only Speed",
563
+ 341 => "Lo Compressor ECM Speed",
564
+ 342 => "Hi Compressor ECM Speed",
565
+ 344 => "ECM Speed",
566
+ 346 => "Cooling Airflow Adjustment",
567
+ 347 => "Aux Heat ECM Speed",
568
+ 362 => "Active Dehumidify", # any value is true
569
+ 401 => "DHW Setpoint",
570
+ 414 => "On Peak/SmartGrid 2", # 0x0001 only
571
+ 483 => "Number of IZ2 Zones",
572
+ 564 => "IZ2 Compressor Speed Desired",
573
+ 565 => "IZ2 Blower % Desired",
574
+ 567 => "Entering Air",
575
+ 740 => "Entering Air",
576
+ 741 => "Relative Humidity",
577
+ 745 => "Heating Set Point",
578
+ 746 => "Cooling Set Point",
579
+ 807 => "AXB Version",
580
+ 813 => "IZ2 Version?",
581
+ 816 => "AOC Version 1?",
582
+ 817 => "AOC Version 2?",
583
+ 819 => "MOC Version 1?",
584
+ 820 => "MOC Version 2?",
585
+ 825 => "EEV2 Version",
586
+ 828 => "AWL Version",
587
+ 900 => "Leaving Air",
588
+ 1103 => "AXB Inputs",
589
+ 1104 => "AXB Outputs",
590
+ 1105 => "Blower Amps",
591
+ 1106 => "Aux Amps",
592
+ 1107 => "Compressor 1 Amps",
593
+ 1108 => "Compressor 2 Amps",
594
+ 1109 => "Heating Liquid Line",
595
+ 1110 => "Leaving Water",
596
+ 1111 => "Entering Water",
597
+ 1114 => "DHW Temp",
598
+ 1117 => "Waterflow",
599
+ 1134 => "Saturated Discharge Temperature",
600
+ 1135 => "SubCooling",
601
+ 1147 => "Compressor Watts",
602
+ 1149 => "Blower Watts",
603
+ 1151 => "Aux Watts",
604
+ 1153 => "Total Watts",
605
+ 1157 => "Ht of Rej",
606
+ 1165 => "VS Pump Watts",
607
+ 3027 => "Compressor Speed",
608
+ 31003 => "Outdoor Temp",
609
+ 31005 => "IZ2 Demand",
610
+ 31109 => "Humidifier Mode", # write to 21114
611
+ 31110 => "Manual De/Humidification Target", # write to 21115
612
+ 31400 => "Dealer Name",
613
+ 31413 => "Dealer Phone",
614
+ 31421 => "Dealer Address 1",
615
+ 31434 => "Dealer Address 2",
616
+ 31447 => "Dealer Email",
617
+ 31460 => "Dealer Website",
618
+ }.merge(ignore(89..91)).
619
+ merge(ignore(93..104)).
620
+ merge(ignore(106..109)).
621
+ merge(faults(601..699)).
622
+ merge(zone_registers).
623
+ merge(ignore(31401..31412)).
624
+ merge(ignore(31414..31420)).
625
+ merge(ignore(31422..31433)).
626
+ merge(ignore(31435..31446)).
627
+ merge(ignore(31447..31459)).
628
+ merge(ignore(31461..31472))
629
+
630
+ def transform_registers(registers)
631
+ registers.each do |(k, v)|
632
+ value_proc = REGISTER_CONVERTERS.find { |(_, z)| z.include?(k) }&.first
633
+ next unless value_proc
634
+
635
+ value = value_proc.arity == 2 ? value_proc.call(registers, k) : value_proc.call(v)
636
+ registers[k] = value
637
+ end
638
+ registers
639
+ end
640
+
641
+ def print_registers(registers)
642
+ result = []
643
+ registers.each do |(k, v)|
644
+ # ignored
645
+ next if REGISTER_NAMES.key?(k) && REGISTER_NAMES[k].nil?
646
+ name = REGISTER_NAMES[k]
647
+ value_proc = REGISTER_CONVERTERS.find { |(_, z)| z.include?(k) }&.first || ->(v) { v }
648
+ format = REGISTER_FORMATS.find { |(_, z)| z.include?(k) }&.first || "%s"
649
+ format = "%1$d (0x%1$04x)" unless name
650
+ name ||= "???"
651
+
652
+ value = value_proc.arity == 2 ? value_proc.call(registers, k) : value_proc.call(v)
653
+ value = value.join(", ") if value.is_a?(Array)
654
+ value = sprintf(format, value) if value
655
+
656
+ result << "#{name} (#{k}): #{value}"
657
+ end
658
+ result.join("\n")
659
+ end
660
+ end