lxp-packet 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a06975e75dcaa0908d9f1e795d6a9106cf4cabe951b5907ce9b73802d1322999
4
+ data.tar.gz: 32919a40bd5f34819be74a7b5eeb51d5c6cdd322882d75b2f533f2cbc73c68a6
5
+ SHA512:
6
+ metadata.gz: 52d0753334fb109f1caddbf00ced45d9e2d372b8de76cc8358154d95a79931357d71cfad525a0f8ee578e89f15a95648d599948501f17f8ca3a7c137bca926b0
7
+ data.tar.gz: 75e3b687650069eb24fd0d875b27a0a831ef20157d885b5c12450cee60e7c6e1f038606109e2dd0338f9cd9d710ce1a6a6dd240a228364c0fee65dd81975d7e4
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.swp
2
+ vendor/*
3
+ .bundle
4
+ Gemfile.lock
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+ All notable changes to this project will be documented in this file.
3
+
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ - Nothing yet
10
+
11
+ ## [0.1.0] - 2020-01-05
12
+
13
+ ### Added
14
+
15
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Chris Elsworth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # LXP Communications Library
2
+
3
+ This is a Ruby gem to parse and generate data packets from/to a LuxPower inverter. This is tested with my ACS3600 model but may work with others.
4
+
5
+ Unfortunately LuxPower refuse to release API documentation for this inverter, so this is all reverse engineered. Use with care.
6
+
7
+ That said, I've managed to work out quite a bit of it. You can parse "input" packets (via `ReadInput`) from the inverter (which are the data sent in 3 packets at 2 minute intervals, concerning energy flows and states), request settings (via `ReadHold`) for a specific register, and then modify and write those settings back (via `WriteSingle`).
8
+
9
+ There's a `WriteMulti` too but I've not used that yet. It may be used for setting the time, but I'm not interested in that so didn't bother working it out.
10
+
11
+ When you send the inverter a read/write packet, it sends a reply with the same register you sent it, and in the case of write packets, the updated value. This can be used to check the write has taken effect.
12
+
13
+ Note that the replies appear to be sent to all connected clients. If you updating a setting then the reply containing the new updated value is sent to all connected clients (including Lux in China, as per the default inverter configuration). They probably just ignore it as being unexpected; but this is partly the reason there's a `read_reply` method below which ignores packets until we get the one we expect.
14
+
15
+ See the docs in doc/ for a list of registers and my notes on how the packets are constructed. Please remember there may be errors so test carefully before letting any of this loose on your own kit. To repeat, this is all detective work by myself without the assistance of any official docs.
16
+
17
+ Also note that `lib/lxp/packet/registers.rb` only contains registers I've used, not all of them. I'll happily accept PRs adding new ones.
18
+
19
+ This library uses semantic versioning, and accordingly I currently make no guarantees about preserving backwards compatibility. Lock your gem version if this might affect you!
20
+
21
+ ## Install
22
+
23
+ Standard Ruby fare in your Gemfile:
24
+
25
+ ```ruby
26
+ gem 'lxp-packet'
27
+ ```
28
+
29
+ Then require the base library when you want it:
30
+
31
+ ```ruby
32
+ require 'lxp/packet'
33
+ ```
34
+
35
+ ## Setup
36
+
37
+ You obviously need the WiFi datalogger module in your inverter to use this. Mine came with it as standard.
38
+
39
+ The datalogger by default sends information about itself to LuxPower (see Connection 1 below) every 2 minutes. It connects to Lux at the IP shown below. This is how their portal gets information about you, and how they can send your inverter commands over the open channel.
40
+
41
+ It can optionally be configured with a second network endpoint; I set this to TCP Server with a port of 4346, which means you can connect to the inverter on that port and get the same information sent to you. You can also send it commands. So the "Network Setting" page of my inverter looks like this:
42
+
43
+ ![LXP ACS Network Settings](https://i.imgur.com/teygH6h.png)
44
+
45
+ Alternatively you can probably just change the first setting if you don't care about the official Lux portal or mobile app being updated, though I found it useful to verify I was setting the right values at first. This would also prevent LuxPower sending you firmware updates (for better or for worse), not that I've had any so far.
46
+
47
+ ## Examples
48
+
49
+ The inverter requires that your datalog serial and inverter serial are in the packets you send to it for it to respond.
50
+
51
+ These are set like so; this will not be repeated in all subsequent examples.
52
+
53
+ ```ruby
54
+ pkt = LXP::Packet::ReadHold.new
55
+ pkt.datalog_serial = 'AB12345678'
56
+ pkt.inverter_serial = '1234567890'
57
+ ```
58
+
59
+ There's also a commonly-used loop that I'll refer to. This reads input from the socket until it gets a decoded packet which matches the packet register we've previously sent to the inverter, ignoring heartbeats and other data.
60
+
61
+ This is extremely rudimentary but you get the idea. It should really have a timeout so it doesn't block forever.
62
+
63
+ ```ruby
64
+ def read_reply(sock, pkt)
65
+ loop do
66
+ input = sock.recvfrom(2000)[0]
67
+ # puts "IN: #{input.unpack('C*')}"
68
+ r = LXP::Packet::Parser.parse(input)
69
+ return r if r.is_a?(pkt.class) && r.register == pkt.register
70
+ end
71
+ end
72
+ ```
73
+
74
+ This is necessary because occasionally the inverter will send us state data and heartbeats, as well as replies for other clients (see above) which we need to either process (if you're interested in them) or ignore (which is easier, and done here).
75
+
76
+ ### Reading
77
+
78
+ This is the simplest use-case; read the value of something from the inverter.
79
+
80
+ ```ruby
81
+ pkt = LXP::Packet::ReadHold.new
82
+ pkt.datalog_serial = 'AB12345678'
83
+ pkt.inverter_serial = '1234567890'
84
+ pkt.register = LXP::Packet::Registers::DISCHG_CUT_OFF_SOC_EOD
85
+
86
+ # pkt.bytes returns an array of integers if you want to inspect what we'll send
87
+
88
+ # pack the integers into a binary packet to send to a socket
89
+ out = pkt.to_bin
90
+
91
+ # assuming your inverter is at 192.168.0.30
92
+ sock = TCPSocket.new('192.168.0.30', 4346)
93
+ sock.write(out)
94
+
95
+ r = read_reply(sock, pkt)
96
+ puts "Received: #{r.value}" # should be discharge cut-off value
97
+ ```
98
+
99
+ ### Writing
100
+
101
+ Updating a value on the inverter.
102
+
103
+ ```ruby
104
+ pkt = LXP::Packet::WriteSingle.new
105
+ # set serials like above..
106
+
107
+ # set discharge cutoff to 20%
108
+ pkt.register = LXP::Packet::Registers::DISCHG_CUT_OFF_SOC_EOD
109
+ pkt.value = 20
110
+
111
+ # pack the integers into a binary packet to send to a socket
112
+ out = pkt.to_bin
113
+
114
+ # assuming your inverter is at 192.168.0.30
115
+ sock = TCPSocket.new('192.168.0.30', 4346)
116
+ sock.write(out)
117
+
118
+ r = read_reply(sock, pkt)
119
+ puts "Received: #{r.value}" # should be new discharge cut-off value, 20
120
+ ```
121
+
122
+ ### Updating a multi-byte register
123
+
124
+ The Lux has two registers that contain multiple settings. In `doc/LXP_REGISTERS.txt` you can see them at 21 and 110. They have two bytes.
125
+
126
+ This library combines them into a 16bit word, so that the constants in `LXP::Packet::RegisterBits` can be applied directly to those values.
127
+
128
+ First you need to read the previous value, update it with a new bit, then write it back. This is really just a combination of the two above examples.
129
+
130
+ This enables AC charge. You need to OR the bit with the previous value so as not to change other settings tored in register 21.
131
+
132
+ It could be improved not to bother doing the write if it was already enabled.
133
+
134
+ ```ruby
135
+ sock = TCPSocket.new('192.168.0.30', 4346)
136
+
137
+ pkt = LXP::Packet::ReadHold.new
138
+ # serials..
139
+
140
+ pkt.register = 21
141
+ sock.write(pkt.to_bin)
142
+
143
+ r = read_reply(sock, pkt)
144
+ # r.value is a 16bit integer
145
+
146
+ pkt = LXP::Packet::WriteSingle.new
147
+ # serials..
148
+
149
+ pkt.register = 21
150
+
151
+ # enable AC charge by ORing it with the previous value
152
+ pkt.value = r.value | LXP::Packet::RegisterBits::AC_CHARGE_ENABLE
153
+
154
+ # or maybe you want to disable AC charge
155
+ # pkt.value = r.value & ~LXP::Packet::RegisterBits::AC_CHARGE_ENABLE
156
+
157
+ sock.write(pkt.to_bin)
158
+
159
+ r = read_reply(sock, pkt)
160
+ # now r.value should be the updated 16bit integer
161
+ ```
@@ -0,0 +1,64 @@
1
+ Reverse-engineered packet analysis when talking to my LXP ACS inverter.
2
+
3
+ E&OE - this may not be complete or correct, please exercise care!
4
+
5
+ All numbers appear to be transmitted least significant byte first. Numbers
6
+ in this document are presented in decimal.
7
+
8
+ Most packets appear to be 38 bytes (though this can vary). There appears
9
+ to be a prefix of 6 bytes:
10
+
11
+ BYTE BYTES VALUE MEANING
12
+ 0 1 161 Prefix
13
+ 1 1 26 Prefix
14
+ 2 2 protocol number
15
+ 4 2 frame length
16
+
17
+ Protocol number normally seems to be 1, though I've seen 2 returned from the
18
+ inverter.
19
+
20
+ Frame length is a count of all bytes after those 6, so for a usual 38 byte
21
+ packet, this will be 32.
22
+
23
+ Then there's a header:
24
+
25
+ BYTE BYTES VALUE MEANING
26
+ 6 1 1 always seems to be 1
27
+ 7 1 TCP_FUNCTION; 193=heartbeat
28
+ 194=data to translate
29
+ 195=read param 196=write param
30
+ 8 10 datalog serial number
31
+ 18 2 data frame length or heartbeat number?
32
+
33
+ Mostly the packets I've been dealing with have TCP_FUNCTION 194. I ignore
34
+ heartbeats, and I've not seen 195 or 196 I don't think.
35
+
36
+ For 194, bytes 18/19 are the data frame length which should match the number
37
+ of bytes that follows. It's sometimes easier to think of the dataframe as
38
+ its own structure so here are byte offsets in both the dataframe and the entire
39
+ packet:
40
+
41
+ BYTE BYTES VALUE MEANING
42
+ 0/20 1 address? 0 when writing to inverter, 1 when reading?
43
+ 1/21 1 DEVICE_FUNCTION; 3=R_HOLD 4=R_INPUT 6=W_SINGLE 16=W_MULTI
44
+ 2/22 10 inverter serial number
45
+ 12/32 2 register (also start address - 0/40/80)
46
+ 14/34 2? value (also point number)
47
+ 16/36 2 modbus_CRC16 (of bytes 0-15 of this dataframe)
48
+
49
+ Data frames are usually 18 bytes, but I have seen 17 and 19. Sometimes there's
50
+ a byte integer between the register and the value, I think this says how many
51
+ value bytes there are. Not entirely clear when this will be such an int, or
52
+ when it will be the first byte of the value though. Maybe just when len != 18?
53
+
54
+ DEVICE_FUNCTION is basically the type of command. Not sure of the difference
55
+ between R_HOLD and R_INPUT yet. W_SINGLE writes a single register; not used
56
+ W_MULTI.
57
+
58
+ To get the inverter to populate the response value for an R_HOLD, the value
59
+ you send seems to need to be 1.
60
+
61
+ Register and value are also referred to as start address and point number;
62
+ this seems to be for the regular stats the inverter sends out every 2 minutes;
63
+ seems to send 3 packets, the first one with address 0, then 40, then 80.
64
+ Not really done much with this yet.
@@ -0,0 +1,126 @@
1
+ This is a list of registers in the LXP inverter. The numbers here are
2
+ decimal.
3
+
4
+ This is all reverse engineered and worked out manually so there may be
5
+ omissions. Please take care before changing critical values without proper
6
+ testing in case I got anything wrong!
7
+
8
+ Registers 21 and 110 are bitmasks; normally you fetch the previous
9
+ settings, apply your changes, and set the new value back.
10
+
11
+ 0 MODEL
12
+ 2 SERIAL_NUM
13
+ 7 FW_CODE
14
+ 12 TIME
15
+ 15 COM_ADDR
16
+ 16 LANGUAGE
17
+ 20 PV_INPUT_MODE
18
+ 21 FUNC_AC_CHARGE / FUNC_FORCED_DISCHG_EN / FUNC_FORCED_CHG_EN /
19
+ FUNC_EPS_EN / FUNC_FEED_IN_GRID_EN / FUNC_SET_TO_STANDBY ?
20
+ ## LEAST SIGNIFICANT BYTE
21
+ bit 7 = ac charge
22
+ bit 6 = grid on power ss
23
+ bit 5 = neutral detect
24
+ bit 4 = anti island
25
+ bit 3 = ? can't see any effect. normally off..
26
+ bit 2 = drms
27
+ bit 1 = ovf load derate
28
+ bit 0 = eps
29
+ fails with error code 3? (134 in dataframe second byte)
30
+ ## MOST SIGNIFICANT BYTE
31
+ bit 7 = feed in grid
32
+ bit 6 = DCI
33
+ bit 5 = GFCI
34
+ bit 4 = ? can't see any effect. normally on.
35
+ bit 3 = charge priority (charge before supplying load)
36
+ bit 2 = forced discharge
37
+ bit 1 = normal/standby (does not appear to save any power anyway)
38
+ bit 0 = seamless eps switching
39
+ 22 START_PV_VOLT
40
+ 23 CONNECT_TIME
41
+ 24 RECONNECT_TIME
42
+ 25 GRID_VOLT_CONN_LOW
43
+ 26 GRID_VOLT_CONN_HIGH
44
+ 27 GRID_FREQ_CONN_LOW
45
+ 28 GRID_FREQ_CONN_HIGH
46
+ 29 GRID_VOLT_LIMIT1_LOW
47
+ 30 GRID_VOLT_LIMIT1_HIGH
48
+ 31 GRID_VOLT_LIMIT1_LOW_TIME
49
+ 32 GRID_VOLT_LIMIT1_HIGH_TIME
50
+ 33 GRID_VOLT_LIMIT2_LOW
51
+ 34 GRID_VOLT_LIMIT2_HIGH
52
+ 35 GRID_VOLT_LIMIT2_LOW_TIME
53
+ 36 GRID_VOLT_LIMIT2_HIGH_TIME
54
+ 37 GRID_VOLT_LIMIT3_LOW
55
+ 38 GRID_VOLT_LIMIT3_HIGH
56
+ 39 GRID_VOLT_LIMIT3_LOW_TIME
57
+ 40 GRID_VOLT_LIMIT3_HIGH_TIME
58
+ 41 GRID_VOLT_MOV_AVG_HIGH
59
+ 42 GRID_FREQ_LIMIT1_LOW
60
+ 43 GRID_FREQ_LIMIT1_HIGH
61
+ 44 GRID_FREQ_LIMIT1_LOW_TIME
62
+ 45 GRID_FREQ_LIMIT1_HIGH_TIME
63
+ 46 GRID_FREQ_LIMIT2_LOW
64
+ 47 GRID_FREQ_LIMIT2_HIGH
65
+ 48 GRID_FREQ_LIMIT2_LOW_TIME
66
+ 49 GRID_FREQ_LIMIT2_HIGH_TIME
67
+ 50 GRID_FREQ_LIMIT3_LOW
68
+ 51 GRID_FREQ_LIMIT3_HIGH
69
+ 52 GRID_FREQ_LIMIT3_LOW_TIME
70
+ 53 GRID_FREQ_LIMIT3_HIGH_TIME
71
+ 54 MAX_Q_PERCENT_FOR_QV
72
+ 55 V1L
73
+ 56 V2L
74
+ 57 V1H
75
+ 58 V2H
76
+ 59 REACTIVE_POWER_CMD_TYPE
77
+ 60 ACTIVE_POWER_PERCENT_CMD
78
+ 61 REACTIVE_POWER_PERCENT_CMD
79
+ 62 PF_CMD
80
+ 63 POWER_SOFT_START_SLOPE
81
+ 64 CHARGE_POWER_PERCENT_CMD
82
+ 65 DISCHG_POWER_PERCENT_CMD
83
+ 66 AC_CHARGE_POWER_CMD
84
+ 67 AC_CHARGE_SOC_LIMIT
85
+ 68 AC_CHARGE_START_HOUR / AC_CHARGE_START_MINUTE
86
+ 69 AC_CHARGE_END_HOUR / AC_CHARGE_END_MINUTE
87
+ 70 AC_CHARGE_START_HOUR_1 / AC_CHARGE_START_MINUTE_1
88
+ 71 AC_CHARGE_END_HOUR_1 / AC_CHARGE_END_MINUTE_1
89
+ 72 AC_CHARGE_START_HOUR_2 / AC_CHARGE_START_MINUTE_2
90
+ 73 AC_CHARGE_END_HOUR_2 / AC_CHARGE_END_MINUTE_2
91
+ 74 FORCED_CHG_POWER_CMD
92
+ 75 FORCED_CHG_SOC_LIMIT
93
+ 76 FORCED_CHARGE_START_HOUR / FORCED_CHARGE_START_MINUTE
94
+ 77 FORCED_CHARGE_END_HOUR/ FORCED_CHARGE_END_MINUTE
95
+ 78 FORCED_CHARGE_START_HOUR_1 / FORCED_CHARGE_START_MINUTE_1
96
+ 79 FORCED_CHARGE_END_HOUR_1 / FORCED_CHARGE_END_MINUTE_1
97
+ 80 FORCED_CHARGE_START_HOUR_2 / FORCED_CHARGE_START_MINUTE_2
98
+ 81 FORCED_CHARGE_END_HOUR_2 / FORCED_CHARGE_END_MINUTE_2
99
+ 82 FORCED_DISCHG_POWER_CMD
100
+ 83 FORCED_DISCHG_SOC_LIMIT
101
+ 84 FORCED_DISCHARGE_START_HOUR / FORCED_DISCHARGE_START_MINUTE
102
+ 85 FORCED_DISCHARGE_END_HOUR / FORCED_DISCHARGE_END_MINUTE
103
+ 86 FORCED_DISCHARGE_START_HOUR_1 / FORCED_DISCHARGE_START_MINUTE_1
104
+ 87 FORCED_DISCHARGE_END_HOUR_1 / FORCED_DISCHARGE_END_MINUTE_1
105
+ 88 FORCED_DISCHARGE_START_HOUR_2 / FORCED_DISCHARGE_START_MINUTE_2
106
+ 89 FORCED_DISCHARGE_END_HOUR_2 / FORCED_DISCHARGE_END_MINUTE_2
107
+ 90 EPS_VOLT_SET
108
+ 91 EPS_FREQ_SET
109
+ 99 LEAD_ACID_CHARGE_VOLT_REF
110
+ 100 LEAD_ACID_DISCHARGE_CUT_OFF_VOLT
111
+ 101 LEAD_ACID_CHARGE_RATE
112
+ 102 LEAD_ACID_DISCHARGE_RATE
113
+ 103 FEED_IN_GRID_POWER_PERCENT
114
+ 105 DISCHG_CUT_OFF_SOC_EOD
115
+ 106 LEAD_ACID_TEMPR_LOWER_LIMIT_DISCHG
116
+ 107 LEAD_ACID_TEMPR_UPPER_LIMIT_DISCHG
117
+ 108 LEAD_ACID_TEMPR_LOWER_LIMIT_CHG
118
+ 109 LEAD_ACID_TEMPR_UPPER_LIMIT_CHG
119
+ 110 FUNC_PV_GRID_OFF_EN / FUNC_RUN_WITHOUT_GRID / FUNC_MICRO_GRID_EN?
120
+ ## LEAST SIGNIFICANT BYTE
121
+ bit 2 = micro grid enable
122
+ bit 1 = fast zero export
123
+ bit 0 = ? fails with error 3 (134 in dataframe second byte)
124
+ ## MOST SIGNIFICANT BYTE
125
+ 112 SET_MASTER_OR_SLAVE
126
+ 113 SET_COMPOSED_PHASE
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'device_functions'
4
+
5
+ class LXP
6
+ class Packet
7
+ class Base
8
+ # 20 bytes
9
+ attr_accessor :header
10
+
11
+ # usually 16 bytes?
12
+ attr_accessor :data
13
+
14
+ # 2 bytes
15
+ attr_accessor :chksum
16
+
17
+ def initialize
18
+ @header = [0] * 20
19
+ @data = [0] * 16
20
+ @chksum = [0, 0]
21
+
22
+ # prefix
23
+ @header[0] = 161
24
+ @header[1] = 26
25
+
26
+ self.protocol = 1
27
+
28
+ # length after first 6 bytes maybe?
29
+ self.packet_length = 32
30
+
31
+ @header[6] = 1 # unsure
32
+
33
+ @header[7] = 194 # translated data, TBD
34
+ end
35
+
36
+ def bytes
37
+ update_checksum
38
+ header + data + chksum
39
+ end
40
+
41
+ def to_bin
42
+ bytes.pack('C*')
43
+ end
44
+
45
+ def self.parse(ascii)
46
+ # array of integers
47
+ bdata = ascii.unpack('C*')
48
+
49
+ raise 'invalid packet' if bdata[0] != 161 || bdata[1] != 26
50
+
51
+ i = new
52
+
53
+ # header is always 20 bytes
54
+ i.header[0, 20] = bdata[0, 20]
55
+
56
+ # data can vary from 17 bytes upwards
57
+ i.data = bdata[20, i.data_length - 2] # -2, don't copy checksum
58
+ raise 'bad data length' unless i.data.length != i.data_length
59
+
60
+ # calculate checksum and compare to input
61
+ i.update_checksum
62
+ raise 'invalid checksum' if i.chksum != bdata[-2..-1]
63
+
64
+ i
65
+ end
66
+
67
+ def protocol
68
+ @header[2] | @header[3] >> 8
69
+ end
70
+
71
+ def protocol=(protocol)
72
+ @header[2] = protocol & 0xff
73
+ @header[3] = (protocol >> 8) & 0xff
74
+ end
75
+
76
+ def packet_length
77
+ @header[4] | @header[5] >> 8
78
+ end
79
+
80
+ def packet_length=(packet_length)
81
+ @header[4] = packet_length & 0xff
82
+ @header[5] = (packet_length >> 8) & 0xff
83
+ end
84
+
85
+ # Passed as a string
86
+ def datalog_serial=(datalog_serial)
87
+ @header[8, 10] = datalog_serial.bytes
88
+ end
89
+
90
+ def data_length
91
+ @header[18] | @header[19] >> 8
92
+ end
93
+
94
+ def data_length=(data_length)
95
+ @header[18] = data_length & 0xff
96
+ @header[19] = (data_length >> 8) & 0xff
97
+ end
98
+
99
+ def device_function
100
+ @data[1]
101
+ end
102
+
103
+ def device_function=(device_function)
104
+ @data[1] = device_function
105
+ end
106
+
107
+ # Passed as a string
108
+ def inverter_serial=(inverter_serial)
109
+ @data[2, 10] = inverter_serial.bytes
110
+ end
111
+
112
+ def register
113
+ @data[12] | @data[13] >> 8
114
+ end
115
+
116
+ def register=(register)
117
+ @data[12] = register & 0xff
118
+ @data[13] = (register >> 8) & 0xff
119
+ end
120
+
121
+ def value_length_byte?
122
+ @value_length_byte ||=
123
+ protocol == 2 &&
124
+ device_function != DeviceFunctions::WRITE_SINGLE
125
+ end
126
+
127
+ def value_length
128
+ if value_length_byte?
129
+ @data[14]
130
+ else
131
+ 2
132
+ end
133
+ end
134
+
135
+ # protocol 1 has value at 14 and 15
136
+ # protocol 2 has length at 14, then that many bytes of values
137
+ #
138
+ # So this can return an int or an array.
139
+ #
140
+ def value
141
+ if value_length_byte?
142
+ @data[15, value_length]
143
+ else
144
+ @data[14] | @data[15] >> 8
145
+ end
146
+ end
147
+
148
+ # this only makes sense for protocol 1 at the moment.
149
+ # for 2 we'd need to append to an array, and not sure that
150
+ # is even used (sending an array of values to inverter)
151
+ # (maybe with W_MULTI?)
152
+ def value=(value)
153
+ raise 'cannot set value with protocol 2 yet' if protocol == 2
154
+
155
+ @data[14] = value & 0xff
156
+ @data[15] = (value >> 8) & 0xff
157
+ end
158
+
159
+ def update_checksum
160
+ chksum = crc16_modbus(data)
161
+ @chksum[0] = chksum & 0xff
162
+ @chksum[1] = (chksum >> 8) & 0xff
163
+ end
164
+
165
+ private
166
+
167
+ def crc16_modbus(arr)
168
+ arr.length.times.inject(0xffff) do |r, n|
169
+ r ^= arr[n]
170
+ 8.times do
171
+ t = r >> 1
172
+ r = (r & 1).positive? ? 40_961 ^ t : t
173
+ end
174
+ r
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LXP
4
+ class Packet
5
+ module DeviceFunctions
6
+ READ_HOLD = 3
7
+ READ_INPUT = 4
8
+ WRITE_SINGLE = 6
9
+ WRITE_MULTI = 16
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'utils'
4
+
5
+ require_relative 'device_functions'
6
+ require_relative 'tcp_functions'
7
+
8
+ class LXP
9
+ class Packet
10
+ # Given an input string, work out which type of LXP::Packet it should be,
11
+ # and call .parse on the appropriate class.
12
+ class Parser
13
+ attr_reader :ascii, :bdata
14
+
15
+ def self.parse(ascii)
16
+ new(ascii).parse
17
+ end
18
+
19
+ def initialize(ascii)
20
+ @ascii = ascii
21
+ @bdata = ascii.unpack('C*')
22
+ end
23
+
24
+ def parse
25
+ case bdata[7] # tcp_function
26
+ when TcpFunctions::HEARTBEAT then nil # ignored
27
+ when TcpFunctions::TRANSLATED_DATA then parse_translated_data
28
+ else
29
+ raise "unhandled tcp_function #{bdata[7]}"
30
+ end
31
+ end
32
+
33
+ def parse_translated_data
34
+ kls = case bdata[21] # device_function
35
+ when DeviceFunctions::READ_HOLD then ReadHold
36
+ when DeviceFunctions::WRITE_SINGLE then WriteSingle
37
+ when DeviceFunctions::READ_INPUT then parse_input
38
+ else
39
+ raise "unhandled device_function #{bdata[21]}"
40
+ end
41
+
42
+ kls.parse(ascii)
43
+ end
44
+
45
+ # Input packets are 1-of-3; work out which it is from the register
46
+ def parse_input
47
+ case Utils.int(bdata[32, 2]) # register
48
+ when 0 then ReadInput1
49
+ when 40 then ReadInput2
50
+ when 80 then ReadInput3
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'device_functions'
5
+
6
+ class LXP
7
+ class Packet
8
+ class ReadHold < Base
9
+ def initialize
10
+ super
11
+
12
+ self.device_function = DeviceFunctions::READ_HOLD
13
+
14
+ self.data_length = 18
15
+ # start by assuming this packet will be sent to the inverter.
16
+ # we need to put a 1 in the value to get the inverter to
17
+ # populate the reply.
18
+ self.value = 1
19
+ end
20
+
21
+ # ReadHold packets should always (I think) have two byte values.
22
+ #
23
+ # Raise if not, as that is not expected?
24
+ #
25
+ # Base#value will return an int for protocol 1, or an Array
26
+ # for protocol 2. If we can, convert that Array to an int.
27
+ #
28
+ def value
29
+ raise 'value_length not 2?' unless value_length == 2
30
+
31
+ r = super
32
+ r.is_a?(Array) ? Utils.int(r, 2) : r
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ class LXP
6
+ class Packet
7
+ # Input packets are a stream of values related to energy flows (inputs?)
8
+ class ReadInput < Base
9
+ def self.parse(ascii)
10
+ i = super
11
+
12
+ if i.packet_length != 111 || i.data_length != 97
13
+ raise 'unexpected packet length'
14
+ end
15
+
16
+ i
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'utils'
4
+ require_relative 'read_input'
5
+
6
+ class LXP
7
+ class Packet
8
+ class ReadInput1 < ReadInput
9
+ # Decode the data and return a hash of values in this input packet
10
+ def to_h
11
+ {
12
+ status: @data[15],
13
+
14
+ v_bat: Utils.int(@data[23, 2], :lsb) / 10.0, # V
15
+ soc: @data[25], # %
16
+
17
+ p_pv: Utils.int(@data[29, 2], :lsb), # W
18
+ p_charge: Utils.int(@data[35, 2], :lsb), # W
19
+ p_discharge: Utils.int(@data[37, 2], :lsb), # W
20
+ v_acr: Utils.int(@data[39, 2], :lsb) / 10.0, # V
21
+ f_ac: Utils.int(@data[45, 2], :lsb) / 100.0, # Hz
22
+
23
+ p_inv: Utils.int(@data[47, 2], :lsb), # W
24
+ p_rec: Utils.int(@data[49, 2], :lsb), # W
25
+
26
+ v_eps: Utils.int(@data[55, 2], :lsb) / 10.0, # V
27
+ f_eps: Utils.int(@data[61, 2], :lsb) / 100.0, # Hz
28
+
29
+ # peps and seps in 62..65?
30
+
31
+ p_to_grid: Utils.int(@data[66, 2], :msb), # W
32
+ p_to_user: Utils.int(@data[68, 2], :msb), # W
33
+
34
+ e_pv_day: Utils.int(@data[70, 2], :msb) / 10.0, # kWh
35
+ # not sure what 72..75 are
36
+ e_inv_day: Utils.int(@data[76, 2], :msb) / 10.0, # kWh
37
+ e_rec_day: Utils.int(@data[78, 2], :msb) / 10.0, # kWh
38
+ e_chg_day: Utils.int(@data[80, 2], :msb) / 10.0, # kWh
39
+ e_dischg_day: Utils.int(@data[82, 2], :msb) / 10.0, # kWh
40
+ e_eps_day: Utils.int(@data[84, 2], :msb) / 10.0, # kWh
41
+ e_to_grid_day: Utils.int(@data[86, 2], :msb) / 10.0, # kWh
42
+ e_to_user_day: Utils.int(@data[88, 2], :msb) / 10.0, # kWh
43
+
44
+ v_bus_1: Utils.int(@data[91, 2], :lsb) / 10.0, # V
45
+ v_bus_2: Utils.int(@data[93, 2], :lsb) / 10.0 # V
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'utils'
4
+ require_relative 'read_input'
5
+
6
+ class LXP
7
+ class Packet
8
+ class ReadInput2 < ReadInput
9
+ # Decode the data and return a hash of values in this input packet
10
+ def to_h
11
+ {
12
+ e_pv_all: Utils.int(@data[15, 4], :lsb) / 10.0, # kWh
13
+ e_inv_all: Utils.int(@data[27, 4], :lsb) / 10.0, # kWh
14
+ e_rec_all: Utils.int(@data[31, 4], :lsb) / 10.0, # kWh
15
+ e_chg_all: Utils.int(@data[35, 4], :lsb) / 10.0, # kWh
16
+ e_dischg_all: Utils.int(@data[39, 4], :lsb) / 10.0, # kWh
17
+ e_eps_all: Utils.int(@data[43, 4], :lsb) / 10.0, # kWh
18
+ e_to_grid_all: Utils.int(@data[47, 4], :lsb) / 10.0, # kWh
19
+ e_to_user_all: Utils.int(@data[51, 4], :lsb) / 10.0, # kWh
20
+
21
+ t_inner: Utils.int(@data[62, 2], :msb),
22
+ t_rad_1: Utils.int(@data[64, 2], :msb),
23
+ t_rad_2: Utils.int(@data[66, 2], :msb)
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'utils'
4
+ require_relative 'read_input'
5
+
6
+ class LXP
7
+ class Packet
8
+ class ReadInput3 < ReadInput
9
+ # Decode the data and return a hash of values in this input packet
10
+ def to_h
11
+ {
12
+ max_chg_curr: Utils.int(@data[17, 2], :lsb) / 100.0, # A
13
+ max_dischg_curr: Utils.int(@data[19, 2], :lsb) / 100.0, # A
14
+ charge_volt_ref: Utils.int(@data[21, 2], :lsb) / 10.0, # V
15
+ dischg_cut_volt: Utils.int(@data[23, 2], :lsb) / 10.0, # V
16
+
17
+ bat_status_0: @data[25],
18
+ bat_status_1: @data[27],
19
+ bat_status_2: @data[29],
20
+ bat_status_3: @data[31],
21
+ bat_status_4: @data[33],
22
+ bat_status_5: @data[35],
23
+ bat_status_6: @data[37],
24
+ bat_status_7: @data[39],
25
+ bat_status_8: @data[41],
26
+ bat_status_9: @data[43],
27
+ bat_status_inv: @data[45]
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LXP
4
+ class Packet
5
+ module RegisterBits
6
+ ###
7
+ ### Register 21, Most Significant Byte
8
+ ###
9
+ FEED_IN_GRID = 1 << 15
10
+ DCI_ENABLE = 1 << 14
11
+ GFCI_ENABLE = 1 << 13
12
+ R21_UNKNOWN_BIT_12 = 1 << 12
13
+ CHARGE_PRIORITY = 1 << 11
14
+ FORCED_DISCHARGE_ENABLE = 1 << 10
15
+ NORMAL_OR_STANDBY = 1 << 9
16
+ SEAMLESS_EPS_SWITCHING = 1 << 8
17
+
18
+ ### Register 21, Least Significant Byte
19
+ AC_CHARGE_ENABLE = 1 << 7
20
+ GRID_ON_POWER_SS = 1 << 6
21
+ NEUTRAL_DETECT_ENABLE = 1 << 5
22
+ ANTI_ISLAND_ENABLE = 1 << 4
23
+ R21_UNKNOWN_BIT_3 = 1 << 3
24
+ DRMS_ENABLE = 1 << 2
25
+ OVF_LOAD_DERATE_ENABLE = 1 << 1
26
+ POWER_BACKUP_ENABLE = 1 << 0
27
+
28
+ # Not a recommendation, just what my defaults appeared to be when
29
+ # setting up the unit for the first time, so probably sane..?
30
+ R21_DEFAULTS = FEED_IN_GRID |
31
+ DCI_ENABLE | GFCI_ENABLE |
32
+ R21_UNKNOWN_BIT_12 |
33
+ NORMAL_OR_STANDBY | SEAMLESS_EPS_SWITCHING |
34
+ GRID_ON_POWER_SS | ANTI_ISLAND_ENABLE |
35
+ DRMS_ENABLE
36
+
37
+ ###
38
+ ### Register 105, Least Significant Byte
39
+ ###
40
+ MICRO_GRID_ENABLE = 1 << 2
41
+ FAST_ZERO_EXPORT_ENABLE = 1 << 1
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LXP
4
+ class Packet
5
+ module Registers
6
+ # System Charge Rate (%)
7
+ CHARGE_POWER_PERCENT_CMD = 64
8
+
9
+ # System Discharge Rate (%)
10
+ DISCHG_POWER_PERCENT_CMD = 65
11
+
12
+ # Grid Charge Power Rate (%)
13
+ AC_CHARGE_POWER_CMD = 66
14
+
15
+ # Discharge cut-off SOC (%)
16
+ DISCHG_CUT_OFF_SOC_EOD = 105
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LXP
4
+ class Packet
5
+ module TcpFunctions
6
+ HEARTBEAT = 193
7
+ TRANSLATED_DATA = 194
8
+ READ_PARAM = 195
9
+ WRITE_PARAM = 196
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ class LXP
6
+ class Packet
7
+ class WriteSingle < Base
8
+ def initialize
9
+ super
10
+
11
+ self.device_function = DeviceFunctions::WRITE_SINGLE
12
+ self.data_length = 18
13
+ end
14
+
15
+ def discharge_rate=(value)
16
+ self.register = Registers::DISCHG_POWER_PERCENT_CMD
17
+ self.value = value
18
+ end
19
+
20
+ def discharge_cut_off=(value)
21
+ self.register = Registers::DISCHG_CUT_OFF_SOC_EOD
22
+ self.value = value
23
+ end
24
+
25
+ # WriteSingle packets should always (I think) have two byte values.
26
+ #
27
+ # Raise if not, as that is not expected?
28
+ #
29
+ # Base#value will return an int for protocol 1, or an Array
30
+ # for protocol 2. If we can, convert that Array to an int.
31
+ #
32
+ def value
33
+ raise 'value_length not 2?' unless value_length == 2
34
+
35
+ r = super
36
+ r.is_a?(Array) ? Utils.int(r, 2) : r
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/lxp/packet.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'packet/registers'
4
+ require_relative 'packet/register_bits'
5
+
6
+ require_relative 'packet/parser'
7
+
8
+ require_relative 'packet/read_input1'
9
+ require_relative 'packet/read_input2'
10
+ require_relative 'packet/read_input3'
11
+ require_relative 'packet/read_hold'
12
+ require_relative 'packet/write_single'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LXP
4
+ VERSION = '0.1.0'
5
+ end
data/lib/utils.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Utils
4
+ module_function
5
+
6
+ def int(bytes, order = :lsb)
7
+ bytes = bytes.reverse if order == :msb
8
+
9
+ bytes.each_with_index.map do |b, idx|
10
+ b << (idx * 8)
11
+ end.inject(:|)
12
+ end
13
+
14
+ def int_complement(bytes, order = :lsb)
15
+ r = int(bytes, order)
16
+ r -= 0x10000 if r & 0x8000 == 0x8000
17
+ r
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'lxp/version'
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = 'lxp-packet'
9
+ s.version = LXP::VERSION
10
+ s.authors = ['Chris Elsworth']
11
+ s.email = ['chris@cae.me.uk']
12
+
13
+ s.summary = 'Library to generate and parse LuxPower inverter packets'
14
+ s.homepage = 'https://github.com/celsworth/lxp-packet'
15
+ s.license = 'MIT'
16
+
17
+ s.metadata['homepage_uri'] = s.homepage
18
+ s.metadata['source_code_uri'] = 'https://github.com/celsworth/lxp-packet'
19
+
20
+ s.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|s|features)/}) }
22
+ end
23
+ s.bindir = 'exe'
24
+ s.executables = s.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ s.require_paths = ['lib']
26
+
27
+ s.add_development_dependency 'bundler', '~> 2.0'
28
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lxp-packet
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chris Elsworth
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2020-01-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ description:
28
+ email:
29
+ - chris@cae.me.uk
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".gitignore"
35
+ - CHANGELOG.md
36
+ - Gemfile
37
+ - LICENSE.txt
38
+ - README.md
39
+ - doc/LXP_PACKET.txt
40
+ - doc/LXP_REGISTERS.txt
41
+ - lib/lxp/packet.rb
42
+ - lib/lxp/packet/base.rb
43
+ - lib/lxp/packet/device_functions.rb
44
+ - lib/lxp/packet/parser.rb
45
+ - lib/lxp/packet/read_hold.rb
46
+ - lib/lxp/packet/read_input.rb
47
+ - lib/lxp/packet/read_input1.rb
48
+ - lib/lxp/packet/read_input2.rb
49
+ - lib/lxp/packet/read_input3.rb
50
+ - lib/lxp/packet/register_bits.rb
51
+ - lib/lxp/packet/registers.rb
52
+ - lib/lxp/packet/tcp_functions.rb
53
+ - lib/lxp/packet/write_single.rb
54
+ - lib/lxp/version.rb
55
+ - lib/utils.rb
56
+ - lxp-packet.gemspec
57
+ homepage: https://github.com/celsworth/lxp-packet
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://github.com/celsworth/lxp-packet
62
+ source_code_uri: https://github.com/celsworth/lxp-packet
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.0.3
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: Library to generate and parse LuxPower inverter packets
82
+ test_files: []