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 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
+ )