axpert_rs232 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
+ SHA1:
3
+ metadata.gz: feaf14c95a0bb048162f2de460083f56e1564c9a
4
+ data.tar.gz: 124bfdfe33531983e742cff13ce5883ca8163d9f
5
+ SHA512:
6
+ metadata.gz: 4f69247e57e69cd8bee76208639a0fbd55f94743d85b9282fe7f1a300bbc289e85a6c2ae4859d3e55ab5d2417922a88712bd85465d88ff99b85af690bb53e0a7
7
+ data.tar.gz: cd36a586e15a3f5421abf9260b63bcd5c7d35c0499876bf6b3adcf57212d635792d766d2f8bd1cefc281ff07f6e93465eaac4d6595f19822135302f09e3ed5b7
@@ -0,0 +1,543 @@
1
+ ##
2
+ # A list of known commands
3
+ #
4
+ # Commands implemented as constants
5
+ # Each command includes the actual command to be executed and a parser for the results of the command
6
+ #
7
+ # Commands source: http://forums.aeva.asn.au/uploads/293/HS_MS_MSX_RS232_Protocol_20140822_after_current_upgrade.pdf
8
+ #
9
+ # @author: Johan van der Vyver
10
+ module AxpertCommands
11
+ require 'voltronic_device_operation'
12
+ OP = ::VoltronicDeviceOperation # :nodoc
13
+
14
+ ##
15
+ # Device protocol ID
16
+ #
17
+ # Returns:
18
+ # # An Integer specifying the protocol ID
19
+ # 30 # Example
20
+ PROTOCOL_ID = OP.new(command: 'QPI', parser: lambda { |r| Integer(r.data[3..-1]) })
21
+
22
+ ##
23
+ # Device serial number
24
+ #
25
+ # Returns:
26
+ # # A String specifying the device serial number
27
+ # "XXXXXXXXXXXXXX" # Example
28
+ SERIAL_NUMBER = OP.new(command: 'QID', parser: lambda { |r| r.data[1..-1] })
29
+
30
+ ##
31
+ # Main CPU Firmware version
32
+ #
33
+ # Returns:
34
+ # # A String representing the CPU Firmware as a hexidecimal number
35
+ # "124004.10" # Example
36
+ #
37
+ MAIN_CPU_FIRMWARE = OP.new(command: 'QVFW', parser: lambda do |r|
38
+ r.data[8..-1].split('.').map { |s| s.chars.map { |c| Integer(c, 16).to_s }.flatten.join }.join('.').upcase
39
+ end)
40
+
41
+ ##
42
+ # Other CPU Firmware version
43
+ #
44
+ # Returns:
45
+ # # A String representing the CPU Firmware as a hexidecimal number
46
+ # "124004.10" # Example
47
+ OTHER_CPU_FIRMWARE = OP.new(command: 'QVFW2', parser: lambda do |r|
48
+ r.data[8..-1].split('.').map { |s| s.chars.map { |c| Integer(c, 16).to_s }.flatten.join }.join('.').upcase
49
+ end)
50
+
51
+ ##
52
+ # Device rating information
53
+ #
54
+ # Returns:
55
+ # # A Hash containing device rating information
56
+ # { grid_voltage: 230.5, grid_current: 11.2, .. }
57
+ DEVICE_RATING = OP.new(command: 'QPIRI', parser: lambda do |r|
58
+ r = r.data[1..-1].split(' ')
59
+ { grid_voltage: Float(r[0]),
60
+ grid_current: Float(r[1]),
61
+ output_voltage: Float(r[2]),
62
+ output_frequency: Float(r[3]),
63
+ output_current: Float(r[4]),
64
+ output_va: Integer(r[5]),
65
+ output_watts: Integer(r[6]),
66
+ battery_voltage: Float(r[7]),
67
+ battery_recharge_voltage: Float(r[8]),
68
+ battery_under_voltage: Float(r[9]),
69
+ battery_bulk_charge_voltage: Float(r[10]),
70
+ battery_float_charge_voltage: Float(r[11]),
71
+ battery_type: ::AxpertConstants::BATTERY_TYPE[r[12]],
72
+ maximum_ac_charge_current: Integer(r[13]),
73
+ maximum_charge_current: Integer(r[14]),
74
+ input_voltage_sensitivity: CONS::INPUT_VOLTAGE_SENSITIVITY[r[15]],
75
+ output_source_priority: CONS::OUTPUT_SOURCE_PRIORITY[r[16]],
76
+ charger_source_priority: CONS::CHARGER_SOURCE_PRIORITY[r[17]],
77
+ maximum_parallel_units: Integer(r[18]),
78
+ device_type: CONS::DEVICE_TYPE[r[19]],
79
+ device_topology: CONS::DEVICE_TOPOLOGY[r[20]],
80
+ output_mode: CONS::OUTPUT_MODE[r[21]],
81
+ battery_redischarge_voltage: Float(r[22]),
82
+ pv_parallel_ok_mode: CONS::PV_PARALLEL_OK_MODE[r[23]],
83
+ pv_power_balance_mode: CONS::PV_POWER_BALANCE_MODE[r[24]] }
84
+ end)
85
+
86
+
87
+ ##
88
+ # Device flags
89
+ #
90
+ # Returns:
91
+ # # A Hash containing device flag status as booleans
92
+ # {enable_buzzer: true/false, nable_bypass_to_utility_on_overload: true/false, ... }
93
+ DEVICE_FLAGS = OP.new(command: 'QFLAG', parser: lambda do |r|
94
+ r = r.data[1..-1]
95
+ lookup = Hash.new { |_, k| raise "The device did not return the status of the flag '#{k}'" }
96
+ r.scan(/[E][a-z]*/).first.to_s[1..-1].to_s.chars.each { |e| lookup[e.upcase] = true }
97
+ r.scan(/[D][a-z]*/).first.to_s[1..-1].to_s.chars.each { |d| lookup[d.upcase] = false }
98
+ { enable_buzzer: lookup['A'],
99
+ enable_bypass_to_utility_on_overload: lookup['B'],
100
+ enable_power_saving: lookup['J'],
101
+ enable_lcd_timeout_escape_to_default_page: lookup['K'],
102
+ enable_overload_restart: lookup['U'],
103
+ enable_over_temperature_restart: lookup['V'],
104
+ enable_lcd_backlight: lookup['X'],
105
+ enable_primary_source_interrupt_alarm: lookup['Y'],
106
+ enable_fault_code_recording: lookup['Z'] }
107
+ end)
108
+
109
+ ##
110
+ # The current device status
111
+ #
112
+ # Returns:
113
+ # # A Hash containing device status information
114
+ # { grid_voltage: 230.5, grid_current: 11.2, .. }
115
+ DEVICE_STATUS = OP.new(command: 'QPIGS', parser: lambda do |r|
116
+ r = r.data[1..-1].split(' ')
117
+ status = r[16].chars.map { |c| Boolean(c) }
118
+ { grid_voltage: Float(r[0]),
119
+ grid_frequency: Float(r[1]),
120
+ output_voltage: Float(r[2]),
121
+ output_frequency: Float(r[3]),
122
+ output_va: Integer(r[4]),
123
+ output_watts: Integer(r[5]),
124
+ output_load_percent: Integer(r[6]),
125
+ dc_bus_voltage: Integer(r[7]),
126
+ battery_voltage: Float(r[8]),
127
+ battery_charge_current: Float(r[9]),
128
+ battery_capacity_remaining: Integer(r[10]),
129
+ inverter_heatsink_celsius_temperature: Integer(r[11]),
130
+ pv_battery_input_current: Integer(r[12]),
131
+ pv_input_voltage: Float(r[13]),
132
+ solar_charge_controller_battery_voltage: Float(r[14]),
133
+ battery_discharge_current: Integer(r[15]),
134
+ add_sbu_priority_version: status[0],
135
+ configuration_changed: status[1],
136
+ solar_charge_controller_firmware_changed: status[2],
137
+ load_on: status[3],
138
+ battery_voltage_stable: status[4],
139
+ charger_enabled: status[5],
140
+ charging_from_solar_charge_controller: status[6],
141
+ charging_from_utility: status[7] }
142
+ end)
143
+
144
+ # Device mode
145
+ #
146
+ # Returns:
147
+ # # A symbol denoting the device mode
148
+ # See AxpertConstants::DEVICE_MODE for constants
149
+ DEVICE_MODE = OP.new(command: 'QMOD', parser: lambda { |r| CONS::DEVICE_MODE[r.data[1..-1].upcase] })
150
+
151
+ ##
152
+ # Device warning status messages
153
+ #
154
+ # Returns:
155
+ # # An Array of Hashes
156
+ # # Each Hash has the format { description: String description, level: :none/:fault/:warning }
157
+ # # The level is only :none if a reserved error was returned which may signify an update
158
+ # # of the parser is needed
159
+ # [{ description: 'Bus voltage is too high', level: :fault}, ..]
160
+ DEVICE_WARNING_STATUS = OP.new(command: 'QPIWS', parser: lambda do |r|
161
+ r = r.data[1..-1].chars.map { |s| Boolean(s) }
162
+ parse = lambda { |desc, lvl = :none| { description: desc, level: lvl } }
163
+ errors = []
164
+ errors << parse.yield('Reserved') if r[0]
165
+ errors << parse.yield('Inverter fault', :fault) if r[1]
166
+ errors << parse.yield('Bus voltage is too high', :fault) if r[2]
167
+ errors << parse.yield('Bus voltage is too low', :fault) if r[3]
168
+ errors << parse.yield('Bus soft start failed ', :fault) if r[4]
169
+ errors << parse.yield('LINE_FAIL', :warning) if r[5]
170
+ errors << parse.yield('Output short circuited', :warning) if r[6]
171
+ errors << parse.yield('Inverter voltage too low', :fault) if r[7]
172
+ errors << parse.yield('Output voltage is too high', :fault) if r[8]
173
+ errors << parse.yield('Over temperature', (r[1] ? :fault : :warning)) if r[9]
174
+ errors << parse.yield('Fan is locked', (r[1] ? :fault : :warning)) if r[10]
175
+ errors << parse.yield('Battery voltage is too high', (r[1] ? :fault : :warning)) if r[11]
176
+ errors << parse.yield('Battery voltage is too low', 'Fault') if r[12]
177
+ errors << parse.yield('Reserved') if r[13]
178
+ errors << parse.yield('Battery under shutdown', :warning) if r[14]
179
+ errors << parse.yield('Reserved') if r[15]
180
+ errors << parse.yield('Overload', (r[1] ? :fault : :warning)) if r[16]
181
+ errors << parse.yield('EEPROM fault', :warning) if r[17]
182
+ errors << parse.yield('Inverter over current', :fault) if r[18]
183
+ errors << parse.yield('Inverter soft start failed', :fault) if r[19]
184
+ errors << parse.yield('Self test fail', :fault) if r[20]
185
+ errors << parse.yield('Over voltage on DC output of inverter', :fault) if r[21]
186
+ errors << parse.yield('Battery connection is open', :fault) if r[22]
187
+ errors << parse.yield('Current sensor failed ', :fault) if r[23]
188
+ errors << parse.yield('Battery short', :fault) if r[24]
189
+ errors << parse.yield('Power limit', :warning) if r[25]
190
+ errors << parse.yield('PV voltage high', :warning) if r[26]
191
+ errors << parse.yield('MPPT overload fault', :warning) if r[27]
192
+ errors << parse.yield('MPPT overload warning', :warning) if r[28]
193
+ errors << parse.yield('Battery too low to charge', :warning) if r[29]
194
+ errors << parse.yield('Reserved') if r[30]
195
+ errors << parse.yield('Reserved') if r[31]
196
+ errors
197
+ end)
198
+
199
+ ##
200
+ # The device default settings
201
+ #
202
+ # Returns:
203
+ # # A Hash containing device defaults
204
+ # { grid_voltage: 230.5, grid_current: 11.2, .. }
205
+ DEFAULT_SETTINGS = OP.new(command: 'QDI', parser: lambda do |r|
206
+ r = r.data[1..-1].split(' ')
207
+ { output_voltage: Float(r[0]),
208
+ output_frequency: Float(r[1]),
209
+ maximum_ac_charge_current: Integer(r[2]),
210
+ battery_under_voltage: Float(r[3]),
211
+ battery_float_charge_voltage: Float(r[4]),
212
+ battery_bulk_charge_voltage: Float(r[5]),
213
+ battery_recharge_voltage: Float(r[6]),
214
+ maximum_charge_current: Integer(r[7]),
215
+ input_voltage_sensitivity: CONS::INPUT_VOLTAGE_SENSITIVITY[r[8]],
216
+ output_source_priority: CONS::OUTPUT_SOURCE_PRIORITY[r[9]],
217
+ charger_source_priority: CONS::CHARGER_SOURCE_PRIORITY[r[10]],
218
+ battery_type: ::AxpertConstants::BATTERY_TYPE[r[11]],
219
+ enable_buzzer: !Boolean(r[12]),
220
+ enable_power_saving: Boolean(r[13]),
221
+ enable_overload_restart: Boolean(r[14]),
222
+ enable_over_temperature_restart: Boolean(r[15]),
223
+ enable_lcd_backlight: Boolean(r[16]),
224
+ enable_primary_source_interrupt_alarm: Boolean(r[17]),
225
+ enable_fault_code_recording: Boolean(r[18]),
226
+ enable_bypass_to_utility_on_overload: Boolean(r[19]),
227
+ enable_lcd_timeout_escape_to_default_page: Boolean(r[20]),
228
+ output_mode: CONS::OUTPUT_MODE[r[21]],
229
+ battery_redischarge_voltage: Float(r[22]),
230
+ pv_parallel_ok_mode: CONS::PV_PARALLEL_OK_MODE[r[23]],
231
+ pv_power_balance_mode: CONS::PV_POWER_BALANCE_MODE[r[24]] }
232
+ end)
233
+
234
+ ##
235
+ # All the possible input values for the charge current setting
236
+ #
237
+ # Returns:
238
+ # [10, 20, 30] # => Array of Integers, example given
239
+ ACCEPTED_CHARGE_CURRENT_VALUES = OP.new(command: 'QMCHGCR', parser: lambda do |r|
240
+ r.data[1..-1].split(' ').map { |s| Integer(s) }
241
+ end)
242
+
243
+ ##
244
+ # All the possible input values for the utility charge current setting
245
+ #
246
+ # Returns:
247
+ # [10, 20, 30] # => Array of Integers, example given
248
+ ACCEPTED_UTILITY_CHARGE_CURRENT_VALUES = OP.new(command: 'QMUCHGCR', parser: lambda do |r|
249
+ r.data[1..-1].split(' ').map { |s| Integer(s) }
250
+ end)
251
+
252
+ ##
253
+ # Has device undergone DSP bootstrap
254
+ #
255
+ # Returns:
256
+ # # A Boolean indicating if the device has undergone DSP bootstrap
257
+ # true # Example
258
+ DSP_BOOTSTRAP_STATUS = OP.new(command: 'QBOOT', parser: lambda { |r| Boolean(r.data[1..-1]) })
259
+
260
+ ##
261
+ # Device parallel output mode status
262
+ #
263
+ # Returns:
264
+ # # A symbol denoting the current device parallel output mode
265
+ # See AxpertConstants::OUTPUT_MODE for constants
266
+ OUTPUT_MODE = OP.new(command: 'QOPM', parser: lambda { |r| CONS::OUTPUT_MODE[r.data[2..-1]] })
267
+
268
+ ##
269
+ # Parallel device information
270
+ #
271
+ # Input: (OPTIONAL, default = 0)
272
+ # # Default of 0 seems to work for single unit mode
273
+ # 2 # => parallel_machine_number (Parallel mode only)
274
+ #
275
+ # Returns:
276
+ # # A Hash containing parallel device status information
277
+ # { grid_voltage: 230.5, grid_current: 11.2, .. }
278
+ PARALLEL_DEVICE_STATUS = OP.new(command: lambda { |i = 0| "QPGS#{Integer(i)}" }, parser: lambda do |r|
279
+ r = r.data[1..-1].split(' ')
280
+ status = r[19].chars
281
+ { parallel_number_exists: Boolean(r[0]),
282
+ serial_number: r[1],
283
+ device_mode: CONS::DEVICE_MODE[r[2]],
284
+ fault_code: CONS::FAULT_CODE[r[3]],
285
+ grid_voltage: Float(r[4]),
286
+ grid_frequency: Float(r[5]),
287
+ output_voltage: Float(r[6]),
288
+ output_frequency: Float(r[7]),
289
+ output_va: Integer(r[8]),
290
+ output_watts: Integer(r[9]),
291
+ load_percentage: Integer(r[10]),
292
+ battery_voltage: Float(r[11]),
293
+ battery_charge_current: Integer(r[12]),
294
+ battery_capacity: Integer(r[13]),
295
+ pv_input_voltage: Float(r[14]),
296
+ total_charge_current: Integer(r[15]),
297
+ total_output_va: Integer(r[16]),
298
+ total_output_watt: Integer(r[17]),
299
+ total_load_percentage: Integer(r[18]),
300
+ solar_charge_controller_enabled: Boolean(status[0]),
301
+ charging_from_utility: Boolean(status[1]),
302
+ charging_from_solar_charge_controller: Boolean(status[2]),
303
+ battery_status: [:normal, :under, :open][Integer("#{status[3]}#{status[4]}")],
304
+ line_status_ok: !Boolean(status[5]),
305
+ load_on: Boolean(status[6]),
306
+ configuration_changed: Boolean(status[7]),
307
+ output_mode: CONS::OUTPUT_MODE[r[20]],
308
+ charger_source_priority: CONS::CHARGER_SOURCE_PRIORITY[r[21]],
309
+ maximum_charge_current: Integer(r[22]),
310
+ device_maximum_charge_current: Integer(r[23]),
311
+ pv_input_current: Integer(r[24]),
312
+ battery_discharge_current: Integer(r[25]) }
313
+ end)
314
+
315
+ ##
316
+ # Reset device to factory defaults
317
+ #
318
+ # Returns:
319
+ # # A Boolean indicating if the device has reset to defaults succesfully
320
+ # true # Example
321
+ RESET_TO_DEFAULT = OP.new(command: 'PF', error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
322
+
323
+ ##
324
+ # Set device output frequency
325
+ #
326
+ # Input:
327
+ # 50 # => Any integer is accepted, consult manual for valid values
328
+ #
329
+ # Returns:
330
+ # # A Boolean indicating if the frequency was set succesfully
331
+ # true # Example
332
+ SET_OUTPUT_FREQUENCY = OP.new(command: lambda { |input| "F#{Integer(input).to_s.rjust(2, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
333
+
334
+ ##
335
+ # Set device output source priority
336
+ #
337
+ # Input:
338
+ # # A symbol donating the output source priority setting
339
+ # See AxpertConstants::OUTPUT_SOURCE_PRIORITY
340
+ #
341
+ # Returns:
342
+ # # A Boolean indicating if the output priority was set succesfully
343
+ # true # Example
344
+ SET_OUTPUT_SOURCE_PRIORITY = OP.new(command: lambda { |i| "POP#{CONS::OUTPUT_SOURCE_PRIORITY.key(i).to_s.rjust(2, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
345
+
346
+ ##
347
+ # Set battery re-charge voltage
348
+ #
349
+ # Input:
350
+ # 24.3 # => Any float is accepted, consult manual for valid values
351
+ #
352
+ # Returns:
353
+ # # A Boolean indicating if the recharge voltage was successfully set
354
+ # true # Example
355
+ SET_BATTERY_RECHARGE_VOLTAGE = OP.new(command: lambda { |input| "PBCV#{Float(input).to_s.rjust(4, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
356
+
357
+ ##
358
+ # Set battery re-discharge voltage
359
+ #
360
+ # Input:
361
+ # 24.3 # => Any float is accepted, consult manual for valid values
362
+ #
363
+ # Returns:
364
+ # # A Boolean indicating if the re-discharge voltage was successfully set
365
+ # true # Example
366
+ SET_BATTERY_REDISCHARGE_VOLTAGE = OP.new(command: lambda { |input| "PBDV#{Float(input).to_s.rjust(4, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
367
+
368
+ ##
369
+ # Set device charger priority
370
+ #
371
+ # Input:
372
+ # # (REQUIRED) Parameter 0 => A symbol donating the charger source priority
373
+ # See AxpertConstants::CHARGER_SOURCE_PRIORITY
374
+ #
375
+ # # (OPTIONAL) Parameter 1 => parallel_machine_number (Parallel mode only)
376
+ # 5 # => Do not pass in this value unless the device is running in parallel mode
377
+ #
378
+ # Returns:
379
+ # # A Boolean indicating if the device charger priority was set successfully
380
+ # true # Example
381
+ SET_CHARGER_SOURCE_PRIORITY = OP.new(command: lambda do |mode, parallel_machine_number = nil|
382
+ if parallel_machine_number.nil?
383
+ "PPCP#{Integer(parallel_machine_number).to_s}#{CONS::CHARGER_SOURCE_PRIORITY.key(mode).to_s.rjust(2, '0')}"
384
+ else
385
+ "PCP#{CONS::CHARGER_SOURCE_PRIORITY.key(mode).to_s.rjust(2, '0')}"
386
+ end
387
+ end, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
388
+
389
+ ##
390
+ # Set device input voltage sensitivity
391
+ #
392
+ # Input:
393
+ # # A symbol denoting the input voltage sensitivity
394
+ # See AxpertConstants::INPUT_VOLTAGE_SENSITIVITY
395
+ #
396
+ # Returns:
397
+ # # A Boolean indicating if the device input voltage sensitivity was set successfully
398
+ # true # Example
399
+ SET_INPUT_VOLTAGE_SENSITIVITY = OP.new(command: lambda { |i| "PGR#{CONS::INPUT_VOLTAGE_SENSITIVITY.key(i).to_s.rjust(2, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
400
+
401
+ ##
402
+ # Set battery type
403
+ #
404
+ # Input:
405
+ # # A symbol denoting the battery type
406
+ # See AxpertConstants::BATTERY_TYPE
407
+ #
408
+ # Returns:
409
+ # # A Boolean indicating if the device battery type was set successfully
410
+ # true # Example
411
+ SET_BATTERY_TYPE = OP.new(command: lambda { |i| "PBT#{CONS::BATTERY_TYPE.key(i).to_s.rjust(2, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
412
+
413
+ ##
414
+ # Set device battery cut-off voltage
415
+ #
416
+ # Input:
417
+ # 22.1 # => Any float is accepted, consult manual for valid values
418
+ #
419
+ # Returns:
420
+ # # A Boolean indicating if the battery cut-off voltage was successfully set
421
+ # true # Example
422
+ SET_BATTERY_CUTOFF_VOLTAGE = OP.new(command: lambda { |input| "PSDV#{Float(input).to_s.rjust(4, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
423
+
424
+ ##
425
+ # Set device battery constant charging voltage
426
+ #
427
+ # Input:
428
+ # 28.3 # => Any float is accepted, consult manual for valid values
429
+ #
430
+ # Returns:
431
+ # # A Boolean indicating if the battery constant charging voltage was successfully set
432
+ # true # Example
433
+ SET_BATTERY_CONSTANT_CHARGING_VOLTAGE = OP.new(command: lambda { |input| "PCVV#{Float(input).to_s.rjust(4, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
434
+
435
+ ##
436
+ # Set device battery float charging voltage
437
+ #
438
+ # Input:
439
+ # 26.5 # => Any float is accepted, consult manual for valid values
440
+ #
441
+ # Returns:
442
+ # # A Boolean indicating if the battery float charging was successfully set
443
+ # true # Example
444
+ SET_BATTERY_FLOAT_CHARGING_VOLTAGE = OP.new(command: lambda { |input| "PBFT#{Float(input).to_s.rjust(4, '0')}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
445
+
446
+ ##
447
+ # Set parallel PV OK condition
448
+ #
449
+ # Input:
450
+ # # A input symbol donating the PV Parallel OK condition mode setting
451
+ # See AxpertConstants::PV_PARALLEL_OK_MODE
452
+ #
453
+ # Returns:
454
+ # # A Boolean indicating if the parallel PV OK condition was set successfully
455
+ # true # Example
456
+ SET_PV_PARALLEL_OK_MODE = OP.new(command: lambda { |i| "PPVOKC#{CONS::PV_PARALLEL_OK_MODE.key(i).to_s}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
457
+
458
+ ##
459
+ # Set PV power balance mode
460
+ #
461
+ # Input:
462
+ # # A input symbol donating the PV Power balance mode
463
+ # See AxpertConstants::PV_POWER_BALANCE_MODE
464
+ #
465
+ # Returns:
466
+ # # A Boolean indicating if the PV power balance mode was set successfully
467
+ # true # Example
468
+ SET_PV_POWER_BALANCE_MODE = OP.new(command: lambda { |i| "PSPB#{CONS::PV_POWER_BALANCE_MODE.key(i).to_s}" }, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
469
+
470
+ ##
471
+ # Set the maximum charging current
472
+ #
473
+ # Input:
474
+ # # (REQUIRED) Parameter 0 => Charge current
475
+ # 30 # => Any integer is accepted, consult manual for valid values
476
+ #
477
+ # # (OPTIONAL) Parameter 1 => parallel_machine_number (Parallel mode only)
478
+ # 5 # => Do not pass in this value unless the device is running in parallel mode
479
+ #
480
+ # Returns:
481
+ # # A Boolean indicating if the maximum charging current was set successfully
482
+ # true # Example
483
+ SET_MAXIMUM_CHARGING_CURRENT = OP.new(command: lambda do |current, parallel_machine_number = 0|
484
+ current = Integer(current)
485
+ if (current >= 100)
486
+ "MNCHGC#{Integer(parallel_machine_number).to_s}#{current.to_s.rjust(3, '0')}"
487
+ else
488
+ "MCHGC#{Integer(parallel_machine_number).to_s}#{current.to_s.rjust(2, '0')}"
489
+ end
490
+ end, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
491
+
492
+ #
493
+ # Set the maximum charging current for utility
494
+ #
495
+ # Input:
496
+ # # (REQUIRED) Parameter 0 => Charge current
497
+ # 30 # => Any integer is accepted, consult manual for valid values
498
+ #
499
+ # # (OPTIONAL) Parameter 1 => parallel_machine_number (Parallel mode only)
500
+ # 5 # => Do not pass in this value unless the device is running in parallel mode
501
+ #
502
+ # Returns:
503
+ # # A Boolean indicating if the maximum charging current for utility was set successfully
504
+ # true # Example
505
+ SET_MAXIMUM_UTILITY_CHARGING_CURRENT = OP.new(command: lambda do |current, parallel_machine_number = 0|
506
+ "MUCHGC#{Integer(parallel_machine_number).to_s}#{Integer(current).to_s.rjust(2, '0')}"
507
+ end, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
508
+
509
+ ##
510
+ # Set the parallel output mode (or single mode)
511
+ #
512
+ # Input:
513
+ # # (REQUIRED) Parameter 0 => A input symbol donating output mode
514
+ # See AxpertConstants::OUTPUT_MODE
515
+ #
516
+ # # (OPTIONAL) Parameter 1 => parallel_machine_number (Parallel mode only)
517
+ # 5 # => Do not pass in this value unless the device is running in parallel mode
518
+ #
519
+ # Returns:
520
+ # # A Boolean indicating if the parallel output mode was set successfully
521
+ # true # Example
522
+ SET_OUTPUT_MODE = OP.new(command: lambda do |mode, parallel_machine_number = 0|
523
+ "POPM#{CONS::OUTPUT_MODE.key(mode)}#{Integer(parallel_machine_number).to_s}"
524
+ end, error_on_nak: false, parser: lambda { |r| (r.data == '(ACK') })
525
+
526
+ require 'axpert_constants'
527
+ CONS = ::AxpertConstants # :nodoc:
528
+ send(:remove_const, :OP)
529
+ end
530
+
531
+ # This is hacky but it is really a must for accurate parsing
532
+ module ::Kernel # :nodoc:
533
+ def Boolean(input) # :nodoc:
534
+ return true if input.equal?(TrueClass)
535
+ return false if input.equal?(FalseClass)
536
+ parse = input.to_s.chomp.downcase
537
+ return true if ('true' == parse) || ('y' == parse) || ('t' == parse) || (1 == (Integer(parse) rescue nil))
538
+ return false if ('false' == parse) || ('n' == parse) || ('f' == parse) || (0 == (Integer(parse) rescue nil))
539
+ raise
540
+ rescue StandardError, ScriptError
541
+ raise ::ArgumentError.new("invalid value for Boolean(): \"#{input}\"")
542
+ end
543
+ end
@@ -0,0 +1,151 @@
1
+ ##
2
+ # A list of constants used by the Axpert devices with a convenient parser
3
+ #
4
+ # @author: Johan van der Vyver
5
+ module AxpertConstants
6
+ def self.lookup_hash(type, values) # :nodoc:
7
+ type = type.to_s.strip.freeze
8
+ values.values.each(&:freeze)
9
+ lookup = Hash.new do |_, k|
10
+ if lookup.has_key?(k.to_s)
11
+ lookup[k.to_s]
12
+ elsif (k.is_a?(Numeric) && (k.to_i == k) && (lookup.values.count > k))
13
+ lookup.values[k]
14
+ elsif lookup.values.include?(k)
15
+ k
16
+ else
17
+ raise ::AxpertConstants::UnknownConstant.new("Unknown #{type} '#{k}'")
18
+ end
19
+ end
20
+ lookup.instance_eval <<-RUBY_CODE
21
+ def key(input) # :nodoc:
22
+ parse = super(self[input]) rescue nil
23
+ parse = (super(input.to_s.to_sym) rescue nil) if parse.nil?
24
+ parse = (super(input) rescue nil) if parse.nil?
25
+ return parse unless parse.nil?
26
+ raise ::AxpertConstants::UnknownConstant.new("Could not find the #{type} constant '\#{input}' in \#{self.values}")
27
+ end
28
+ RUBY_CODE
29
+ lookup.merge!(values).freeze
30
+ end
31
+
32
+ ##
33
+ # The type of batteries connected to the device
34
+ # Used to determine the float, bulk charging, re-charge and re-discharge voltage ranges
35
+ #
36
+ # :agm # => Absorbent Glass Mat (AGM)
37
+ # :flooded # => Flooded Cell
38
+ # :user # => User defined
39
+ BATTERY_TYPE = lookup_hash('battery type', {'0' => :agm, '1' => :flooded, '2' => :user})
40
+
41
+ ##
42
+ # The current device mode
43
+ #
44
+ # :power # => Power on
45
+ # :standby # => Standby
46
+ # :line # => Line
47
+ # :battery # => Battery
48
+ # :fault # => Fault
49
+ DEVICE_MODE = lookup_hash('device mode', {'P' => :power, 'S' => :standby, 'L' => :line, 'B' => :battery, 'F' => :fault})
50
+
51
+ ##
52
+ # The input voltage is monitored to determine if it is within an acceptable range
53
+ # The sensitivity determines when the unit switches output mode to/from Utility
54
+ #
55
+ # :appliance # => Appliance mode sensitivity (see manual for ranges)
56
+ # :ups # => UPS mode sensitivity (see manual for ranges)
57
+ INPUT_VOLTAGE_SENSITIVITY = lookup_hash('input voltage sensitivity', {'0' => :appliance, '1' => :ups})
58
+
59
+ ##
60
+ # Device output priority
61
+ #
62
+ # :utility # => Prefer utility as first output power source
63
+ # :solar # => Prefer solar as first output power source
64
+ # :sbu # => Prefer solar first, then battery and utility last as output power source
65
+ OUTPUT_SOURCE_PRIORITY = lookup_hash('output source priority', {'0' => :utility, '1' => :solar, '2' => :sbu})
66
+
67
+ ##
68
+ # Battery charger source priority
69
+ #
70
+ # :utility_first # => Charge batteries from utility first
71
+ # :solar:first # => Charge batteries from solar first,
72
+ # :solar_and_utility # => Charge batteries from solar & utility
73
+ # :solar_only # => Charge batteries from solar only
74
+ CHARGER_SOURCE_PRIORITY = lookup_hash('charger source priority', {'0' => :utility_first, '1' => :solar_first, '2' => :solar_and_utility, '3' => :solar_only})
75
+
76
+ ##
77
+ # The type of device
78
+ #
79
+ # :grid_tie # => Grid tie device
80
+ # :off_grid # => Off-Grid device
81
+ # :hybrid # =>Hybrid device
82
+ DEVICE_TYPE = lookup_hash('device type', {'00' => :grid_tie, '01' => :off_grid, '10' => :hybrid})
83
+
84
+ ##
85
+ # The internal device topology
86
+ # NOTE: All models make use of a transformer in Inverter mode
87
+ #
88
+ # :transformerless # => The device output does not pass through an isolation transformer
89
+ # :transformer # => The device output does pass through an isolation transformer
90
+ DEVICE_TOPOLOGY = lookup_hash('device topology', {'0' => :transformerless, '1' => :transformer})
91
+
92
+ ##
93
+ # The current output mode of the device
94
+ #
95
+ # :single # => The device is running in single mode, single phase output
96
+ # :parallel # => The device is running in parallel mode (allow multiple units in parallel), single phase output
97
+ # :phase1 # => The device is running in parallel mode, 3 phase output, set to phase 1 of 3
98
+ # :phase2 # => The device is running in parallel mode, 3 phase output, set to phase 2 of 3
99
+ # :phase3 # => The device is running in parallel mode, 3 phase output, set to phase 3 of 3
100
+ OUTPUT_MODE = lookup_hash('output mode', {'0' => :single, '1' => :parallel, '2' => :phase1, '3' => :phase2, '4' => :phase3})
101
+
102
+ ##
103
+ # Only applicable to units running in Parallel!
104
+ # The required mode for the PV to report OK on the inverter in parallel mode
105
+ #
106
+ # :one # => Only one unit needs to report PV is OK
107
+ # :all # => All units must report that PV is OK
108
+ PV_PARALLEL_OK_MODE = lookup_hash('PV parallel mode', {'0' => :one, '1' => :all})
109
+
110
+ ##
111
+ # The PV power balance mode setting
112
+ #
113
+ # :charge # => PV output is limited to battery charge current
114
+ # :charge_and_load # => PV output will attempt to use enough power for charging the battery and enough to supply the connected load
115
+ PV_POWER_BALANCE_MODE = lookup_hash('PV power balance mode', {'0' => :charge, '1' => :charge_and_load})
116
+
117
+ ##
118
+ # Possible fault codes returned by the device
119
+ FAULT_CODE = lookup_hash('fault code',
120
+ { '00' => 'No faults',
121
+ '01' => 'Fan is locked',
122
+ '02' => 'Over temperature',
123
+ '03' => 'Battery voltage is too high',
124
+ '04' => 'Battery voltage is too low',
125
+ '05' => 'Output short circuited/Over temperature',
126
+ '06' => 'Output voltage is too high',
127
+ '07' => 'Overload time out',
128
+ '08' => 'Bus voltage is too high',
129
+ '09' => 'Bus soft start failed',
130
+ '11' => 'Main relay failed',
131
+ '51' => 'Inverter over current',
132
+ '52' => 'Bus soft start failed',
133
+ '53' => 'Inverter soft start failed',
134
+ '54' => 'Self-test failed',
135
+ '55' => 'Inverter over voltage on DC output',
136
+ '56' => 'Battery connection is open',
137
+ '57' => 'Current sensor failed',
138
+ '58' => 'Output voltage is too low',
139
+ '60' => 'Inverter negative power',
140
+ '71' => 'Parallel version different',
141
+ '71' => 'Output circuit failed',
142
+ '80' => 'CAN communication failed',
143
+ '81' => 'Parallel host line lost',
144
+ '82' => 'Parallel synchronized signal lost',
145
+ '83' => 'Parallel battery voltage is detected as different',
146
+ '84' => 'Parallel line voltage or frequency is detected as different',
147
+ '85' => 'Parallel line input current unbalanced',
148
+ '86' => 'Parallel output setting is different' })
149
+
150
+ class UnknownConstant < RuntimeError; end # :nodoc:
151
+ end
@@ -0,0 +1,2 @@
1
+ require 'axpert_commands'
2
+
@@ -0,0 +1,113 @@
1
+ ##
2
+ # A convenient immutable object to encapsulate the logic
3
+ # of a Voltronic Device operation consisting of:
4
+ # Command, parameter validation and result parser
5
+ #
6
+ # @author: Johan van der Vyver
7
+ class VoltronicDeviceOperation
8
+ require 'voltronic_rs232'
9
+ require 'time'
10
+
11
+ def initialize(input, &blk)
12
+ input = {}.merge(input) rescue (raise ::ArgumentError.new("Expected an input hash"))
13
+
14
+ @command = begin
15
+ as_lambda(input.fetch(:command))
16
+ rescue ::StandardError => err
17
+ err = "#{err.class.name.to_s} thrown; #{err.message.to_s}"
18
+ raise ::ArgumentError.new("Expected :command to be a String with a device command or Proc (#{err})")
19
+ end
20
+
21
+ @error_on_nak = (true == input.fetch(:error_on_nak, true))
22
+
23
+ @parser = begin
24
+ as_lambda(input.fetch(:parser))
25
+ rescue ::StandardError => err
26
+ err = "#{err.class.name.to_s} thrown; #{err.message.to_s}"
27
+ raise ::ArgumentError.new("Expected :parser to be a Proc or Lambda (#{err})")
28
+ end
29
+
30
+ @read_timeout = Integer(input.fetch(:serial_read_timeout_seconds, 2))
31
+ @write_timeout = Integer(input.fetch(:serial_write_timeout_seconds, 2))
32
+
33
+ @termination_character = begin
34
+ parse = input.fetch(:serial_termination_character, "\r").to_s
35
+ raise ::ArgumentError.new("Expected :serial_termination_character to be a single character") unless (parse.length == 1)
36
+ parse.freeze
37
+ end
38
+
39
+ freeze
40
+ end
41
+
42
+ ##
43
+ # Issue a command to the device and parse the output result
44
+ def issue_command(serial, *args)
45
+ serial.read_timeout = -1 # Prevent locking on serial
46
+
47
+ serial.write(command(*args).bytes)
48
+ result = begin
49
+ parse = ''
50
+ read_timeout = ::Time.now.to_i + @read_timeout # 2 seconds
51
+ while(true)
52
+ ch = serial.getc # Retrieve a single character from Serial port
53
+ if ch.nil?
54
+ sleep 0.1 # 100ms pause before polling again
55
+ next
56
+ end
57
+ parse += ch
58
+ break if (@termination_character == ch)
59
+ raise ::IOError.new("IO read timeout reached, giving up") if (Time.now.to_i > read_timeout)
60
+ end
61
+ parse
62
+ end
63
+
64
+ parse_result(result[0..-4])
65
+ end
66
+
67
+ ##
68
+ # Create an VoltronicRS232 object containing a command
69
+ # and optional parameter to execute on the device
70
+ def command(*args)
71
+ RS232_PROTO.new(@command.yield(*args))
72
+ end
73
+
74
+ ##
75
+ # Parse the command output returned from the Voltronic device
76
+ def parse_result(result)
77
+ result = RS232_PROTO.new(result)
78
+ if (@error_on_nak && ('(NAK' == result.data.upcase))
79
+ raise NAKReceivedError.new("Received NAK from device, this usually means an error occured, an invalid value was supplied or the command is not supported")
80
+ end
81
+ @parser.yield(result)
82
+ rescue ::StandardError, ::ScriptError => err
83
+ raise err if err.is_a?(NAKReceivedError)
84
+ err = "#{err.class.name.to_s} thrown; #{err.message.to_s}"
85
+ raise RS232ParseError.new("Could not parse the result, the output format may have changed (#{err})")
86
+ end
87
+
88
+ def to_s # :nodoc:
89
+ "#{self.class.name.to_s.split('::').last}"
90
+ end
91
+
92
+ def inspect # :nodoc:
93
+ self.to_s
94
+ end
95
+
96
+ private
97
+
98
+ def as_lambda(input)
99
+ if !input.is_a?(Proc)
100
+ input = begin
101
+ input_string = input.to_s.chomp.freeze
102
+ lambda { input_string.to_s.chomp.freeze }
103
+ end
104
+ end
105
+ result = Object.new
106
+ result.define_singleton_method(:_, &input)
107
+ result.method(:_).to_proc.freeze
108
+ end
109
+
110
+ RS232_PROTO = ::VoltronicRS232 # :nodoc
111
+ class NAKReceivedError < ::RuntimeError; end # :nodoc:
112
+ class RS232ParseError < RuntimeError; end # :nodoc:
113
+ end
@@ -0,0 +1,75 @@
1
+ ##
2
+ # Simple immutable object representing the Voltronic RS232 protocol
3
+ #
4
+ # @author: Johan van der Vyver
5
+ class VoltronicRS232
6
+ ##
7
+ # The human readable command to be sent to the device
8
+ attr_reader :data
9
+
10
+ ##
11
+ # The calculated CRC for the data
12
+ attr_reader :crc
13
+
14
+ ##
15
+ # The encoded data that will be transmitted over the wire
16
+ # Format: <DATA><CRC><CR>
17
+ attr_reader :bytes
18
+
19
+ def initialize(data) #:nodoc:
20
+ @data = data.to_s.chomp.dup.freeze
21
+ if (@data != @data.encode(::Encoding.find('ASCII'), {invalid: :replace, undef: :replace, replace: ''}))
22
+ raise ArgumentError.new("Input data can only be ASCII")
23
+ end
24
+ @crc = calculate_crc(data.bytes.to_a).map { |b| b.chr }.join.freeze
25
+ @bytes = "#{@data}#{@crc}\r".freeze
26
+ self.freeze
27
+ end
28
+
29
+ def to_s #:nodoc:
30
+ "#{self.class.name.to_s.split('::').last}(data: '#{data}')"
31
+ end
32
+
33
+ def inspect #:nodoc:
34
+ to_s
35
+ end
36
+
37
+ def ==(other) #:nodoc:
38
+ return true if self.equal?(other)
39
+ return false if other.nil?
40
+ return false unless other.respond_to?(:bytes)
41
+ (self.bytes == other.bytes)
42
+ end
43
+
44
+ private
45
+
46
+ # CRC calculation source: http://forums.aeva.asn.au/pip4048ms-inverter_topic4332_post53760.html#53760
47
+ def calculate_crc(pin) #:nodoc:
48
+ crc, da = 0, 0
49
+ for index in 0..(pin.length-1)
50
+ da = byte(byte(crc >> 8) >> 4)
51
+ crc = short(short(crc << 4) ^ CRC_TABLE[byte(da ^ byte(pin[index] >> 4))])
52
+ da = byte(byte(crc >> 8) >> 4)
53
+ crc = short(short(crc << 4) ^ CRC_TABLE[byte(da ^ byte(pin[index] & 0x0f))])
54
+ end
55
+
56
+ crc_low, crc_high = byte(crc & 0x00FF), byte(crc >> 8)
57
+ crc_low = short(crc_low + 1) if CRC_MOD.include?(crc_low)
58
+ crc_high = short(crc_high + 1) if CRC_MOD.include?(crc_high)
59
+ crc = short(short(crc_high << 8) | crc_low)
60
+ [byte(short(crc >> 8) & 0xff), byte(crc & 0xff)]
61
+ end
62
+
63
+ def byte(input) #:nodoc:
64
+ (input & 255)
65
+ end
66
+
67
+ def short(input) #:nodoc:
68
+ (input & 65535)
69
+ end
70
+
71
+ CRC_TABLE = [0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7,
72
+ 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef].freeze #:nodoc:
73
+
74
+ CRC_MOD = [0x28, 0x0d, 0x0a].freeze #:nodoc:
75
+ end
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: axpert_rs232
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Johan van der Vyver
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-08-26 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Simplify communicating with a Voltronics Axpert range inverter
14
+ email: johan.vdvyver@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - lib/axpert_commands.rb
20
+ - lib/axpert_constants.rb
21
+ - lib/voltronic_device_operation.rb
22
+ - lib/voltronic_rs232.rb
23
+ - lib/axpert_rs232.rb
24
+ homepage: http://rubygems.org/gems/axpert_rs232
25
+ licenses:
26
+ - MIT
27
+ metadata: {}
28
+ post_install_message:
29
+ rdoc_options: []
30
+ require_paths:
31
+ - lib
32
+ required_ruby_version: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - '>='
35
+ - !ruby/object:Gem::Version
36
+ version: '0'
37
+ required_rubygems_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - '>='
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ requirements: []
43
+ rubyforge_project:
44
+ rubygems_version: 2.0.14
45
+ signing_key:
46
+ specification_version: 4
47
+ summary: Simplify communicating with a Voltronics Axpert range inverter
48
+ test_files: []