Skip to content

Commit 371b4f0

Browse files
committed
Add BCD and DNP3TIME primitive data type support for DNP3
--- + Fix hexdump() output + Add iec61850 ASN.1 sources to CMake file
1 parent 4ac2085 commit 371b4f0

8 files changed

Lines changed: 189 additions & 30 deletions

File tree

CMakeLists.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ if (NOT SKBUILD)
1717
in your environment once and use the following command that avoids
1818
a costly creation of a new virtual environment at every compilation:
1919
=====================================================================
20-
$ pip install pybind11 scikit-build-core[pyproject]
20+
$ pip install scikit-build-core[pyproject]
2121
$ pip install --no-build-isolation -ve .
2222
=====================================================================
2323
You may optionally add -Ceditable.rebuild=true to auto-rebuild when
@@ -40,4 +40,9 @@ add_asn1_extension(
4040
NAME _mms
4141
DIR "${ICS_SOURCE_DIR}/proto/mms/_mms"
4242
INSTALL "proto/mms"
43+
)
44+
add_asn1_extension(
45+
NAME _iec61850
46+
DIR "${ICS_SOURCE_DIR}/proto/iec61850/_iec61850"
47+
INSTALL "proto/iec61850"
4348
)

docs/source/protocols/dnp3/api.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@
33
API Reference
44
=============
55

6+
.. automodule:: icspacket.proto.dnp3
7+
68

79
.. toctree::
810
:caption: Layers
911

1012
api/link
1113
api/transport
1214
api/application
15+
16+
17+
.. toctree::
18+
:caption: Object Standard Library
19+
1320
api/object_library

src/icspacket/core/hexdump.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,7 @@ def hexdump(data: bytes, width: int = 16) -> str:
8686
chunk_ascii = [chr(b) if b in ascii_letters_bytes else "." for b in chunk]
8787
suffix = "".join(chunk_ascii)
8888

89+
if len(chunk_ascii) < width:
90+
suffix = (" " * (width - len(chunk_ascii))) + suffix
8991
_ = result.write(f"{offset:08x}: {chunk_hex} {suffix}\n")
9092
return result.getvalue()

src/icspacket/core/logger.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@
3131
5). These messages will appear when using ``-vvv`` verbosity.
3232
"""
3333

34+
TRACE_PACKETS = 4
35+
"""
36+
Custom logging level to trace packets, numerically below ``TRACE`` (value =
37+
4). These messages will appear when using ``-vvvv`` verbosity.
38+
39+
.. versionadded:: 0.2.0
40+
"""
41+
3442

3543
class PrefixFormatter(logging.Formatter):
3644
"""
@@ -54,7 +62,7 @@ def format(self, record):
5462
record.prefix = "[[red]E[/]]"
5563
case logging.CRITICAL:
5664
record.prefix = "[[white on red]C[/]]"
57-
case 5:
65+
case 5 | 4:
5866
record.prefix = "[[dark_green]T[/]]"
5967
case _:
6068
record.prefix = "[[bright_black]-[/]]"
@@ -107,7 +115,8 @@ def init_from_args(verbosity: int, quiet: bool, ts: bool):
107115
108116
* ``verbosity = 0`` -> ``INFO``
109117
* ``verbosity = 1`` -> ``DEBUG``
110-
* ``verbosity >= 2`` -> ``TRACE``
118+
* ``verbosity = 2`` -> ``TRACE``
119+
* ``verbosity >= 3`` -> ``TRACE_PACKETS``
111120
* ``quiet = True`` -> force ``ERROR`` regardless of verbosity
112121
113122
:param verbosity: CLI verbosity count (``-v`` flags).
@@ -120,9 +129,10 @@ def init_from_args(verbosity: int, quiet: bool, ts: bool):
120129
level = logging.INFO
121130
if verbosity == 1:
122131
level = logging.DEBUG
123-
elif verbosity >= 2:
132+
elif verbosity == 2:
124133
level = TRACE
125-
134+
elif verbosity >= 3:
135+
level = TRACE_PACKETS
126136
if quiet:
127137
level = logging.ERROR
128138

