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.
@@ -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")
@@ -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