lxp-packet 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +161 -0
- data/doc/LXP_PACKET.txt +64 -0
- data/doc/LXP_REGISTERS.txt +126 -0
- data/lib/lxp/packet/base.rb +179 -0
- data/lib/lxp/packet/device_functions.rb +12 -0
- data/lib/lxp/packet/parser.rb +55 -0
- data/lib/lxp/packet/read_hold.rb +36 -0
- data/lib/lxp/packet/read_input.rb +20 -0
- data/lib/lxp/packet/read_input1.rb +50 -0
- data/lib/lxp/packet/read_input2.rb +28 -0
- data/lib/lxp/packet/read_input3.rb +32 -0
- data/lib/lxp/packet/register_bits.rb +44 -0
- data/lib/lxp/packet/registers.rb +19 -0
- data/lib/lxp/packet/tcp_functions.rb +12 -0
- data/lib/lxp/packet/write_single.rb +40 -0
- data/lib/lxp/packet.rb +12 -0
- data/lib/lxp/version.rb +5 -0
- data/lib/utils.rb +19 -0
- data/lxp-packet.gemspec +28 -0
- metadata +82 -0
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
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
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
|
+
```
|
data/doc/LXP_PACKET.txt
ADDED
@@ -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,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,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'
|
data/lib/lxp/version.rb
ADDED
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
|
data/lxp-packet.gemspec
ADDED
@@ -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: []
|