src/icspacket/proto/dnp3/__init__.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414
# You should have received a copy of the GNU General Public License
1515
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1616
"""\
17-
IEEE 1815 - Distributed Network Protocol (DNP3)
18-
===============================================
17+
**IEEE 1815 - Distributed Network Protocol (DNP3)**
1918
20-
Abstract: The DNP3 protocol structure, functions, and interoperable application
21-
options (subset levels) are specified. The simplest application level is
22-
intended for low-cost distribution feeder devices, and the most complex for
23-
full-featured systems.
19+
Abstract: The DNP3 protocol structure, functions, and interoperable application
20+
options (subset levels) are specified. The simplest application level is
21+
intended for low-cost distribution feeder devices, and the most complex for
22+
full-featured systems.
2423
25-
-- IEEE 1815
24+
-- IEEE 1815
25+
26+
.. versionadded:: 0.2.0
2627
"""

src/icspacket/proto/dnp3/link.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@
1515
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1616
# pyright: reportGeneralTypeIssues=false, reportUninitializedInstanceVariable=false, reportInvalidTypeForm=false
1717
import enum
18+
import math
19+
1820
import crcmod.predefined
1921

2022
from caterpillar.shortcuts import this, struct, bitfield, LittleEndian
2123
from caterpillar.fields import singleton, uint16, uint8
2224
from caterpillar.model import EnumFactory, pack, unpack
2325
from caterpillar.context import CTX_STREAM
26+
from caterpillar.exception import ValidationError
2427

2528
from icspacket.proto.dnp3.application import APDU
2629
from icspacket.proto.dnp3.transport import TPDU
@@ -207,7 +210,9 @@ def __unpack__(self, context):
207210
chunk_crc = uint16.__unpack__(context)
208211
expected_crc = Crc16DNP(chunk_data)
209212
if expected_crc != chunk_crc:
210-
raise ValueError(f"CRC error: expected {expected_crc}, got {chunk_crc}")
213+
raise ValidationError(
214+
f"CRC error: expected {expected_crc}, got {chunk_crc}"
215+
)
211216

212217
user_data.extend(chunk_data)
213218
length -= size
@@ -285,6 +290,15 @@ def build(self) -> bytes:
285290
self.crc16 = Crc16DNP(header_octets)
286291
return pack(self)
287292

293+
@staticmethod
294+
def full_length(length: int) -> int:
295+
base_length = 3 # +(start, length)
296+
base_length += 2 # +(crc of header)
297+
base_length += length
298+
# +(crc of user data)
299+
base_length += math.ceil((length - LPDU_HEADER_MIN_LENGTH) / 16) * 2
300+
return base_length
301+
288302
def __bytes__(self):
289303
"""
290304
Get the byte representation of the LPDU.

src/icspacket/proto/dnp3/objects/primitive.py

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,25 @@
3333
3434
These types are used internally by the unpacking and packing mechanisms
3535
when parsing or constructing DNP3 Application Layer objects.
36-
37-
3836
"""
3937

