py1815 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.
- py1815/__init__.py +74 -0
- py1815/application.py +501 -0
- py1815/control.py +473 -0
- py1815/crc.py +68 -0
- py1815/events.py +326 -0
- py1815/link.py +294 -0
- py1815/objects.py +401 -0
- py1815/py.typed +0 -0
- py1815/server.py +406 -0
- py1815/session.py +1549 -0
- py1815/transport.py +141 -0
- py1815-0.1.0.dist-info/METADATA +161 -0
- py1815-0.1.0.dist-info/RECORD +16 -0
- py1815-0.1.0.dist-info/WHEEL +4 -0
- py1815-0.1.0.dist-info/licenses/LICENSE +202 -0
- py1815-0.1.0.dist-info/licenses/NOTICE +10 -0
py1815/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""DNP3 (IEEE 1815) outstation.
|
|
2
|
+
|
|
3
|
+
The layers are separate modules so that each is testable without the ones above
|
|
4
|
+
it: framing without a data source, the session without a socket, and neither
|
|
5
|
+
test having to stand up the other half. This package holds the protocol below
|
|
6
|
+
the point map -- the data link, the transport function, the application
|
|
7
|
+
envelope, the static object encoders, and the session that drives them.
|
|
8
|
+
|
|
9
|
+
See ``docs/DESIGN.md`` for the decisions behind the shape.
|
|
10
|
+
|
|
11
|
+
Copyright 2026 DER Security Corp. Licensed under the Apache License, Version 2.0.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from py1815 import control, crc, events, objects
|
|
17
|
+
from py1815.application import (
|
|
18
|
+
IIN,
|
|
19
|
+
AppControl,
|
|
20
|
+
FunctionCode,
|
|
21
|
+
IIN2Bit,
|
|
22
|
+
IINBit,
|
|
23
|
+
QualifierCode,
|
|
24
|
+
Request,
|
|
25
|
+
RequestError,
|
|
26
|
+
build_response,
|
|
27
|
+
null_response,
|
|
28
|
+
object_header,
|
|
29
|
+
parse_request,
|
|
30
|
+
)
|
|
31
|
+
from py1815.link import (
|
|
32
|
+
Broadcast,
|
|
33
|
+
FrameReader,
|
|
34
|
+
LinkFrame,
|
|
35
|
+
LinkFrameError,
|
|
36
|
+
PrimaryFunction,
|
|
37
|
+
SecondaryFunction,
|
|
38
|
+
)
|
|
39
|
+
from py1815.server import OutstationServer, PeerRefused
|
|
40
|
+
from py1815.session import ReadProvider, Session, UnknownObject
|
|
41
|
+
from py1815.transport import Reassembler, TransportError, segment
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"IIN",
|
|
45
|
+
"AppControl",
|
|
46
|
+
"Broadcast",
|
|
47
|
+
"FrameReader",
|
|
48
|
+
"FunctionCode",
|
|
49
|
+
"IIN2Bit",
|
|
50
|
+
"IINBit",
|
|
51
|
+
"LinkFrame",
|
|
52
|
+
"LinkFrameError",
|
|
53
|
+
"OutstationServer",
|
|
54
|
+
"PeerRefused",
|
|
55
|
+
"PrimaryFunction",
|
|
56
|
+
"QualifierCode",
|
|
57
|
+
"ReadProvider",
|
|
58
|
+
"Reassembler",
|
|
59
|
+
"Request",
|
|
60
|
+
"RequestError",
|
|
61
|
+
"SecondaryFunction",
|
|
62
|
+
"Session",
|
|
63
|
+
"TransportError",
|
|
64
|
+
"UnknownObject",
|
|
65
|
+
"build_response",
|
|
66
|
+
"control",
|
|
67
|
+
"crc",
|
|
68
|
+
"events",
|
|
69
|
+
"null_response",
|
|
70
|
+
"object_header",
|
|
71
|
+
"objects",
|
|
72
|
+
"parse_request",
|
|
73
|
+
"segment",
|
|
74
|
+
]
|
py1815/application.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""The DNP3 application layer: request headers in, response headers out.
|
|
2
|
+
|
|
3
|
+
This module reads the shape of a request -- the application control octet, the
|
|
4
|
+
function code, and the object headers that say what is being asked for -- and
|
|
5
|
+
writes the shape of a response. It knows nothing about what any group means, how
|
|
6
|
+
wide a point is, or where the values come from. Object semantics belong to the
|
|
7
|
+
module that owns the point map; this one owns the envelope.
|
|
8
|
+
|
|
9
|
+
**Where it stops is deliberate.** A request that carries object data is parsed as
|
|
10
|
+
far as its first object header. Walking past that means knowing how many octets
|
|
11
|
+
each group and variation occupies, which is the point map's knowledge, and a
|
|
12
|
+
parser that guessed would misread the second header of every write. Requests that
|
|
13
|
+
carry no object data -- a READ, an unsolicited enable, an assign-class -- are
|
|
14
|
+
parsed in full, and those are the whole of the read path.
|
|
15
|
+
|
|
16
|
+
Copyright 2026 DER Security Corp. Licensed under the Apache License, Version 2.0.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import struct
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from enum import IntEnum
|
|
25
|
+
|
|
26
|
+
#: Application control octet. Note the order against the transport function's
|
|
27
|
+
#: header, where FIN is 0x80 and FIR is 0x40: the two layers assign the same two
|
|
28
|
+
#: names to opposite bits, and reading one layer's constants into the other
|
|
29
|
+
#: produces a fragment that looks well formed and is inverted.
|
|
30
|
+
FIR_MASK = 0x80
|
|
31
|
+
FIN_MASK = 0x40
|
|
32
|
+
CON_MASK = 0x20
|
|
33
|
+
UNS_MASK = 0x10
|
|
34
|
+
SEQ_MASK = 0x0F
|
|
35
|
+
|
|
36
|
+
#: Application sequence numbers wrap at 16, unlike the transport function's 64.
|
|
37
|
+
SEQUENCE_MODULUS = SEQ_MASK + 1
|
|
38
|
+
|
|
39
|
+
#: Control octet and function code.
|
|
40
|
+
REQUEST_HEADER_SIZE = 2
|
|
41
|
+
#: Application control, function code and the two indication octets. What a
|
|
42
|
+
#: response costs before it carries anything.
|
|
43
|
+
RESPONSE_HEADER_SIZE = 4
|
|
44
|
+
|
|
45
|
+
#: Group 60 is the class-data group: one variation per class.
|
|
46
|
+
CLASS_GROUP = 60
|
|
47
|
+
CLASS_VARIATIONS = {1: 0, 2: 1, 3: 2, 4: 3}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FunctionCode(IntEnum):
|
|
51
|
+
"""Function codes, including the ones this outstation refuses.
|
|
52
|
+
|
|
53
|
+
The refused ones are named rather than left as integers because answering
|
|
54
|
+
"function not supported" is a different thing from failing to recognize the
|
|
55
|
+
request at all, and the log line should be able to say which.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
CONFIRM = 0x00
|
|
59
|
+
READ = 0x01
|
|
60
|
+
WRITE = 0x02
|
|
61
|
+
SELECT = 0x03
|
|
62
|
+
OPERATE = 0x04
|
|
63
|
+
DIRECT_OPERATE = 0x05
|
|
64
|
+
DIRECT_OPERATE_NR = 0x06
|
|
65
|
+
IMMED_FREEZE = 0x07
|
|
66
|
+
IMMED_FREEZE_NR = 0x08
|
|
67
|
+
FREEZE_CLEAR = 0x09
|
|
68
|
+
FREEZE_CLEAR_NR = 0x0A
|
|
69
|
+
FREEZE_AT_TIME = 0x0B
|
|
70
|
+
FREEZE_AT_TIME_NR = 0x0C
|
|
71
|
+
COLD_RESTART = 0x0D
|
|
72
|
+
WARM_RESTART = 0x0E
|
|
73
|
+
INITIALIZE_DATA = 0x0F
|
|
74
|
+
INITIALIZE_APPLICATION = 0x10
|
|
75
|
+
START_APPLICATION = 0x11
|
|
76
|
+
STOP_APPLICATION = 0x12
|
|
77
|
+
SAVE_CONFIGURATION = 0x13
|
|
78
|
+
ENABLE_UNSOLICITED = 0x14
|
|
79
|
+
DISABLE_UNSOLICITED = 0x15
|
|
80
|
+
ASSIGN_CLASS = 0x16
|
|
81
|
+
DELAY_MEASURE = 0x17
|
|
82
|
+
RECORD_CURRENT_TIME = 0x18
|
|
83
|
+
OPEN_FILE = 0x19
|
|
84
|
+
CLOSE_FILE = 0x1A
|
|
85
|
+
DELETE_FILE = 0x1B
|
|
86
|
+
GET_FILE_INFO = 0x1C
|
|
87
|
+
AUTHENTICATE_FILE = 0x1D
|
|
88
|
+
ABORT_FILE = 0x1E
|
|
89
|
+
AUTH_REQUEST = 0x20
|
|
90
|
+
AUTH_REQUEST_NO_ACK = 0x21
|
|
91
|
+
RESPONSE = 0x81
|
|
92
|
+
UNSOLICITED_RESPONSE = 0x82
|
|
93
|
+
AUTH_RESPONSE = 0x83
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#: Requests whose object headers are self-delimiting: the header says what to
|
|
97
|
+
#: read or which classes to act on, and no object data follows it. Every other
|
|
98
|
+
#: function carries objects whose widths this module does not know.
|
|
99
|
+
HEADER_ONLY_FUNCTIONS = frozenset(
|
|
100
|
+
{
|
|
101
|
+
FunctionCode.READ,
|
|
102
|
+
FunctionCode.ENABLE_UNSOLICITED,
|
|
103
|
+
FunctionCode.DISABLE_UNSOLICITED,
|
|
104
|
+
FunctionCode.ASSIGN_CLASS,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
#: Functions whose object headers this module parses. A recognized function
|
|
109
|
+
#: outside this set is returned with its payload untouched, exactly as an
|
|
110
|
+
#: unrecognized one is: a file-transfer or secure-authentication request carries
|
|
111
|
+
#: qualifiers this module does not accept, and raising on them would answer
|
|
112
|
+
#: "parameter error" to a request whose real answer is "function not supported".
|
|
113
|
+
#: Which of those a master hears is the difference between it retrying with a
|
|
114
|
+
#: different qualifier and it stopping.
|
|
115
|
+
_PARSED_FUNCTIONS = HEADER_ONLY_FUNCTIONS | {
|
|
116
|
+
FunctionCode.WRITE,
|
|
117
|
+
FunctionCode.SELECT,
|
|
118
|
+
FunctionCode.OPERATE,
|
|
119
|
+
FunctionCode.DIRECT_OPERATE,
|
|
120
|
+
FunctionCode.DIRECT_OPERATE_NR,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class QualifierCode(IntEnum):
|
|
125
|
+
"""Qualifiers this outstation accepts in a request.
|
|
126
|
+
|
|
127
|
+
The high nibble is the index or size prefix and the low nibble is the range
|
|
128
|
+
specifier, which is why these values are not contiguous.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
UINT8_START_STOP = 0x00
|
|
132
|
+
UINT16_START_STOP = 0x01
|
|
133
|
+
ALL_OBJECTS = 0x06
|
|
134
|
+
UINT8_COUNT = 0x07
|
|
135
|
+
UINT16_COUNT = 0x08
|
|
136
|
+
UINT8_COUNT_UINT8_INDEX = 0x17
|
|
137
|
+
UINT16_COUNT_UINT16_INDEX = 0x28
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
#: Index widths for the qualifiers that prefix each object with one.
|
|
141
|
+
_INDEX_WIDTHS = {
|
|
142
|
+
QualifierCode.UINT8_COUNT_UINT8_INDEX: 1,
|
|
143
|
+
QualifierCode.UINT16_COUNT_UINT16_INDEX: 2,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class IINBit(IntEnum):
|
|
148
|
+
"""Internal indication bits, by the octet each lives in."""
|
|
149
|
+
|
|
150
|
+
BROADCAST = 0x01
|
|
151
|
+
CLASS_1_EVENTS = 0x02
|
|
152
|
+
CLASS_2_EVENTS = 0x04
|
|
153
|
+
CLASS_3_EVENTS = 0x08
|
|
154
|
+
NEED_TIME = 0x10
|
|
155
|
+
LOCAL_CONTROL = 0x20
|
|
156
|
+
DEVICE_TROUBLE = 0x40
|
|
157
|
+
DEVICE_RESTART = 0x80
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class IIN2Bit(IntEnum):
|
|
161
|
+
"""The second internal indication octet: what went wrong with this request."""
|
|
162
|
+
|
|
163
|
+
FUNC_NOT_SUPPORTED = 0x01
|
|
164
|
+
OBJECT_UNKNOWN = 0x02
|
|
165
|
+
PARAM_ERROR = 0x04
|
|
166
|
+
EVENT_BUFFER_OVERFLOW = 0x08
|
|
167
|
+
ALREADY_EXECUTING = 0x10
|
|
168
|
+
CONFIG_CORRUPT = 0x20
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class IIN:
|
|
173
|
+
"""The two internal indication octets a response carries."""
|
|
174
|
+
|
|
175
|
+
first: int = 0
|
|
176
|
+
second: int = 0
|
|
177
|
+
|
|
178
|
+
def __or__(self, other: IIN) -> IIN:
|
|
179
|
+
return IIN(self.first | other.first, self.second | other.second)
|
|
180
|
+
|
|
181
|
+
def is_set(self, bit: IINBit | IIN2Bit) -> bool:
|
|
182
|
+
octet = self.first if isinstance(bit, IINBit) else self.second
|
|
183
|
+
return bool(octet & bit)
|
|
184
|
+
|
|
185
|
+
def to_bytes(self) -> bytes:
|
|
186
|
+
return bytes([self.first & 0xFF, self.second & 0xFF])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class AppControl:
|
|
191
|
+
"""The application control octet."""
|
|
192
|
+
|
|
193
|
+
fir: bool = True
|
|
194
|
+
fin: bool = True
|
|
195
|
+
con: bool = False
|
|
196
|
+
uns: bool = False
|
|
197
|
+
sequence: int = 0
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def from_byte(cls, octet: int) -> AppControl:
|
|
201
|
+
return cls(
|
|
202
|
+
fir=bool(octet & FIR_MASK),
|
|
203
|
+
fin=bool(octet & FIN_MASK),
|
|
204
|
+
con=bool(octet & CON_MASK),
|
|
205
|
+
uns=bool(octet & UNS_MASK),
|
|
206
|
+
sequence=octet & SEQ_MASK,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def to_byte(self) -> int:
|
|
210
|
+
octet = self.sequence % SEQUENCE_MODULUS
|
|
211
|
+
if self.fir:
|
|
212
|
+
octet |= FIR_MASK
|
|
213
|
+
if self.fin:
|
|
214
|
+
octet |= FIN_MASK
|
|
215
|
+
if self.con:
|
|
216
|
+
octet |= CON_MASK
|
|
217
|
+
if self.uns:
|
|
218
|
+
octet |= UNS_MASK
|
|
219
|
+
return octet
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class ObjectHeader:
|
|
224
|
+
"""One object header: what is being asked for, and over what range."""
|
|
225
|
+
|
|
226
|
+
group: int
|
|
227
|
+
variation: int
|
|
228
|
+
qualifier: QualifierCode
|
|
229
|
+
start: int | None = None
|
|
230
|
+
stop: int | None = None
|
|
231
|
+
count: int | None = None
|
|
232
|
+
#: The indices an index-prefixed qualifier names, when they are contiguous.
|
|
233
|
+
#: A request carrying object data interleaves them with the objects, so they
|
|
234
|
+
#: are read only where nothing follows them -- see :func:`parse_request`.
|
|
235
|
+
indices: tuple[int, ...] | None = None
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def is_class_request(self) -> bool:
|
|
239
|
+
return self.group == CLASS_GROUP and self.variation in CLASS_VARIATIONS
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def event_class(self) -> int | None:
|
|
243
|
+
"""The class this header names, or None if it does not name one."""
|
|
244
|
+
if not self.is_class_request:
|
|
245
|
+
return None
|
|
246
|
+
return CLASS_VARIATIONS[self.variation]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class RequestError(Exception):
|
|
250
|
+
"""A request that cannot be parsed, and the indication bit that says so.
|
|
251
|
+
|
|
252
|
+
Carrying the bit is what lets the session answer a malformed request
|
|
253
|
+
correctly without re-deriving why it was malformed. A qualifier this
|
|
254
|
+
outstation does not implement is a parameter error; a group it does not know
|
|
255
|
+
is an unknown object. Those are different answers, and a master acts on the
|
|
256
|
+
difference.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(self, reason: str, bit: IIN2Bit = IIN2Bit.PARAM_ERROR) -> None:
|
|
260
|
+
super().__init__(reason)
|
|
261
|
+
self.bit = bit
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True)
|
|
265
|
+
class Request:
|
|
266
|
+
"""One parsed request fragment."""
|
|
267
|
+
|
|
268
|
+
control: AppControl
|
|
269
|
+
function: int
|
|
270
|
+
headers: tuple[ObjectHeader, ...] = ()
|
|
271
|
+
#: Object data this module did not consume, for a function that carries some.
|
|
272
|
+
body: bytes = b""
|
|
273
|
+
known_function: FunctionCode | None = field(default=None, compare=False)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parse_header(
|
|
277
|
+
fragment: bytes, offset: int, *, indices_follow: bool
|
|
278
|
+
) -> tuple[ObjectHeader, int]:
|
|
279
|
+
if offset + 3 > len(fragment):
|
|
280
|
+
raise RequestError("object header is truncated")
|
|
281
|
+
group, variation, raw_qualifier = fragment[offset : offset + 3]
|
|
282
|
+
offset += 3
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
qualifier = QualifierCode(raw_qualifier)
|
|
286
|
+
except ValueError as exc:
|
|
287
|
+
raise RequestError(
|
|
288
|
+
f"qualifier 0x{raw_qualifier:02X} is not one this outstation accepts"
|
|
289
|
+
) from exc
|
|
290
|
+
|
|
291
|
+
start = stop = count = None
|
|
292
|
+
if qualifier is QualifierCode.UINT8_START_STOP:
|
|
293
|
+
if offset + 2 > len(fragment):
|
|
294
|
+
raise RequestError("range field is truncated")
|
|
295
|
+
start, stop = fragment[offset], fragment[offset + 1]
|
|
296
|
+
offset += 2
|
|
297
|
+
elif qualifier is QualifierCode.UINT16_START_STOP:
|
|
298
|
+
if offset + 4 > len(fragment):
|
|
299
|
+
raise RequestError("range field is truncated")
|
|
300
|
+
start, stop = struct.unpack("<HH", fragment[offset : offset + 4])
|
|
301
|
+
offset += 4
|
|
302
|
+
elif qualifier in (QualifierCode.UINT8_COUNT, QualifierCode.UINT8_COUNT_UINT8_INDEX):
|
|
303
|
+
if offset + 1 > len(fragment):
|
|
304
|
+
raise RequestError("count field is truncated")
|
|
305
|
+
count = fragment[offset]
|
|
306
|
+
offset += 1
|
|
307
|
+
elif qualifier in (QualifierCode.UINT16_COUNT, QualifierCode.UINT16_COUNT_UINT16_INDEX):
|
|
308
|
+
if offset + 2 > len(fragment):
|
|
309
|
+
raise RequestError("count field is truncated")
|
|
310
|
+
count = struct.unpack("<H", fragment[offset : offset + 2])[0]
|
|
311
|
+
offset += 2
|
|
312
|
+
|
|
313
|
+
if start is not None and stop is not None and stop < start:
|
|
314
|
+
raise RequestError(f"range {start}..{stop} ends before it begins")
|
|
315
|
+
|
|
316
|
+
indices: tuple[int, ...] | None = None
|
|
317
|
+
if indices_follow and qualifier in _INDEX_WIDTHS and count is not None:
|
|
318
|
+
width = _INDEX_WIDTHS[qualifier]
|
|
319
|
+
span = count * width
|
|
320
|
+
if offset + span > len(fragment):
|
|
321
|
+
raise RequestError("index list is truncated")
|
|
322
|
+
raw = fragment[offset : offset + span]
|
|
323
|
+
indices = tuple(raw) if width == 1 else struct.unpack(f"<{count}H", raw)
|
|
324
|
+
offset += span
|
|
325
|
+
|
|
326
|
+
return ObjectHeader(group, variation, qualifier, start, stop, count, indices), offset
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parse_request(fragment: bytes) -> Request:
|
|
330
|
+
"""Parse a request fragment, or say why it is not one."""
|
|
331
|
+
if len(fragment) < REQUEST_HEADER_SIZE:
|
|
332
|
+
raise RequestError("fragment is shorter than an application header")
|
|
333
|
+
|
|
334
|
+
control = AppControl.from_byte(fragment[0])
|
|
335
|
+
function = fragment[1]
|
|
336
|
+
try:
|
|
337
|
+
known: FunctionCode | None = FunctionCode(function)
|
|
338
|
+
except ValueError:
|
|
339
|
+
known = None
|
|
340
|
+
|
|
341
|
+
if known is FunctionCode.CONFIRM:
|
|
342
|
+
if len(fragment) > REQUEST_HEADER_SIZE:
|
|
343
|
+
raise RequestError("a confirmation carries no objects")
|
|
344
|
+
return Request(control, function, known_function=known)
|
|
345
|
+
|
|
346
|
+
if known is None or known not in _PARSED_FUNCTIONS:
|
|
347
|
+
# Two cases, one answer. Nothing here knows an unrecognized function's
|
|
348
|
+
# body layout, and a recognized function this module does not parse is
|
|
349
|
+
# in the same position. Reading either's first octets as an object
|
|
350
|
+
# header invents a structure and then refuses the request for failing to
|
|
351
|
+
# have it. The answer a master is owed is "function not supported", and
|
|
352
|
+
# producing that needs the request parsed rather than rejected. The
|
|
353
|
+
# payload travels untouched in ``body``, and ``known_function`` travels
|
|
354
|
+
# with it so a refusal can name what it refused.
|
|
355
|
+
return Request(control, function, body=fragment[REQUEST_HEADER_SIZE:], known_function=known)
|
|
356
|
+
|
|
357
|
+
headers: list[ObjectHeader] = []
|
|
358
|
+
offset = REQUEST_HEADER_SIZE
|
|
359
|
+
header_only = known in HEADER_ONLY_FUNCTIONS
|
|
360
|
+
while offset < len(fragment):
|
|
361
|
+
# In a request carrying no object data, an index-prefixed qualifier is
|
|
362
|
+
# followed by its index list and nothing else, so the list is read here.
|
|
363
|
+
# In one that carries objects, each index prefixes its own object and
|
|
364
|
+
# the two interleave -- which is why parsing stops at the first header
|
|
365
|
+
# there rather than walking a list that is not contiguous.
|
|
366
|
+
header, offset = _parse_header(fragment, offset, indices_follow=header_only)
|
|
367
|
+
headers.append(header)
|
|
368
|
+
if not header_only:
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
return Request(
|
|
372
|
+
control=control,
|
|
373
|
+
function=function,
|
|
374
|
+
headers=tuple(headers),
|
|
375
|
+
body=fragment[offset:],
|
|
376
|
+
known_function=known,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass(frozen=True)
|
|
381
|
+
class ObjectBlock:
|
|
382
|
+
"""One object header and the indexed objects that followed it."""
|
|
383
|
+
|
|
384
|
+
header: ObjectHeader
|
|
385
|
+
#: ``(index, octets)`` in the order they arrived. An index may repeat: a
|
|
386
|
+
#: request naming the same point twice is a request naming it twice, and
|
|
387
|
+
#: collapsing that here would answer fewer objects than were asked about.
|
|
388
|
+
items: tuple[tuple[int, bytes], ...]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def parse_object_blocks(
|
|
392
|
+
request: Request, size_of: Callable[[int, int], int | None]
|
|
393
|
+
) -> tuple[ObjectBlock, ...]:
|
|
394
|
+
"""Walk a request whose objects interleave with their indices.
|
|
395
|
+
|
|
396
|
+
``parse_request`` stops at the first header for such a request and leaves
|
|
397
|
+
the remainder in ``body``, because the indices do not form a list that can
|
|
398
|
+
be read ahead of the objects. This walks that remainder: for each header,
|
|
399
|
+
``count`` objects each prefixed by its own index, then the next header.
|
|
400
|
+
|
|
401
|
+
``size_of`` answers how wide one object of a group and variation is, and
|
|
402
|
+
``None`` where it knows of none. The layer is kept ignorant of object
|
|
403
|
+
formats deliberately -- it parses headers and qualifiers and nothing below
|
|
404
|
+
them -- so the caller supplies the only thing it cannot know.
|
|
405
|
+
|
|
406
|
+
Every failure here is a failure of the fragment rather than of one object,
|
|
407
|
+
and that is not a stylistic choice. A truncated body, a count that disagrees
|
|
408
|
+
with the octets present, or a width nothing recognizes may leave no complete
|
|
409
|
+
object at all, and a status has to be attached to something. Raising
|
|
410
|
+
:class:`RequestError` is what tells the caller to refuse the fragment rather
|
|
411
|
+
than to answer per index.
|
|
412
|
+
|
|
413
|
+
No object count is imposed. A fragment is already bounded, and eleven octets
|
|
414
|
+
plus an index is the smallest control a request can carry, so the ceiling is
|
|
415
|
+
the one the transport already sets.
|
|
416
|
+
"""
|
|
417
|
+
if not request.headers:
|
|
418
|
+
raise RequestError("a request carrying objects has no object header")
|
|
419
|
+
|
|
420
|
+
blocks: list[ObjectBlock] = []
|
|
421
|
+
header = request.headers[0]
|
|
422
|
+
body = request.body
|
|
423
|
+
offset = 0
|
|
424
|
+
|
|
425
|
+
while True:
|
|
426
|
+
width = _INDEX_WIDTHS.get(header.qualifier)
|
|
427
|
+
if width is None:
|
|
428
|
+
raise RequestError(
|
|
429
|
+
f"qualifier 0x{int(header.qualifier):02X} prefixes no index, so the "
|
|
430
|
+
"objects after it cannot be told apart"
|
|
431
|
+
)
|
|
432
|
+
if header.count is None:
|
|
433
|
+
raise RequestError("an index-prefixed qualifier carries no count")
|
|
434
|
+
|
|
435
|
+
size = size_of(header.group, header.variation)
|
|
436
|
+
if size is None:
|
|
437
|
+
raise RequestError(
|
|
438
|
+
f"no object of group {header.group} variation {header.variation} is known, "
|
|
439
|
+
"so its width cannot be read"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
items: list[tuple[int, bytes]] = []
|
|
443
|
+
for _ in range(header.count):
|
|
444
|
+
end = offset + width + size
|
|
445
|
+
if end > len(body):
|
|
446
|
+
raise RequestError(
|
|
447
|
+
f"group {header.group} variation {header.variation} declares "
|
|
448
|
+
f"{header.count} objects and the body holds fewer"
|
|
449
|
+
)
|
|
450
|
+
items.append(
|
|
451
|
+
(
|
|
452
|
+
int.from_bytes(body[offset : offset + width], "little"),
|
|
453
|
+
bytes(body[offset + width : end]),
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
offset = end
|
|
457
|
+
|
|
458
|
+
blocks.append(ObjectBlock(header=header, items=tuple(items)))
|
|
459
|
+
|
|
460
|
+
if offset == len(body):
|
|
461
|
+
return tuple(blocks)
|
|
462
|
+
header, offset = _parse_header(body, offset, indices_follow=False)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def object_header(group: int, variation: int, *, start: int, stop: int) -> bytes:
|
|
466
|
+
"""An object header covering a contiguous range, in the narrowest qualifier.
|
|
467
|
+
|
|
468
|
+
Narrowest rather than always 16-bit because a master reads the qualifier to
|
|
469
|
+
size the range field, and a one-octet range is two octets cheaper in every
|
|
470
|
+
response that fits it -- which, for a fleet served in blocks, is most of them.
|
|
471
|
+
"""
|
|
472
|
+
if stop < start:
|
|
473
|
+
raise ValueError(f"range {start}..{stop} ends before it begins")
|
|
474
|
+
if start < 0 or stop > 0xFFFF:
|
|
475
|
+
raise ValueError(f"range {start}..{stop} does not fit a 16-bit index")
|
|
476
|
+
if stop <= 0xFF:
|
|
477
|
+
return bytes([group, variation, QualifierCode.UINT8_START_STOP, start, stop])
|
|
478
|
+
return struct.pack("<BBBHH", group, variation, QualifierCode.UINT16_START_STOP, start, stop)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def build_response(
|
|
482
|
+
*,
|
|
483
|
+
control: AppControl,
|
|
484
|
+
iin: IIN,
|
|
485
|
+
body: bytes = b"",
|
|
486
|
+
function: FunctionCode = FunctionCode.RESPONSE,
|
|
487
|
+
) -> bytes:
|
|
488
|
+
"""One response fragment, ready for the transport function."""
|
|
489
|
+
return bytes([control.to_byte(), function, *iin.to_bytes()]) + body
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def null_response(*, sequence: int, iin: IIN) -> bytes:
|
|
493
|
+
"""A response carrying indications and no objects.
|
|
494
|
+
|
|
495
|
+
The answer to a request that was understood and produced nothing to say --
|
|
496
|
+
a refusal, an acknowledged write, an empty class poll.
|
|
497
|
+
"""
|
|
498
|
+
return build_response(
|
|
499
|
+
control=AppControl(fir=True, fin=True, sequence=sequence),
|
|
500
|
+
iin=iin,
|
|
501
|
+
)
|