iec60870-parser 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Baller300000
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: iec60870-parser
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency IEC 60870-5-101/103/104 frame parser
5
+ Author: IEC 60870 Parser Contributors
6
+ License: MIT
7
+ Keywords: iec60870,scada,telemetry,industrial,protocol
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: System :: Networking
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # iec60870-parser
19
+
20
+ A pure-Python, zero-runtime-dependency parser and validator for IEC 60870-5-101, -103, and -104 telemetry frames. It consumes one raw byte buffer at a time and returns a structured `ParseResult` with `VALID`, `INCOMPLETE`, or `CORRUPTED` status.
21
+
22
+ ## Install
23
+
24
+ ```text
25
+ python -m pip install iec60870-parser
26
+ ```
27
+
28
+ For a source checkout:
29
+
30
+ ```text
31
+ python -m pip install -e .
32
+ ```
33
+
34
+ ## API
35
+
36
+ ```python
37
+ from iec60870 import FrameStatus, T104Session, parse_frame
38
+
39
+ result = parse_frame(raw_bytes, protocol="t104")
40
+ if result.status is FrameStatus.VALID:
41
+ print(result.frame_type, result.fields)
42
+ elif result.status is FrameStatus.INCOMPLETE:
43
+ # Retain the buffer and read more bytes.
44
+ pass
45
+ else:
46
+ print(result.error)
47
+ ```
48
+
49
+ `protocol` accepts `t101`, `t103`, `t104`, or `auto`. Explicit protocol selection is preferred for short/incomplete buffers. T101 uses a one-byte link address by default; T103 uses two bytes. Override either with `link_address_size=1` or `2`.
50
+
51
+ For a stream connection, `T104Session` checks the receive sequence number of I-format APDUs and advances it after each valid telemetry frame:
52
+
53
+ ```python
54
+ session = T104Session()
55
+ result = session.parse(apdu_bytes)
56
+ ```
57
+
58
+ ## Wire formats
59
+
60
+ ### T101 and T103 FT1.2
61
+
62
+ Variable frames are `68 L L 68 [control] [link address] [ASDU] [CS] 16`. `L` is the number of bytes from control through ASDU, and `CS` is the sum of those bytes modulo 256. Fixed frames are `10 [control] [link address] [CS] 16`; the fixed-frame helper uses the one-byte control/address layout defined by this package API.
63
+
64
+ T103 uses the same FT1.2 framing and checksum but defaults to a two-byte link address. Companion-standard ASDU variations that are not Type 1 or Type 30 remain available through raw frame fields and can be decoded with `parse_asdu` using the relevant address widths.
65
+
66
+ ### T104 APCI
67
+
68
+ An APDU is `68 L [four APCI control bytes] [ASDU]`. I-format control fields expose `send_sequence` and `receive_sequence`; S-format exposes the receive sequence; U-format exposes `STARTDT`, `STOPDT`, and `TESTFR` activation/confirmation names. APCI integers are decoded in little-endian wire order and sequence values are 15-bit values.
69
+
70
+ ## ASDU schema
71
+
72
+ `parse_asdu` returns a dictionary containing `type_id`, `vsq`, `count`, `sequence`, `cot`, `common_address`, and `information_objects`. Type 1 objects decode a one-byte single-point value. Type 30 objects decode the one-byte value plus the seven raw CP56Time2a bytes. Unknown type identifiers are validated at the header/address level and leave their remaining bytes in `unparsed`.
73
+
74
+ The parser validates bounds before every read, rejects trailing bytes when parsing a single frame, and does not perform socket or stream I/O.
75
+
76
+ ## Development
77
+
78
+ ```text
79
+ python -m pip install pytest build
80
+ python -m pytest
81
+ python -m build
82
+ ```
@@ -0,0 +1,65 @@
1
+ # iec60870-parser
2
+
3
+ A pure-Python, zero-runtime-dependency parser and validator for IEC 60870-5-101, -103, and -104 telemetry frames. It consumes one raw byte buffer at a time and returns a structured `ParseResult` with `VALID`, `INCOMPLETE`, or `CORRUPTED` status.
4
+
5
+ ## Install
6
+
7
+ ```text
8
+ python -m pip install iec60870-parser
9
+ ```
10
+
11
+ For a source checkout:
12
+
13
+ ```text
14
+ python -m pip install -e .
15
+ ```
16
+
17
+ ## API
18
+
19
+ ```python
20
+ from iec60870 import FrameStatus, T104Session, parse_frame
21
+
22
+ result = parse_frame(raw_bytes, protocol="t104")
23
+ if result.status is FrameStatus.VALID:
24
+ print(result.frame_type, result.fields)
25
+ elif result.status is FrameStatus.INCOMPLETE:
26
+ # Retain the buffer and read more bytes.
27
+ pass
28
+ else:
29
+ print(result.error)
30
+ ```
31
+
32
+ `protocol` accepts `t101`, `t103`, `t104`, or `auto`. Explicit protocol selection is preferred for short/incomplete buffers. T101 uses a one-byte link address by default; T103 uses two bytes. Override either with `link_address_size=1` or `2`.
33
+
34
+ For a stream connection, `T104Session` checks the receive sequence number of I-format APDUs and advances it after each valid telemetry frame:
35
+
36
+ ```python
37
+ session = T104Session()
38
+ result = session.parse(apdu_bytes)
39
+ ```
40
+
41
+ ## Wire formats
42
+
43
+ ### T101 and T103 FT1.2
44
+
45
+ Variable frames are `68 L L 68 [control] [link address] [ASDU] [CS] 16`. `L` is the number of bytes from control through ASDU, and `CS` is the sum of those bytes modulo 256. Fixed frames are `10 [control] [link address] [CS] 16`; the fixed-frame helper uses the one-byte control/address layout defined by this package API.
46
+
47
+ T103 uses the same FT1.2 framing and checksum but defaults to a two-byte link address. Companion-standard ASDU variations that are not Type 1 or Type 30 remain available through raw frame fields and can be decoded with `parse_asdu` using the relevant address widths.
48
+
49
+ ### T104 APCI
50
+
51
+ An APDU is `68 L [four APCI control bytes] [ASDU]`. I-format control fields expose `send_sequence` and `receive_sequence`; S-format exposes the receive sequence; U-format exposes `STARTDT`, `STOPDT`, and `TESTFR` activation/confirmation names. APCI integers are decoded in little-endian wire order and sequence values are 15-bit values.
52
+
53
+ ## ASDU schema
54
+
55
+ `parse_asdu` returns a dictionary containing `type_id`, `vsq`, `count`, `sequence`, `cot`, `common_address`, and `information_objects`. Type 1 objects decode a one-byte single-point value. Type 30 objects decode the one-byte value plus the seven raw CP56Time2a bytes. Unknown type identifiers are validated at the header/address level and leave their remaining bytes in `unparsed`.
56
+
57
+ The parser validates bounds before every read, rejects trailing bytes when parsing a single frame, and does not perform socket or stream I/O.
58
+
59
+ ## Development
60
+
61
+ ```text
62
+ python -m pip install pytest build
63
+ python -m pytest
64
+ python -m build
65
+ ```
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "iec60870-parser"
7
+ version = "0.1.0"
8
+ description = "Zero-dependency IEC 60870-5-101/103/104 frame parser"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "IEC 60870 Parser Contributors" }]
13
+ keywords = ["iec60870", "scada", "telemetry", "industrial", "protocol"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: System :: Networking",
20
+ ]
21
+
22
+ [tool.setuptools]
23
+ package-dir = { "" = "src" }
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ addopts = "-ra"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ """IEC 60870-5 frame parsing primitives."""
2
+
3
+ from .parser import (
4
+ FrameStatus,
5
+ ParseResult,
6
+ T104Session,
7
+ parse_asdu,
8
+ parse_frame,
9
+ )
10
+
11
+ __all__ = ["FrameStatus", "ParseResult", "T104Session", "parse_asdu", "parse_frame"]
@@ -0,0 +1,246 @@
1
+ """Bounded, allocation-conscious parsers for IEC 60870-5 FT1.2 and APCI.
2
+
3
+ The parser never raises for malformed wire data. It returns INCOMPLETE when more
4
+ bytes may make a frame valid and CORRUPTED when the bytes contradict the format.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import Any, Dict, Optional, Sequence, Tuple
10
+
11
+
12
+ class FrameStatus(str, Enum):
13
+ VALID = "VALID"
14
+ INCOMPLETE = "INCOMPLETE"
15
+ CORRUPTED = "CORRUPTED"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ParseResult:
20
+ status: FrameStatus
21
+ protocol: str
22
+ frame_length: Optional[int] = None
23
+ frame_type: Optional[str] = None
24
+ fields: Dict[str, Any] = field(default_factory=dict)
25
+ error: Optional[str] = None
26
+
27
+
28
+ @dataclass
29
+ class T104Session:
30
+ """Optional APCI sequence state for a TCP connection."""
31
+
32
+ next_receive_sequence: int = 0
33
+ next_send_sequence: int = 0
34
+
35
+ def parse(self, data: bytes, *, parse_asdu_data: bool = True) -> ParseResult:
36
+ result = parse_frame(
37
+ data,
38
+ protocol="t104",
39
+ expected_receive_sequence=self.next_receive_sequence,
40
+ parse_asdu_data=parse_asdu_data,
41
+ )
42
+ if result.status is FrameStatus.VALID and result.frame_type == "I":
43
+ self.next_receive_sequence = (result.fields["receive_sequence"] + 1) & 0x7FFF
44
+ return result
45
+
46
+
47
+ def _u16le(data: Sequence[int], offset: int) -> int:
48
+ return data[offset] | (data[offset + 1] << 8)
49
+
50
+
51
+ def _u24le(data: Sequence[int], offset: int) -> int:
52
+ return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16)
53
+
54
+
55
+ def _result(status: FrameStatus, protocol: str, **kwargs: Any) -> ParseResult:
56
+ return ParseResult(status=status, protocol=protocol, **kwargs)
57
+
58
+
59
+ def _parse_ft12(
60
+ data: bytes,
61
+ protocol: str,
62
+ *,
63
+ link_address_size: int,
64
+ parse_asdu_data: bool,
65
+ ) -> ParseResult:
66
+ if not data:
67
+ return _result(FrameStatus.INCOMPLETE, protocol, error="empty buffer")
68
+ if data[0] == 0x10:
69
+ if len(data) < 5:
70
+ return _result(FrameStatus.INCOMPLETE, protocol, frame_type="fixed")
71
+ if data[4] != 0x16:
72
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="fixed", error="bad fixed-frame stop")
73
+ checksum = sum(data[1:3]) & 0xFF
74
+ if checksum != data[3]:
75
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="fixed", error="checksum mismatch")
76
+ if len(data) > 5:
77
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="fixed", error="trailing bytes")
78
+ return _result(
79
+ FrameStatus.VALID,
80
+ protocol,
81
+ frame_length=5,
82
+ frame_type="fixed",
83
+ fields={"control": data[1], "link_address": data[2], "checksum": data[3]},
84
+ )
85
+ if data[0] != 0x68:
86
+ return _result(FrameStatus.CORRUPTED, protocol, error="unknown FT1.2 start")
87
+ if len(data) < 4:
88
+ return _result(FrameStatus.INCOMPLETE, protocol, frame_type="variable")
89
+ length = data[1]
90
+ if data[2] != length or data[3] != 0x68:
91
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="variable", error="length/start repetition mismatch")
92
+ total = length + 6
93
+ if length < 1 + link_address_size:
94
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="variable", error="length excludes control/address")
95
+ if len(data) < total:
96
+ return _result(FrameStatus.INCOMPLETE, protocol, frame_type="variable", frame_length=total)
97
+ if len(data) > total:
98
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="variable", error="trailing bytes")
99
+ if data[total - 1] != 0x16:
100
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="variable", error="bad variable-frame stop")
101
+ body_start = 4
102
+ checksum_offset = 4 + length
103
+ if (sum(data[body_start:checksum_offset]) & 0xFF) != data[checksum_offset]:
104
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="variable", error="checksum mismatch")
105
+ control = data[body_start]
106
+ address = int.from_bytes(data[body_start + 1:body_start + 1 + link_address_size], "little")
107
+ asdu_start = body_start + 1 + link_address_size
108
+ fields: Dict[str, Any] = {
109
+ "control": control,
110
+ "link_address": address,
111
+ "checksum": data[checksum_offset],
112
+ }
113
+ if parse_asdu_data and asdu_start < checksum_offset:
114
+ asdu = parse_asdu(data[asdu_start:checksum_offset])
115
+ if asdu["status"] != FrameStatus.VALID.value:
116
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type="variable", error=asdu["error"])
117
+ fields["asdu"] = asdu
118
+ return _result(FrameStatus.VALID, protocol, frame_length=total, frame_type="variable", fields=fields)
119
+
120
+
121
+ def _parse_t104(
122
+ data: bytes,
123
+ *,
124
+ expected_receive_sequence: Optional[int],
125
+ parse_asdu_data: bool,
126
+ ) -> ParseResult:
127
+ protocol = "t104"
128
+ if not data:
129
+ return _result(FrameStatus.INCOMPLETE, protocol, error="empty buffer")
130
+ if data[0] != 0x68:
131
+ return _result(FrameStatus.CORRUPTED, protocol, error="missing APCI start")
132
+ if len(data) < 2:
133
+ return _result(FrameStatus.INCOMPLETE, protocol)
134
+ apdu_length = data[1]
135
+ if apdu_length < 4:
136
+ return _result(FrameStatus.CORRUPTED, protocol, error="APCI length is below four control bytes")
137
+ total = apdu_length + 2
138
+ if len(data) < total:
139
+ return _result(FrameStatus.INCOMPLETE, protocol, frame_length=total)
140
+ if len(data) > total:
141
+ return _result(FrameStatus.CORRUPTED, protocol, error="multiple APDUs or trailing bytes")
142
+ control = data[2:6]
143
+ if (control[0] & 1) == 0:
144
+ frame_type = "I"
145
+ send_sequence = ((control[1] << 8) | control[0]) >> 1
146
+ receive_sequence = ((control[3] << 8) | control[2]) >> 1
147
+ if expected_receive_sequence is not None and receive_sequence != expected_receive_sequence:
148
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type=frame_type, error="receive sequence mismatch")
149
+ fields: Dict[str, Any] = {"send_sequence": send_sequence, "receive_sequence": receive_sequence}
150
+ asdu_start = 6
151
+ elif (control[0] & 3) == 1:
152
+ frame_type = "S"
153
+ receive_sequence = ((control[3] << 8) | control[2]) >> 1
154
+ fields = {"receive_sequence": receive_sequence}
155
+ asdu_start = total
156
+ else:
157
+ frame_type = "U"
158
+ code = control[0] & 0xFC
159
+ u_names = {0x04: "STARTDT act", 0x08: "STARTDT con", 0x10: "STOPDT act", 0x20: "STOPDT con", 0x40: "TESTFR act", 0x80: "TESTFR con"}
160
+ fields = {"u_function": u_names.get(code, "UNKNOWN"), "u_code": code}
161
+ asdu_start = total
162
+ if parse_asdu_data and frame_type == "I":
163
+ asdu = parse_asdu(data[asdu_start:total])
164
+ if asdu["status"] != FrameStatus.VALID.value:
165
+ return _result(FrameStatus.CORRUPTED, protocol, frame_type=frame_type, error=asdu["error"])
166
+ fields["asdu"] = asdu
167
+ return _result(FrameStatus.VALID, protocol, frame_length=total, frame_type=frame_type, fields=fields)
168
+
169
+
170
+ def parse_frame(
171
+ data: bytes,
172
+ *,
173
+ protocol: str = "auto",
174
+ link_address_size: Optional[int] = None,
175
+ expected_receive_sequence: Optional[int] = None,
176
+ parse_asdu_data: bool = True,
177
+ ) -> ParseResult:
178
+ """Parse exactly one frame from a bytes-like buffer.
179
+
180
+ ``protocol`` is ``"t101"``, ``"t103"``, ``"t104"``, or ``"auto"``.
181
+ Auto mode distinguishes FT1.2 by its repeated 0x68 marker; explicit mode is
182
+ recommended when an incomplete buffer contains fewer than four bytes.
183
+ """
184
+ try:
185
+ raw = bytes(data)
186
+ except (TypeError, ValueError):
187
+ return _result(FrameStatus.CORRUPTED, protocol, error="data must be bytes-like")
188
+ selected = protocol.lower()
189
+ if selected not in {"auto", "t101", "t103", "t104"}:
190
+ return _result(FrameStatus.CORRUPTED, selected, error="unsupported protocol")
191
+ if selected == "auto":
192
+ if len(raw) >= 4 and raw[0] == 0x68 and raw[3] == 0x68:
193
+ selected = "t101"
194
+ elif raw and raw[0] in (0x10,):
195
+ selected = "t101"
196
+ elif raw and raw[0] == 0x68:
197
+ selected = "t104"
198
+ else:
199
+ selected = "t101"
200
+ if selected in {"t101", "t103"}:
201
+ address_size = link_address_size if link_address_size is not None else (1 if selected == "t101" else 2)
202
+ if address_size not in (1, 2):
203
+ return _result(FrameStatus.CORRUPTED, selected, error="link_address_size must be 1 or 2")
204
+ return _parse_ft12(raw, selected, link_address_size=address_size, parse_asdu_data=parse_asdu_data)
205
+ return _parse_t104(raw, expected_receive_sequence=expected_receive_sequence, parse_asdu_data=parse_asdu_data)
206
+
207
+
208
+ def parse_asdu(data: bytes, *, ioa_size: int = 3, cot_size: int = 2, coa_size: int = 2) -> Dict[str, Any]:
209
+ """Decode the common ASDU header and known Type 1 and Type 30 objects."""
210
+ raw = bytes(data)
211
+ if ioa_size not in (1, 2, 3) or cot_size not in (1, 2) or coa_size not in (1, 2):
212
+ return {"status": FrameStatus.CORRUPTED.value, "error": "unsupported ASDU address width"}
213
+ header_size = 2 + cot_size + coa_size
214
+ if len(raw) < header_size:
215
+ return {"status": FrameStatus.INCOMPLETE.value, "error": "short ASDU header"}
216
+ type_id = raw[0]
217
+ vsq = raw[1]
218
+ count = vsq & 0x7F
219
+ sequence = bool(vsq & 0x80)
220
+ cot = int.from_bytes(raw[2:2 + cot_size], "little")
221
+ coa_start = 2 + cot_size
222
+ common_address = int.from_bytes(raw[coa_start:coa_start + coa_size], "little")
223
+ offset = header_size
224
+ objects = []
225
+ value_size = 1 + (7 if type_id == 30 else 0)
226
+ if type_id not in (1, 30):
227
+ value_size = 0
228
+ for index in range(count):
229
+ if not sequence or index == 0:
230
+ if offset + ioa_size > len(raw):
231
+ return {"status": FrameStatus.INCOMPLETE.value, "error": "short information object address"}
232
+ ioa = int.from_bytes(raw[offset:offset + ioa_size], "little")
233
+ offset += ioa_size
234
+ else:
235
+ ioa += 1
236
+ item: Dict[str, Any] = {"ioa": ioa}
237
+ if value_size:
238
+ if offset + value_size > len(raw):
239
+ return {"status": FrameStatus.INCOMPLETE.value, "error": "short information object value"}
240
+ item["value"] = raw[offset]
241
+ offset += 1
242
+ if type_id == 30:
243
+ item["cp56time2a"] = raw[offset:offset + 7]
244
+ offset += 7
245
+ objects.append(item)
246
+ return {"status": FrameStatus.VALID.value, "type_id": type_id, "vsq": vsq, "count": count, "sequence": sequence, "cot": cot, "common_address": common_address, "information_objects": objects, "unparsed": raw[offset:]}
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: iec60870-parser
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency IEC 60870-5-101/103/104 frame parser
5
+ Author: IEC 60870 Parser Contributors
6
+ License: MIT
7
+ Keywords: iec60870,scada,telemetry,industrial,protocol
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: System :: Networking
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # iec60870-parser
19
+
20
+ A pure-Python, zero-runtime-dependency parser and validator for IEC 60870-5-101, -103, and -104 telemetry frames. It consumes one raw byte buffer at a time and returns a structured `ParseResult` with `VALID`, `INCOMPLETE`, or `CORRUPTED` status.
21
+
22
+ ## Install
23
+
24
+ ```text
25
+ python -m pip install iec60870-parser
26
+ ```
27
+
28
+ For a source checkout:
29
+
30
+ ```text
31
+ python -m pip install -e .
32
+ ```
33
+
34
+ ## API
35
+
36
+ ```python
37
+ from iec60870 import FrameStatus, T104Session, parse_frame
38
+
39
+ result = parse_frame(raw_bytes, protocol="t104")
40
+ if result.status is FrameStatus.VALID:
41
+ print(result.frame_type, result.fields)
42
+ elif result.status is FrameStatus.INCOMPLETE:
43
+ # Retain the buffer and read more bytes.
44
+ pass
45
+ else:
46
+ print(result.error)
47
+ ```
48
+
49
+ `protocol` accepts `t101`, `t103`, `t104`, or `auto`. Explicit protocol selection is preferred for short/incomplete buffers. T101 uses a one-byte link address by default; T103 uses two bytes. Override either with `link_address_size=1` or `2`.
50
+
51
+ For a stream connection, `T104Session` checks the receive sequence number of I-format APDUs and advances it after each valid telemetry frame:
52
+
53
+ ```python
54
+ session = T104Session()
55
+ result = session.parse(apdu_bytes)
56
+ ```
57
+
58
+ ## Wire formats
59
+
60
+ ### T101 and T103 FT1.2
61
+
62
+ Variable frames are `68 L L 68 [control] [link address] [ASDU] [CS] 16`. `L` is the number of bytes from control through ASDU, and `CS` is the sum of those bytes modulo 256. Fixed frames are `10 [control] [link address] [CS] 16`; the fixed-frame helper uses the one-byte control/address layout defined by this package API.
63
+
64
+ T103 uses the same FT1.2 framing and checksum but defaults to a two-byte link address. Companion-standard ASDU variations that are not Type 1 or Type 30 remain available through raw frame fields and can be decoded with `parse_asdu` using the relevant address widths.
65
+
66
+ ### T104 APCI
67
+
68
+ An APDU is `68 L [four APCI control bytes] [ASDU]`. I-format control fields expose `send_sequence` and `receive_sequence`; S-format exposes the receive sequence; U-format exposes `STARTDT`, `STOPDT`, and `TESTFR` activation/confirmation names. APCI integers are decoded in little-endian wire order and sequence values are 15-bit values.
69
+
70
+ ## ASDU schema
71
+
72
+ `parse_asdu` returns a dictionary containing `type_id`, `vsq`, `count`, `sequence`, `cot`, `common_address`, and `information_objects`. Type 1 objects decode a one-byte single-point value. Type 30 objects decode the one-byte value plus the seven raw CP56Time2a bytes. Unknown type identifiers are validated at the header/address level and leave their remaining bytes in `unparsed`.
73
+
74
+ The parser validates bounds before every read, rejects trailing bytes when parsing a single frame, and does not perform socket or stream I/O.
75
+
76
+ ## Development
77
+
78
+ ```text
79
+ python -m pip install pytest build
80
+ python -m pytest
81
+ python -m build
82
+ ```
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/iec60870/__init__.py
5
+ src/iec60870/parser.py
6
+ src/iec60870_parser.egg-info/PKG-INFO
7
+ src/iec60870_parser.egg-info/SOURCES.txt
8
+ src/iec60870_parser.egg-info/dependency_links.txt
9
+ src/iec60870_parser.egg-info/top_level.txt
10
+ tests/test_parser.py
@@ -0,0 +1,66 @@
1
+ from iec60870 import FrameStatus, T104Session, parse_asdu, parse_frame
2
+
3
+
4
+ def variable_frame(asdu: bytes, address: bytes = b"\x01") -> bytes:
5
+ body = b"\x00" + address + asdu
6
+ checksum = bytes([sum(body) & 0xFF])
7
+ return b"\x68" + bytes([len(body)]) * 2 + b"\x68" + body + checksum + b"\x16"
8
+
9
+
10
+ def t104_i_frame(asdu: bytes, send: int = 0, receive: int = 0) -> bytes:
11
+ c0 = (send << 1) & 0xFF
12
+ c1 = (send >> 7) & 0xFF
13
+ c2 = (receive << 1) & 0xFF
14
+ c3 = (receive >> 7) & 0xFF
15
+ body = bytes([c0, c1, c2, c3]) + asdu
16
+ return b"\x68" + bytes([len(body)]) + body
17
+
18
+
19
+ def single_point_asdu(ioa: int = 1) -> bytes:
20
+ return bytes([1, 1, 3, 0, 1, 0]) + ioa.to_bytes(3, "little") + b"\x01"
21
+
22
+
23
+ def test_t101_variable_and_asdu():
24
+ result = parse_frame(variable_frame(single_point_asdu()), protocol="t101")
25
+ assert result.status is FrameStatus.VALID
26
+ assert result.fields["asdu"]["type_id"] == 1
27
+ assert result.fields["asdu"]["information_objects"][0]["ioa"] == 1
28
+
29
+
30
+ def test_t101_checksum_corruption():
31
+ frame = bytearray(variable_frame(single_point_asdu()))
32
+ frame[-2] ^= 0x01
33
+ assert parse_frame(frame, protocol="t101").status is FrameStatus.CORRUPTED
34
+
35
+
36
+ def test_fixed_frame_and_short_frame():
37
+ assert parse_frame(b"\x10\x00\x01\x01\x16", protocol="t101").status is FrameStatus.VALID
38
+ assert parse_frame(b"\x68\x04\x04", protocol="t101").status is FrameStatus.INCOMPLETE
39
+
40
+
41
+ def test_t103_two_byte_link_address():
42
+ result = parse_frame(variable_frame(single_point_asdu(), b"\x34\x12"), protocol="t103")
43
+ assert result.status is FrameStatus.VALID
44
+ assert result.fields["link_address"] == 0x1234
45
+
46
+
47
+ def test_t104_i_sequence_and_session():
48
+ session = T104Session()
49
+ result = session.parse(t104_i_frame(single_point_asdu()))
50
+ assert result.status is FrameStatus.VALID
51
+ assert session.next_receive_sequence == 1
52
+ assert parse_frame(t104_i_frame(single_point_asdu(), receive=3), protocol="t104", expected_receive_sequence=0).status is FrameStatus.CORRUPTED
53
+
54
+
55
+ def test_t104_s_and_u_formats():
56
+ assert parse_frame(b"\x68\x04\x01\x00\x00\x00", protocol="t104").frame_type == "S"
57
+ assert parse_frame(b"\x68\x04\x07\x00\x00\x00", protocol="t104").fields["u_function"] == "STARTDT act"
58
+ assert parse_frame(b"\x68\x04\x13\x00\x00\x00", protocol="t104").fields["u_function"] == "STOPDT act"
59
+
60
+
61
+ def test_type_30_sequence_objects():
62
+ header = bytes([30, 0x82, 3, 0, 1, 0])
63
+ data = header + (10).to_bytes(3, "little") + b"\x01" + bytes(7) + b"\x00" + bytes(7)
64
+ result = parse_asdu(data)
65
+ assert result["status"] == "VALID"
66
+ assert [item["ioa"] for item in result["information_objects"]] == [10, 11]