4038
# 11.3 Primitive data types
39+
import datetime
40+
from io import BytesIO, StringIO
4141
import math
4242
import bitarray
43+
from caterpillar.byteorder import LittleEndian
4344
from caterpillar.context import CTX_STREAM
4445
from caterpillar.fields import (
4546
Bytes,
4647
String,
48+
Transformer,
4749
UInt,
4850
float32,
4951
float64,
5052
int16,
5153
int32,
54+
singleton,
5255
uint16,
5356
uint24,
5457
uint32,
@@ -78,9 +81,6 @@
7881
VSTR = String
7982
"""Variable-length string."""
8083

81-
DNP3TIME = UInt(48)
82-
"""48-bit DNP3 timestamp type."""
83-
8484
OSTR = Bytes
8585
"""Octet string (arbitrary-length byte sequence)."""
8686

@@ -91,6 +91,117 @@
9191
"""64-bit IEEE-754 floating point."""
9292

9393

94+
@singleton
95+
class DNP3TIME(Transformer):
96+
"""48-bit DNP3 timestamp type.
97+
98+
This type represents a DNP3-compliant timestamp using a 48-bit unsigned
99+
integer. The timestamp is encoded as the number of **milliseconds since
100+
the Unix epoch (1970-01-01 00:00:00 UTC)**.
101+
102+
It provides automatic conversion between the raw integer form and a
103+
:class:`datetime.datetime` object when decoding, while encoding supports
104+
either integer millisecond values or datetime objects.
105+
"""
106+
107+
def __init__(self) -> None:
108+
super().__init__(LittleEndian + UInt(48))
109+
110+
def decode(self, parsed: int, context) -> datetime.datetime | int:
111+
"""Decode a 48-bit integer timestamp into a datetime object.
112+
113+
:param parsed: The parsed integer value representing milliseconds
114+
since the Unix epoch.
115+
:type parsed: int
116+
:param context: Transformation context (unused in this implementation).
117+
:type context: Any
118+
:return: A :class:`datetime.datetime` object if the value is within
119+
the valid timestamp range, otherwise the raw integer value.
120+
:rtype: datetime.datetime | int
121+
"""
122+
try:
123+
return datetime.datetime.fromtimestamp(parsed / 1000)
124+
except ValueError:
125+
return parsed
126+
127+
def encode(self, obj: int | datetime.datetime, context) -> int:
128+
"""Encode a datetime object or integer into a 48-bit millisecond value.
129+
130+
:param obj: The timestamp to encode, either as a datetime or an integer
131+
number of milliseconds since the Unix epoch.
132+
:type obj: int | datetime.datetime
133+
:param context: Transformation context (unused in this implementation).
134+
:type context: Any
135+
:return: The encoded timestamp as an integer in milliseconds.
136+
:rtype: int
137+
"""
138+
if isinstance(obj, datetime.datetime):
139+
obj = int(obj.timestamp()) * 1000
140+
return int(obj)
141+
142+
143+
class BCD(Transformer):
144+
"""Binary-coded decimal (BCD) type.
145+
146+
Implements DNP3 section 11.3.6: *Binary-coded decimal values use the notation
147+
BCDn, where ``n`` represents the number of BCD characters. For example,
148+
``BCD8`` requires 8 BCD characters.*
149+
150+
Each BCD character is stored in 4 bits (a nibble). Two characters are packed
151+
into a single byte in little-endian order.
152+
"""
153+
154+
def __init__(self, count: int) -> None:
155+
"""Decode BCD bytes into a string of decimal digits.
156+
157+
:param parsed: Encoded binary-coded decimal bytes.
158+
:type parsed: bytes
159+
:param context: Transformation context (unused in this implementation).
160+
:type context: Any
161+
:return: The decoded decimal string, e.g. "1234".
162+
:rtype: str
163+
"""
164+
# Each BCD character requires 4 bits
165+
super().__init__(Bytes(count / 2))
166+
167+
def decode(self, parsed: bytes, context) -> str:
168+
"""Encode a string of decimal digits into BCD bytes.
169+
170+
:param obj: The decimal string to encode. A '-' character may be used
171+
to represent a nibble value of 10.
172+
:type obj: str
173+
:param context: Transformation context (unused in this implementation).
174+
:type context: Any
175+
:return: Encoded BCD bytes.
176+
:rtype: bytes
177+
"""
178+
string = StringIO()
179+
for byte in parsed:
180+
# because of little endian encoding
181+
low_number = (byte & 0b11110000) >> 4
182+
high_number = byte & 0b00001111
183+
if high_number >= 10:
184+
_ = string.write("-")
185+
else:
186+
_ = string.write(str(high_number))
187+
_ = string.write(str(low_number))
188+
return string.getvalue()
189+
190+
def encode(self, obj: str, context) -> bytes:
191+
packed = BytesIO()
192+
for i in range(0, len(obj), 2):
193+
low_number_str = obj[i]
194+
high_number_str = obj[i + 1]
195+
if high_number_str == "-":
196+
high_number = 10
197+
else:
198+
high_number = int(high_number_str)
199+
low_number = int(low_number_str)
200+
_ = packed.write(high_number | low_number << 4)
201+
202+
return packed.getvalue()
203+
204+
94205
class BSTRn:
95206
"""Packed bit string (BSTRn) representation.
96207

0 commit comments

Comments
 (0)