growatt-protocol 0.1.0__py3-none-any.whl
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.
- growatt_protocol/__init__.py +124 -0
- growatt_protocol/commands.py +262 -0
- growatt_protocol/crc.py +55 -0
- growatt_protocol/crypt.py +58 -0
- growatt_protocol/errors.py +29 -0
- growatt_protocol/framing.py +109 -0
- growatt_protocol/py.typed +0 -0
- growatt_protocol/records.py +301 -0
- growatt_protocol/redaction.py +80 -0
- growatt_protocol/registers/__init__.py +56 -0
- growatt_protocol/registers/base.py +238 -0
- growatt_protocol/registers/profiles.py +171 -0
- growatt_protocol/registers/tables/__init__.py +1 -0
- growatt_protocol/registers/tables/legacy_315.py +64 -0
- growatt_protocol/registers/tables/offgrid.py +62 -0
- growatt_protocol/registers/tables/protocol_ii.py +141 -0
- growatt_protocol/registers/tables/storage.py +56 -0
- growatt_protocol/registers/writable.py +232 -0
- growatt_protocol/relay.py +168 -0
- growatt_protocol/server.py +272 -0
- growatt_protocol/session.py +452 -0
- growatt_protocol/testing/__init__.py +26 -0
- growatt_protocol/testing/datalogger.py +157 -0
- growatt_protocol/testing/frames.py +101 -0
- growatt_protocol/testing/upstream.py +62 -0
- growatt_protocol-0.1.0.dist-info/METADATA +117 -0
- growatt_protocol-0.1.0.dist-info/RECORD +31 -0
- growatt_protocol-0.1.0.dist-info/WHEEL +4 -0
- growatt_protocol-0.1.0.dist-info/licenses/LICENSE +21 -0
- growatt_protocol-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- growatt_protocol-0.1.0.dist-info/licenses/NOTICE +22 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The Growatt datalogger upload protocol, and what its registers mean.
|
|
2
|
+
|
|
3
|
+
A standalone, dependency-free implementation of the protocol a Growatt datalogger speaks
|
|
4
|
+
to its server over TCP 5279: framing, obfuscation, checksums, record decoding, and the
|
|
5
|
+
commands for reading and writing inverter registers. Nothing here imports Home Assistant
|
|
6
|
+
or anything outside the standard library, and a test enforces both.
|
|
7
|
+
|
|
8
|
+
Records are self-describing -- each one states the Modbus register ranges it carries --
|
|
9
|
+
so decoding reads register numbers off the wire rather than indexing a per-model table of
|
|
10
|
+
byte offsets::
|
|
11
|
+
|
|
12
|
+
from growatt_protocol import Frame, Framer, parse_register_record
|
|
13
|
+
from growatt_protocol.registers import decode_registers, resolve_profile
|
|
14
|
+
|
|
15
|
+
framer = Framer()
|
|
16
|
+
for raw in framer.feed(data):
|
|
17
|
+
payload = parse_register_record(Frame(raw))
|
|
18
|
+
match = resolve_profile([(g.start, g.end) for g in payload.groups])
|
|
19
|
+
values = decode_registers(match.profile, payload.registers).values
|
|
20
|
+
|
|
21
|
+
For a whole server, :class:`GrowattServer` accepts connections and hands decoded records
|
|
22
|
+
to a callback. :mod:`growatt_protocol.testing` provides a fake datalogger to drive it
|
|
23
|
+
without hardware.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from . import registers, testing
|
|
29
|
+
from .commands import (
|
|
30
|
+
REGISTER_TIME,
|
|
31
|
+
Command,
|
|
32
|
+
CommandResponse,
|
|
33
|
+
parse_command_response,
|
|
34
|
+
read_datalogger,
|
|
35
|
+
read_inverter,
|
|
36
|
+
set_time,
|
|
37
|
+
write_datalogger,
|
|
38
|
+
write_inverter,
|
|
39
|
+
write_inverter_range,
|
|
40
|
+
)
|
|
41
|
+
from .crc import append_crc, check_crc, modbus_crc
|
|
42
|
+
from .crypt import (
|
|
43
|
+
KEY,
|
|
44
|
+
OBFUSCATED_PROTOCOLS,
|
|
45
|
+
SUPPORTED_PROTOCOLS,
|
|
46
|
+
deobfuscate,
|
|
47
|
+
obfuscate,
|
|
48
|
+
xor_payload,
|
|
49
|
+
)
|
|
50
|
+
from .errors import (
|
|
51
|
+
CommandTimeout,
|
|
52
|
+
FrameError,
|
|
53
|
+
GrowattProtocolError,
|
|
54
|
+
RecordError,
|
|
55
|
+
)
|
|
56
|
+
from .framing import DEFAULT_MAX_FRAME, Framer, frame_length
|
|
57
|
+
from .records import (
|
|
58
|
+
COMMAND_RESPONSE_FUNCTIONS,
|
|
59
|
+
METER_FUNCTIONS,
|
|
60
|
+
REGISTER_RECORD_FUNCTIONS,
|
|
61
|
+
Frame,
|
|
62
|
+
Function,
|
|
63
|
+
RecordPayload,
|
|
64
|
+
RegisterGroup,
|
|
65
|
+
build_ack,
|
|
66
|
+
build_ping_echo,
|
|
67
|
+
parse_register_record,
|
|
68
|
+
)
|
|
69
|
+
from .relay import RelayConfig, RelayConnection
|
|
70
|
+
from .server import DEFAULT_PORT, GrowattServer, ServerConfig, ServerStats
|
|
71
|
+
from .session import Record, Session, SessionStats
|
|
72
|
+
|
|
73
|
+
__version__ = "0.1.0"
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"COMMAND_RESPONSE_FUNCTIONS",
|
|
77
|
+
"DEFAULT_MAX_FRAME",
|
|
78
|
+
"DEFAULT_PORT",
|
|
79
|
+
"KEY",
|
|
80
|
+
"METER_FUNCTIONS",
|
|
81
|
+
"OBFUSCATED_PROTOCOLS",
|
|
82
|
+
"REGISTER_RECORD_FUNCTIONS",
|
|
83
|
+
"REGISTER_TIME",
|
|
84
|
+
"SUPPORTED_PROTOCOLS",
|
|
85
|
+
"Command",
|
|
86
|
+
"CommandResponse",
|
|
87
|
+
"CommandTimeout",
|
|
88
|
+
"Frame",
|
|
89
|
+
"FrameError",
|
|
90
|
+
"Framer",
|
|
91
|
+
"Function",
|
|
92
|
+
"GrowattProtocolError",
|
|
93
|
+
"GrowattServer",
|
|
94
|
+
"Record",
|
|
95
|
+
"RecordError",
|
|
96
|
+
"RecordPayload",
|
|
97
|
+
"RegisterGroup",
|
|
98
|
+
"RelayConfig",
|
|
99
|
+
"RelayConnection",
|
|
100
|
+
"ServerConfig",
|
|
101
|
+
"ServerStats",
|
|
102
|
+
"Session",
|
|
103
|
+
"SessionStats",
|
|
104
|
+
"__version__",
|
|
105
|
+
"append_crc",
|
|
106
|
+
"build_ack",
|
|
107
|
+
"build_ping_echo",
|
|
108
|
+
"check_crc",
|
|
109
|
+
"deobfuscate",
|
|
110
|
+
"frame_length",
|
|
111
|
+
"modbus_crc",
|
|
112
|
+
"obfuscate",
|
|
113
|
+
"parse_command_response",
|
|
114
|
+
"parse_register_record",
|
|
115
|
+
"read_datalogger",
|
|
116
|
+
"read_inverter",
|
|
117
|
+
"registers",
|
|
118
|
+
"set_time",
|
|
119
|
+
"testing",
|
|
120
|
+
"write_datalogger",
|
|
121
|
+
"write_inverter",
|
|
122
|
+
"write_inverter_range",
|
|
123
|
+
"xor_payload",
|
|
124
|
+
]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Commands the server sends to a datalogger, and the replies it gets back.
|
|
2
|
+
|
|
3
|
+
Four kinds, distinguished by function code:
|
|
4
|
+
|
|
5
|
+
=========== ============================================= ================
|
|
6
|
+
Function Meaning Reply
|
|
7
|
+
=========== ============================================= ================
|
|
8
|
+
``0x19`` read a datalogger parameter ``0x19``
|
|
9
|
+
``0x18`` write a datalogger parameter (incl. the clock) ``0x18``
|
|
10
|
+
``0x05`` read an inverter holding register ``0x05``
|
|
11
|
+
``0x06`` write one inverter holding register ``0x06``
|
|
12
|
+
``0x10`` write a range of inverter holding registers ``0x10``
|
|
13
|
+
=========== ============================================= ================
|
|
14
|
+
|
|
15
|
+
The body always begins with the datalogger serial, padded to the same width the device
|
|
16
|
+
uses in its own records: 30 bytes on protocol 06, 10 bytes on 02 and 05. Everything after
|
|
17
|
+
that shifts accordingly, which is why the response parsers take an offset.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from enum import IntEnum
|
|
25
|
+
|
|
26
|
+
from .crc import append_crc
|
|
27
|
+
from .crypt import OBFUSCATED_PROTOCOLS, obfuscate
|
|
28
|
+
from .errors import RecordError
|
|
29
|
+
from .records import Frame, Function
|
|
30
|
+
|
|
31
|
+
#: Datalogger parameter holding the wall clock, as an ASCII timestamp.
|
|
32
|
+
REGISTER_TIME = 0x1F
|
|
33
|
+
|
|
34
|
+
#: Other datalogger parameters, as reported by the device in 0x19 replies.
|
|
35
|
+
REGISTER_UPDATE_INTERVAL = 0x04
|
|
36
|
+
REGISTER_SERIAL = 0x08
|
|
37
|
+
REGISTER_SERVER_IP = 0x11
|
|
38
|
+
REGISTER_SERVER_PORT = 0x12
|
|
39
|
+
REGISTER_TIMEZONE = 0x1E
|
|
40
|
+
|
|
41
|
+
_SERIAL_WIDTH = {2: 10, 5: 10, 6: 30}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Target(IntEnum):
|
|
45
|
+
"""What a command addresses."""
|
|
46
|
+
|
|
47
|
+
DATALOGGER = 0
|
|
48
|
+
INVERTER = 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _serial_field(serial: str, protocol: int) -> bytes:
|
|
52
|
+
width = _SERIAL_WIDTH.get(protocol)
|
|
53
|
+
if width is None:
|
|
54
|
+
raise RecordError(f"no serial width known for protocol {protocol}")
|
|
55
|
+
encoded = serial.encode("ascii")
|
|
56
|
+
if len(encoded) > width:
|
|
57
|
+
raise RecordError(f"serial {serial!r} does not fit in {width} bytes")
|
|
58
|
+
return encoded.ljust(width, b"\x00")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _frame(sequence: int, protocol: int, device_id: int, function: int, body: bytes) -> bytes:
|
|
62
|
+
declared = 2 + len(body)
|
|
63
|
+
frame = (
|
|
64
|
+
(sequence & 0xFFFF).to_bytes(2, "big")
|
|
65
|
+
+ b"\x00"
|
|
66
|
+
+ bytes([protocol])
|
|
67
|
+
+ declared.to_bytes(2, "big")
|
|
68
|
+
+ bytes([device_id, function])
|
|
69
|
+
+ body
|
|
70
|
+
)
|
|
71
|
+
if protocol in OBFUSCATED_PROTOCOLS:
|
|
72
|
+
return append_crc(obfuscate(frame, protocol))
|
|
73
|
+
return frame
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class Command:
|
|
78
|
+
"""A command ready to be given a sequence number and sent."""
|
|
79
|
+
|
|
80
|
+
function: int
|
|
81
|
+
register: int
|
|
82
|
+
body: bytes
|
|
83
|
+
device_id: int = 0x01
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def response_function(self) -> int:
|
|
87
|
+
"""The function code the device answers with. Always the request's own."""
|
|
88
|
+
return self.function
|
|
89
|
+
|
|
90
|
+
def build(self, sequence: int, protocol: int) -> bytes:
|
|
91
|
+
return _frame(sequence, protocol, self.device_id, self.function, self.body)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ----------------------------------------------------------------------------------
|
|
95
|
+
# Builders
|
|
96
|
+
# ----------------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_datalogger(serial: str, protocol: int, register: int) -> Command:
|
|
100
|
+
"""Read one datalogger parameter (0x19)."""
|
|
101
|
+
body = _serial_field(serial, protocol)
|
|
102
|
+
body += register.to_bytes(2, "big") + register.to_bytes(2, "big")
|
|
103
|
+
return Command(Function.CONFIG_READ, register, body)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write_datalogger(serial: str, protocol: int, register: int, value: str) -> Command:
|
|
107
|
+
"""Write one datalogger parameter (0x18).
|
|
108
|
+
|
|
109
|
+
Datalogger parameters are strings, length-prefixed -- unlike inverter registers,
|
|
110
|
+
which are bare 16-bit words.
|
|
111
|
+
"""
|
|
112
|
+
encoded = value.encode("utf-8")
|
|
113
|
+
body = _serial_field(serial, protocol)
|
|
114
|
+
body += register.to_bytes(2, "big")
|
|
115
|
+
body += len(encoded).to_bytes(2, "big") + encoded
|
|
116
|
+
return Command(Function.CONFIG_WRITE, register, body)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_time(serial: str, protocol: int, when: datetime) -> Command:
|
|
120
|
+
"""Set the datalogger clock (0x18, register 0x1f).
|
|
121
|
+
|
|
122
|
+
The device wants ``YYYY-MM-DD HH:MM:SS`` as ASCII -- 19 bytes. Sent in local time,
|
|
123
|
+
since that is what the timestamps in its own records are expressed in.
|
|
124
|
+
"""
|
|
125
|
+
text = when.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
|
|
126
|
+
return write_datalogger(serial, protocol, REGISTER_TIME, text)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_inverter(serial: str, protocol: int, start: int, end: int | None = None) -> Command:
|
|
130
|
+
"""Read one inverter holding register, or a contiguous range (0x05)."""
|
|
131
|
+
end = start if end is None else end
|
|
132
|
+
if end < start:
|
|
133
|
+
raise ValueError(f"inverted register range {start}..{end}")
|
|
134
|
+
body = _serial_field(serial, protocol)
|
|
135
|
+
body += start.to_bytes(2, "big") + end.to_bytes(2, "big")
|
|
136
|
+
return Command(Function.INVERTER_READ, start, body)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_inverter(serial: str, protocol: int, register: int, value: int) -> Command:
|
|
140
|
+
"""Write one inverter holding register (0x06).
|
|
141
|
+
|
|
142
|
+
Note the asymmetry with :func:`write_datalogger`: there is no length field here, the
|
|
143
|
+
value is simply a 16-bit word.
|
|
144
|
+
"""
|
|
145
|
+
if not 0 <= value <= 0xFFFF:
|
|
146
|
+
raise ValueError(f"{value} does not fit in a 16-bit register")
|
|
147
|
+
body = _serial_field(serial, protocol)
|
|
148
|
+
body += register.to_bytes(2, "big") + value.to_bytes(2, "big")
|
|
149
|
+
return Command(Function.INVERTER_WRITE, register, body)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def write_inverter_range(serial: str, protocol: int, start: int, values: list[int]) -> Command:
|
|
153
|
+
"""Write a contiguous run of inverter holding registers (0x10)."""
|
|
154
|
+
if not values:
|
|
155
|
+
raise ValueError("no values to write")
|
|
156
|
+
end = start + len(values) - 1
|
|
157
|
+
body = _serial_field(serial, protocol)
|
|
158
|
+
body += start.to_bytes(2, "big") + end.to_bytes(2, "big")
|
|
159
|
+
for value in values:
|
|
160
|
+
if not 0 <= value <= 0xFFFF:
|
|
161
|
+
raise ValueError(f"{value} does not fit in a 16-bit register")
|
|
162
|
+
body += value.to_bytes(2, "big")
|
|
163
|
+
return Command(Function.INVERTER_WRITE_MULTI, start, body)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ----------------------------------------------------------------------------------
|
|
167
|
+
# Responses
|
|
168
|
+
# ----------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True, slots=True)
|
|
172
|
+
class CommandResponse:
|
|
173
|
+
"""A device's reply to a command."""
|
|
174
|
+
|
|
175
|
+
function: int
|
|
176
|
+
register: int | None
|
|
177
|
+
value: int | str | None = None
|
|
178
|
+
result: int | None = None
|
|
179
|
+
"""The device's status byte for a write. Zero means accepted."""
|
|
180
|
+
|
|
181
|
+
empty: bool = False
|
|
182
|
+
"""A read that returned nothing, which is how a device reports an unknown register."""
|
|
183
|
+
|
|
184
|
+
end_register: int | None = None
|
|
185
|
+
"""The last register of the range, echoed back by a read."""
|
|
186
|
+
|
|
187
|
+
values: tuple[int, ...] = ()
|
|
188
|
+
"""Every word a range read returned. :attr:`value` is the first of them."""
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def ok(self) -> bool:
|
|
192
|
+
return self.result in (None, 0)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_command_response(frame: Frame) -> CommandResponse:
|
|
196
|
+
"""Interpret a 0x05, 0x06, 0x10, 0x18 or 0x19 reply.
|
|
197
|
+
|
|
198
|
+
Offsets are relative to the end of the serial field, which is 20 bytes wider on
|
|
199
|
+
protocol 06.
|
|
200
|
+
"""
|
|
201
|
+
function = frame.function
|
|
202
|
+
body = frame.body
|
|
203
|
+
width = frame.serial_width
|
|
204
|
+
|
|
205
|
+
if len(body) < width + 2:
|
|
206
|
+
raise RecordError(f"{function:#04x} response is too short to hold a register")
|
|
207
|
+
|
|
208
|
+
register = int.from_bytes(body[width : width + 2], "big")
|
|
209
|
+
rest = body[width + 2 :]
|
|
210
|
+
|
|
211
|
+
if function == Function.INVERTER_READ:
|
|
212
|
+
# The reply echoes the range it was asked for -- start *and* end -- before any
|
|
213
|
+
# values, mirroring the request. Reading the word straight after the start
|
|
214
|
+
# register therefore yields the end register rather than the value, which on a
|
|
215
|
+
# single-register read looks convincingly like a plausible number: asking for
|
|
216
|
+
# register 3 comes back as 3. Confirmed against real hardware.
|
|
217
|
+
#
|
|
218
|
+
# A device that does not implement the range answers with the echo and nothing
|
|
219
|
+
# after it, rather than with an error.
|
|
220
|
+
if len(rest) < 4:
|
|
221
|
+
return CommandResponse(function, register, empty=True)
|
|
222
|
+
|
|
223
|
+
end = int.from_bytes(rest[:2], "big")
|
|
224
|
+
payload = rest[2:]
|
|
225
|
+
values = tuple(
|
|
226
|
+
int.from_bytes(payload[i : i + 2], "big")
|
|
227
|
+
for i in range(0, len(payload) - len(payload) % 2, 2)
|
|
228
|
+
)
|
|
229
|
+
if not values:
|
|
230
|
+
return CommandResponse(function, register, end_register=end, empty=True)
|
|
231
|
+
return CommandResponse(function, register, value=values[0], end_register=end, values=values)
|
|
232
|
+
|
|
233
|
+
if function == Function.INVERTER_WRITE:
|
|
234
|
+
# Both fields are present and both matter: an implementation that overwrites one
|
|
235
|
+
# with the other loses the device's acceptance status.
|
|
236
|
+
if not rest:
|
|
237
|
+
raise RecordError("0x06 response carries no result byte")
|
|
238
|
+
result = rest[0]
|
|
239
|
+
value = int.from_bytes(rest[1:3], "big") if len(rest) >= 3 else None
|
|
240
|
+
return CommandResponse(function, register, value=value, result=result)
|
|
241
|
+
|
|
242
|
+
if function == Function.INVERTER_WRITE_MULTI:
|
|
243
|
+
# The register field here is the start of the range; the end follows.
|
|
244
|
+
if len(rest) < 3:
|
|
245
|
+
raise RecordError("0x10 response is truncated")
|
|
246
|
+
return CommandResponse(function, register, result=rest[2])
|
|
247
|
+
|
|
248
|
+
if function == Function.CONFIG_WRITE:
|
|
249
|
+
if not rest:
|
|
250
|
+
raise RecordError("0x18 response carries no result byte")
|
|
251
|
+
return CommandResponse(function, register, result=rest[0])
|
|
252
|
+
|
|
253
|
+
if function == Function.CONFIG_READ:
|
|
254
|
+
if len(rest) < 2:
|
|
255
|
+
return CommandResponse(function, register, empty=True)
|
|
256
|
+
length = int.from_bytes(rest[:2], "big")
|
|
257
|
+
text = rest[2 : 2 + length]
|
|
258
|
+
# ISO-8859-1 rather than UTF-8: these fields carry SSIDs and hostnames, and a
|
|
259
|
+
# stray high byte should not make the whole reply undecodable.
|
|
260
|
+
return CommandResponse(function, register, value=text.decode("ISO-8859-1").rstrip("\x00"))
|
|
261
|
+
|
|
262
|
+
raise RecordError(f"{function:#04x} is not a command response")
|
growatt_protocol/crc.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""CRC-16/MODBUS.
|
|
2
|
+
|
|
3
|
+
Growatt frames using protocol version 05 or 06 carry a two-byte checksum after the
|
|
4
|
+
declared payload. The algorithm is the standard Modbus CRC-16 (reflected polynomial
|
|
5
|
+
0xA001, initial value 0xFFFF, no final XOR), but note that Growatt transmits it
|
|
6
|
+
**big-endian**, which is the opposite of Modbus RTU on a serial line.
|
|
7
|
+
|
|
8
|
+
This is a pure-Python replacement for the ``libscrc`` C extension, which is the only
|
|
9
|
+
compiled dependency other Growatt tooling needs. A 256-entry table is built once at
|
|
10
|
+
import; frames are under a kilobyte and arrive every few minutes, so this is far
|
|
11
|
+
faster than it needs to be.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
_POLY = 0xA001
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_table() -> tuple[int, ...]:
|
|
20
|
+
table = []
|
|
21
|
+
for byte in range(256):
|
|
22
|
+
crc = byte
|
|
23
|
+
for _ in range(8):
|
|
24
|
+
crc = (crc >> 1) ^ _POLY if crc & 1 else crc >> 1
|
|
25
|
+
table.append(crc)
|
|
26
|
+
return tuple(table)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_TABLE = _build_table()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def modbus_crc(data: bytes) -> int:
|
|
33
|
+
"""Return the CRC-16/MODBUS of ``data`` as an unsigned 16-bit integer."""
|
|
34
|
+
crc = 0xFFFF
|
|
35
|
+
for byte in data:
|
|
36
|
+
crc = (crc >> 8) ^ _TABLE[(crc ^ byte) & 0xFF]
|
|
37
|
+
return crc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def append_crc(frame: bytes) -> bytes:
|
|
41
|
+
"""Return ``frame`` with its CRC appended big-endian, as Growatt transmits it."""
|
|
42
|
+
return frame + modbus_crc(frame).to_bytes(2, "big")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_crc(frame: bytes) -> bool:
|
|
46
|
+
"""Whether ``frame``'s trailing two bytes match a CRC over everything before them.
|
|
47
|
+
|
|
48
|
+
Callers should treat a mismatch as advisory. Some dataloggers have been reported to
|
|
49
|
+
fail this check on every single record while still emitting perfectly decodable
|
|
50
|
+
payloads, so refusing such records loses all data from that device. Count the
|
|
51
|
+
mismatch, log it, and decode anyway; frame length is the real validity gate.
|
|
52
|
+
"""
|
|
53
|
+
if len(frame) < 3:
|
|
54
|
+
return False
|
|
55
|
+
return modbus_crc(frame[:-2]) == int.from_bytes(frame[-2:], "big")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The XOR obfuscation used by Growatt protocol versions 05 and 06.
|
|
2
|
+
|
|
3
|
+
The payload is XORed with the repeating ASCII key ``Growatt``. Two details matter and
|
|
4
|
+
are easy to get wrong:
|
|
5
|
+
|
|
6
|
+
* The eight-byte header is **not** obfuscated. It is transmitted in clear so that a
|
|
7
|
+
receiver can read the protocol version and length before it knows whether to
|
|
8
|
+
deobfuscate anything.
|
|
9
|
+
* The keystream index restarts at zero at byte offset 8, so the byte at offset 8 is
|
|
10
|
+
XORed with ``'G'``, not with ``KEY[8 % 7]``.
|
|
11
|
+
|
|
12
|
+
Protocol version 02 is not obfuscated at all. Do not apply this unconditionally.
|
|
13
|
+
|
|
14
|
+
The transform is its own inverse, so the same function encrypts outbound frames.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
KEY = b"Growatt"
|
|
20
|
+
|
|
21
|
+
HEADER_LENGTH = 8
|
|
22
|
+
|
|
23
|
+
#: Protocol versions whose payload is XOR-obfuscated and which carry a trailing CRC.
|
|
24
|
+
OBFUSCATED_PROTOCOLS = frozenset({5, 6})
|
|
25
|
+
|
|
26
|
+
#: Every protocol version this implementation understands.
|
|
27
|
+
SUPPORTED_PROTOCOLS = frozenset({2, 5, 6})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def xor_payload(frame: bytes) -> bytes:
|
|
31
|
+
"""Return ``frame`` with its payload XORed against the repeating key.
|
|
32
|
+
|
|
33
|
+
The first :data:`HEADER_LENGTH` bytes are copied unchanged. Applying this twice
|
|
34
|
+
returns the original frame.
|
|
35
|
+
"""
|
|
36
|
+
if len(frame) <= HEADER_LENGTH:
|
|
37
|
+
return bytes(frame)
|
|
38
|
+
|
|
39
|
+
header = bytes(frame[:HEADER_LENGTH])
|
|
40
|
+
body = memoryview(frame)[HEADER_LENGTH:]
|
|
41
|
+
key_len = len(KEY)
|
|
42
|
+
# Build a keystream that is at least as long as the body, then truncate. This is
|
|
43
|
+
# meaningfully faster than a per-byte modulo for the sizes involved.
|
|
44
|
+
repeats = -(-len(body) // key_len)
|
|
45
|
+
keystream = (KEY * repeats)[: len(body)]
|
|
46
|
+
return header + bytes(a ^ b for a, b in zip(body, keystream, strict=True))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def deobfuscate(frame: bytes, protocol: int) -> bytes:
|
|
50
|
+
"""Return the plaintext of ``frame`` for the given protocol version."""
|
|
51
|
+
if protocol in OBFUSCATED_PROTOCOLS:
|
|
52
|
+
return xor_payload(frame)
|
|
53
|
+
return bytes(frame)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def obfuscate(frame: bytes, protocol: int) -> bytes:
|
|
57
|
+
"""Inverse of :func:`deobfuscate`. Identical, but named for the calling direction."""
|
|
58
|
+
return deobfuscate(frame, protocol)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exceptions raised by the protocol layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GrowattProtocolError(Exception):
|
|
7
|
+
"""Base class for every protocol-layer failure."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FrameError(GrowattProtocolError):
|
|
11
|
+
"""A byte stream could not be split into frames.
|
|
12
|
+
|
|
13
|
+
Raised for structurally impossible framing -- an unsupported protocol version, a
|
|
14
|
+
declared length that cannot be valid, or a frame larger than the configured
|
|
15
|
+
maximum. The connection should be closed, because the stream position is no longer
|
|
16
|
+
trustworthy.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RecordError(GrowattProtocolError):
|
|
21
|
+
"""A frame was well-framed but its payload could not be interpreted.
|
|
22
|
+
|
|
23
|
+
Unlike :class:`FrameError` this is recoverable: the frame boundary was correct, so
|
|
24
|
+
the connection can continue with the next frame.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CommandTimeout(GrowattProtocolError):
|
|
29
|
+
"""A command was sent but no matching response arrived in time."""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Incremental reassembly of Growatt frames from a TCP byte stream.
|
|
2
|
+
|
|
3
|
+
TCP is a stream, not a message queue. A datalogger's records arrive split across several
|
|
4
|
+
segments, or several records arrive coalesced into one. Treating each ``recv()`` as
|
|
5
|
+
exactly one record -- which is a common shortcut in this problem space -- corrupts data
|
|
6
|
+
as soon as either happens.
|
|
7
|
+
|
|
8
|
+
Feed arbitrary chunks to :meth:`Framer.feed` and it yields whole frames.
|
|
9
|
+
|
|
10
|
+
Frame length is derived from the header::
|
|
11
|
+
|
|
12
|
+
total = 6 + declared_length + (2 if protocol in (5, 6) else 0)
|
|
13
|
+
|
|
14
|
+
``declared_length`` counts the device-id and function-code bytes plus the payload, but
|
|
15
|
+
excludes the trailing CRC that protocol 05/06 appends.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .crypt import OBFUSCATED_PROTOCOLS, SUPPORTED_PROTOCOLS
|
|
21
|
+
from .errors import FrameError
|
|
22
|
+
|
|
23
|
+
#: Largest frame we will assemble. Real records are well under 1 KB; this exists so a
|
|
24
|
+
#: corrupt or hostile length field cannot make us buffer without bound.
|
|
25
|
+
DEFAULT_MAX_FRAME = 8192
|
|
26
|
+
|
|
27
|
+
#: Bytes of header needed before the total frame length can be computed.
|
|
28
|
+
_LENGTH_PREFIX = 6
|
|
29
|
+
|
|
30
|
+
#: Smallest meaningful declared length: the device id and function code alone.
|
|
31
|
+
_MIN_DECLARED_LENGTH = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def frame_length(header: bytes) -> int:
|
|
35
|
+
"""Return the total on-the-wire length of the frame beginning with ``header``.
|
|
36
|
+
|
|
37
|
+
``header`` must be at least :data:`_LENGTH_PREFIX` bytes.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
FrameError: if the protocol version is unsupported or the declared length is
|
|
41
|
+
structurally impossible.
|
|
42
|
+
"""
|
|
43
|
+
if len(header) < _LENGTH_PREFIX:
|
|
44
|
+
raise ValueError(f"need at least {_LENGTH_PREFIX} bytes to compute frame length")
|
|
45
|
+
|
|
46
|
+
protocol = header[3]
|
|
47
|
+
if protocol not in SUPPORTED_PROTOCOLS:
|
|
48
|
+
raise FrameError(f"unsupported Growatt protocol version 0x{protocol:02x}")
|
|
49
|
+
|
|
50
|
+
declared = int.from_bytes(header[4:6], "big")
|
|
51
|
+
if declared < _MIN_DECLARED_LENGTH:
|
|
52
|
+
raise FrameError(f"declared frame length {declared} is below the minimum")
|
|
53
|
+
|
|
54
|
+
crc_length = 2 if protocol in OBFUSCATED_PROTOCOLS else 0
|
|
55
|
+
return _LENGTH_PREFIX + declared + crc_length
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Framer:
|
|
59
|
+
"""Stateful reassembler. One instance per connection, per direction."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, max_frame: int = DEFAULT_MAX_FRAME) -> None:
|
|
62
|
+
if max_frame < _LENGTH_PREFIX + _MIN_DECLARED_LENGTH:
|
|
63
|
+
raise ValueError("max_frame is too small to hold any valid frame")
|
|
64
|
+
self.max_frame = max_frame
|
|
65
|
+
self._buffer = bytearray()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def pending(self) -> bytes:
|
|
69
|
+
"""Bytes buffered so far that do not yet form a complete frame."""
|
|
70
|
+
return bytes(self._buffer)
|
|
71
|
+
|
|
72
|
+
def reset(self) -> None:
|
|
73
|
+
"""Discard buffered bytes. Use after an error, before reusing the instance."""
|
|
74
|
+
self._buffer.clear()
|
|
75
|
+
|
|
76
|
+
def feed(self, chunk: bytes) -> list[bytes]:
|
|
77
|
+
"""Add ``chunk`` to the buffer and return every frame it completes.
|
|
78
|
+
|
|
79
|
+
Frames are returned in arrival order. Any trailing partial frame stays buffered
|
|
80
|
+
for the next call.
|
|
81
|
+
|
|
82
|
+
This is deliberately eager rather than a generator: the buffer must be updated
|
|
83
|
+
even when the caller does not consume the result, and an error must surface at
|
|
84
|
+
the call site rather than at some later iteration.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
FrameError: on an unsupported protocol version, an impossible declared
|
|
88
|
+
length, or a frame exceeding ``max_frame``. The connection should be
|
|
89
|
+
closed: once the length field is untrustworthy, so is the stream
|
|
90
|
+
position. Frames completed before the bad one are lost with the
|
|
91
|
+
exception, which is the right trade -- a stream we can no longer
|
|
92
|
+
position ourselves in has no salvageable remainder.
|
|
93
|
+
"""
|
|
94
|
+
self._buffer.extend(chunk)
|
|
95
|
+
frames: list[bytes] = []
|
|
96
|
+
|
|
97
|
+
while len(self._buffer) >= _LENGTH_PREFIX:
|
|
98
|
+
total = frame_length(self._buffer)
|
|
99
|
+
|
|
100
|
+
if total > self.max_frame:
|
|
101
|
+
raise FrameError(f"frame length {total} exceeds maximum {self.max_frame}")
|
|
102
|
+
|
|
103
|
+
if len(self._buffer) < total:
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
frames.append(bytes(self._buffer[:total]))
|
|
107
|
+
del self._buffer[:total]
|
|
108
|
+
|
|
109
|
+
return frames
|
|
File without changes
|