Protocol layers¶
The layers below the point map. Each is testable without the ones above it.
Checksum¶
py1815.crc ¶
CRC-16/DNP, the checksum every DNP3 frame carries several of.
One function over bytes, with no notion of frames or blocks. The link layer decides what gets covered; this decides what the answer is.
The parameters are CRC-16/DNP: polynomial 0x3D65, initial value zero,
reflected input and output, final XOR 0xFFFF. Reflection is why the table is
built from 0xA6BC -- 0x3D65 with its bits reversed -- rather than from the
polynomial as written. The catalogue check value for this parameter set is
0xEA82 over the ASCII digits 123456789, and the test suite pins it: a CRC
that is wrong in a way the implementation agrees with is invisible until a real
master rejects every frame.
Copyright 2026 DER Security Corp. Licensed under the Apache License, Version 2.0.
compute ¶
encode ¶
data followed by its CRC, in the order it goes on the wire.
Little-endian, which is the one thing about this that cannot be derived from the polynomial: DNP3 transmits the low octet first.
Source code in src/py1815/crc.py
verify ¶
Whether block ends in a CRC that matches the rest of it.
Source code in src/py1815/crc.py
Data link¶
py1815.link ¶
The DNP3 data link layer: frames in, frames out, and nothing above them.
This module knows about start octets, the control byte, link addresses and where the CRCs go. It does not know what a transport segment is, what an outstation does, or where the values come from. Kept separate because these rules are testable without a socket, and they are stable in a way the layers above them are not.
The frame is IEC 60870-5 FT3 as DNP3 profiles it: a ten-octet header whose last two octets are a CRC over the other eight, followed by user data in blocks of sixteen octets, each block followed by its own CRC. That shape is why a frame carrying 250 octets of user data occupies 292 on the wire.
Nothing here trusts a length field before its CRC has been checked. A corrupt or hostile header would otherwise size a read, and the reader would wait for octets that are never coming while holding what it already has.
Copyright 2026 DER Security Corp. Licensed under the Apache License, Version 2.0.
Broadcast ¶
PrimaryFunction ¶
Bases: IntEnum
Function codes a primary station sends.
Source code in src/py1815/link.py
SecondaryFunction ¶
LinkFrameError ¶
Bases: Exception
A frame that cannot be parsed, with the reason it could not be.
The reason is carried rather than logged here: this module decides what is malformed, and the session decides how a malformed frame is reported.
LinkFrame
dataclass
¶
One parsed frame.
Source code in src/py1815/link.py
FrameReader ¶
Turns a byte stream into frames, and resynchronizes when it has to.
TCP delivers a stream, not frames: one read can carry half a frame, three frames, or a frame followed by noise. This holds what it has and yields what is complete.
Resynchronization discards two octets, not the frame the header claims. A header whose CRC fails may not be a header at all -- the start octets can occur inside user data of a frame whose beginning was lost -- so its length field is not evidence of anything. Skipping past the start octets and searching again recovers at the next real frame; trusting the length would skip into the middle of one and stay out of step.
Source code in src/py1815/link.py
feed ¶
Add received octets and return every frame they completed.
Source code in src/py1815/link.py
control_byte ¶
control_byte(
*,
from_master: bool,
primary: bool,
function: int,
fcb: bool = False,
fcv: bool = False,
) -> int
Assemble a control octet from what each bit means.
Source code in src/py1815/link.py
block_count ¶
frame_size ¶
The octets a frame carrying this much user data occupies on the wire.
build ¶
One frame, ready to send.
Source code in src/py1815/link.py
parse ¶
Parse one complete frame, or say why it is not one.
Source code in src/py1815/link.py
is_valid_address ¶
Transport¶
py1815.transport ¶
The DNP3 transport function: one application fragment across several frames.
The smallest layer in the protocol -- a single octet of header carrying FIR, FIN and a six-bit sequence number -- and the one with the most ways to be subtly wrong, because every rule it enforces is about what to do with a series that did not arrive as promised.
Segmentation is deterministic and reassembly is suspicious. A receiver that accepted segments in any order, or kept accumulating them, would hand the application layer fragments assembled out of two different requests, or hold memory for a series whose sender has gone away.
Copyright 2026 DER Security Corp. Licensed under the Apache License, Version 2.0.
TransportError ¶
Bases: Exception
A segment that breaks a rule of the transport function.
Raised rather than swallowed so the session can report it. Every case leaves the reassembler empty: a series that broke its own sequence cannot be continued, and continuing it is how two requests become one fragment.
Source code in src/py1815/transport.py
Reassembler ¶
Collects segments into application fragments.
Source code in src/py1815/transport.py
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | |
reset ¶
Abandon any partial fragment.
The session calls this when the association restarts: segments from before a link reset must not be completed by segments from after one.
Source code in src/py1815/transport.py
add ¶
Accept one segment, returning the fragment it completed, if any.
A fragment with nothing in it is not returned: a series that carried no application data has nothing to hand to the application layer.
Source code in src/py1815/transport.py
header_byte ¶
Assemble a transport header octet.
segment ¶
Split an application fragment into segments, in the order they are sent.
An empty fragment still produces one segment. It is a legal thing to send -- a response can carry a header and no objects -- and returning nothing would silently drop it.
Source code in src/py1815/transport.py
Application¶
py1815.application ¶
The DNP3 application layer: request headers in, response headers out.
This module reads the shape of a request -- the application control octet, the function code, and the object headers that say what is being asked for -- and writes the shape of a response. It knows nothing about what any group means, how wide a point is, or where the values come from. Object semantics belong to the module that owns the point map; this one owns the envelope.
Where it stops is deliberate. A request that carries object data is parsed as far as its first object header. Walking past that means knowing how many octets each group and variation occupies, which is the point map's knowledge, and a parser that guessed would misread the second header of every write. Requests that carry no object data -- a READ, an unsolicited enable, an assign-class -- are parsed in full, and those are the whole of the read path.
Copyright 2026 DER Security Corp. Licensed under the Apache License, Version 2.0.
FunctionCode ¶
Bases: IntEnum
Function codes, including the ones this outstation refuses.
The refused ones are named rather than left as integers because answering "function not supported" is a different thing from failing to recognize the request at all, and the log line should be able to say which.
Source code in src/py1815/application.py
QualifierCode ¶
Bases: IntEnum
Qualifiers this outstation accepts in a request.
The high nibble is the index or size prefix and the low nibble is the range specifier, which is why these values are not contiguous.
Source code in src/py1815/application.py
IINBit ¶
Bases: IntEnum
Internal indication bits, by the octet each lives in.
Source code in src/py1815/application.py
IIN2Bit ¶
Bases: IntEnum
The second internal indication octet: what went wrong with this request.
Source code in src/py1815/application.py
IIN
dataclass
¶
The two internal indication octets a response carries.
Source code in src/py1815/application.py
AppControl
dataclass
¶
The application control octet.
Source code in src/py1815/application.py
ObjectHeader
dataclass
¶
One object header: what is being asked for, and over what range.
Source code in src/py1815/application.py
event_class
property
¶
The class this header names, or None if it does not name one.
RequestError ¶
Bases: Exception
A request that cannot be parsed, and the indication bit that says so.
Carrying the bit is what lets the session answer a malformed request correctly without re-deriving why it was malformed. A qualifier this outstation does not implement is a parameter error; a group it does not know is an unknown object. Those are different answers, and a master acts on the difference.
Source code in src/py1815/application.py
Request
dataclass
¶
One parsed request fragment.
Source code in src/py1815/application.py
ObjectBlock
dataclass
¶
One object header and the indexed objects that followed it.
Source code in src/py1815/application.py
ResponseError ¶
Response
dataclass
¶
One parsed response fragment: the envelope, and the objects unread.
Source code in src/py1815/application.py
parse_request ¶
Parse a request fragment, or say why it is not one.
Source code in src/py1815/application.py
parse_header_list ¶
Object headers that follow one another with no object data between them.
For a function parse_request hands back unparsed, once the caller has
decided it will answer it. A freeze names the counters it applies to the
way a read names what it wants, so the same walk reads both; the session
asks for it only when something is there to freeze, which keeps a refused
freeze answered as an unsupported function rather than as a parse error.
Source code in src/py1815/application.py
parse_object_blocks ¶
parse_object_blocks(
request: Request,
size_of: Callable[[int, int], int | None],
) -> tuple[ObjectBlock, ...]
Walk a request whose objects interleave with their indices.
parse_request stops at the first header for such a request and leaves
the remainder in body, because the indices do not form a list that can
be read ahead of the objects. This walks that remainder: for each header,
count objects each prefixed by its own index, then the next header.
size_of answers how wide one object of a group and variation is, and
None where it knows of none. The layer is kept ignorant of object
formats deliberately -- it parses headers and qualifiers and nothing below
them -- so the caller supplies the only thing it cannot know.
Every failure here is a failure of the fragment rather than of one object,
and that is not a stylistic choice. A truncated body, a count that disagrees
with the octets present, or a width nothing recognizes may leave no complete
object at all, and a status has to be attached to something. Raising
:class:RequestError is what tells the caller to refuse the fragment rather
than to answer per index.
No object count is imposed. A fragment is already bounded, and eleven octets plus an index is the smallest control a request can carry, so the ceiling is the one the transport already sets.
Source code in src/py1815/application.py
object_header ¶
An object header covering a contiguous range, in the narrowest qualifier.
Narrowest rather than always 16-bit because a master reads the qualifier to size the range field, and a one-octet range is two octets cheaper in every response that fits it -- which, for a fleet served in blocks, is most of them.
Source code in src/py1815/application.py
build_response ¶
build_response(
*,
control: AppControl,
iin: IIN,
body: bytes = b"",
function: FunctionCode = FunctionCode.RESPONSE,
) -> bytes
One response fragment, ready for the transport function.
Source code in src/py1815/application.py
null_response ¶
A response carrying indications and no objects.
The answer to a request that was understood and produced nothing to say -- a refusal, an acknowledged write, an empty class poll.
Source code in src/py1815/application.py
parse_response ¶
Parse a response fragment, or say why it is not one.
A fragment whose function code and unsolicited bit disagree is refused: a master confirms the two kinds in different sequence series, and one that could be read either way would be confirmed in the wrong one.
Source code in src/py1815/application.py
build_request ¶
One request fragment, ready for the transport function.
A request is always a single fragment, first and final, and never asks to be confirmed: the response is the acknowledgment.
Source code in src/py1815/application.py
build_confirm ¶
The confirmation of a response fragment that asked for one.
It carries the sequence number of the fragment it confirms and that fragment's unsolicited bit, which is what tells an outstation which of its two exchanges is being confirmed.
Source code in src/py1815/application.py
all_objects_header ¶
An object header asking for every object of a group and variation.
class_header ¶
The header that asks for a class: 0 for static data, 1 to 3 for events.
Source code in src/py1815/application.py
index_list_header ¶
An object header naming points one at a time, in the narrowest qualifier.
For a request that carries no object data, where the indices follow the count as a list. The order given is the order asked, and an index may repeat.