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.
- iec60870_parser-0.1.0/LICENSE +21 -0
- iec60870_parser-0.1.0/PKG-INFO +82 -0
- iec60870_parser-0.1.0/README.md +65 -0
- iec60870_parser-0.1.0/pyproject.toml +30 -0
- iec60870_parser-0.1.0/setup.cfg +4 -0
- iec60870_parser-0.1.0/src/iec60870/__init__.py +11 -0
- iec60870_parser-0.1.0/src/iec60870/parser.py +246 -0
- iec60870_parser-0.1.0/src/iec60870_parser.egg-info/PKG-INFO +82 -0
- iec60870_parser-0.1.0/src/iec60870_parser.egg-info/SOURCES.txt +10 -0
- iec60870_parser-0.1.0/src/iec60870_parser.egg-info/dependency_links.txt +1 -0
- iec60870_parser-0.1.0/src/iec60870_parser.egg-info/top_level.txt +1 -0
- iec60870_parser-0.1.0/tests/test_parser.py +66 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
iec60870
|
|
@@ -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]
|