waterfurnace_aurora 0.1.1

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.
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