Skip to content

Commit e367492

Browse files
committed
Add DNP3 structs (without objects nor groups)
---- + Enum conversion: fix size of referenced enum values
1 parent 06a6eee commit e367492

File tree

6 files changed

+844
-11
lines changed

6 files changed

+844
-11
lines changed

src/icspacket/include/skeletons/py_convert.h

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
#include <Python.h>
1111
#include <py_application.h>
1212

13+
typedef long _senum_t;
14+
typedef unsigned long _uenum_t;
15+
1316
#define PyCompat_ArgCheck(obj, ret) \
1417
if (!obj) { \
1518
PyErr_BadArgument(); \
@@ -198,7 +201,7 @@ static inline int _PyCompatUnicode_AsUTF8(PyObject *pObj, char **str,
198201
}
199202

200203
static inline PyObject *PyCompatEnum_FromSsize_t(PyObject *pEnumType,
201-
Py_ssize_t value) {
204+
_senum_t value) {
202205
PyObject *nValue = NULL, *nResult = NULL;
203206
PyCompat_ArgCheck(pEnumType, NULL);
204207

@@ -212,9 +215,9 @@ static inline PyObject *PyCompatEnum_FromSsize_t(PyObject *pEnumType,
212215
}
213216

214217
static inline PyObject *PyCompatEnum_FromSize_t(PyObject *pEnumType,
215-
size_t value) {
218+
_uenum_t value) {
216219
PyObject *nValue = NULL, *nResult = NULL;
217-
if ((nValue = PyLong_FromSize_t(value & 0x7fffffffffffffff)) == NULL) {
220+
if ((nValue = PyLong_FromSize_t(value)) == NULL) {
218221
goto end;
219222
}
220223
nResult = PyObject_CallOneArg(pEnumType, nValue);
@@ -223,15 +226,15 @@ static inline PyObject *PyCompatEnum_FromSize_t(PyObject *pEnumType,
223226
return nResult;
224227
}
225228

226-
static inline Py_ssize_t PyCompatEnum_AsSsize_t(PyObject *pObj) {
229+
static inline _senum_t PyCompatEnum_AsSsize_t(PyObject *pObj) {
227230
PyObject *nValue = NULL;
228231
if (PyLong_Check(pObj)) {
229232
return PyLong_AsSsize_t(pObj);
230233
}
231234

232235
nValue = PyObject_GetAttrString(pObj, "value");
233236
if (nValue != NULL) {
234-
Py_ssize_t result = PyLong_AsSsize_t(nValue);
237+
_senum_t result = PyLong_AsLong(nValue);
235238
Py_XDECREF(nValue);
236239
return result;
237240
}
@@ -242,15 +245,15 @@ static inline Py_ssize_t PyCompatEnum_AsSsize_t(PyObject *pObj) {
242245
return -1;
243246
}
244247

245-
static inline size_t PyCompatEnum_AsSize_t(PyObject *pObj) {
248+
static inline _uenum_t PyCompatEnum_AsSize_t(PyObject *pObj) {
246249
PyObject *nValue = NULL;
247250
if (PyLong_Check(pObj)) {
248251
return PyLong_AsSize_t(pObj);
249252
}
250253

251254
nValue = PyObject_GetAttrString(pObj, "value");
252255
if (nValue != NULL) {
253-
size_t result = PyLong_AsSize_t(nValue);
256+
_uenum_t result = PyLong_AsSize_t(nValue);
254257
Py_XDECREF(nValue);
255258
return result;
256259
}
@@ -264,19 +267,19 @@ static inline size_t PyCompatEnum_AsSize_t(PyObject *pObj) {
264267
static inline int PyCompatEnum_FromObject(PyObject *pObj, void *dst,
265268
int is_signed) {
266269
if (is_signed) {
267-
*(Py_ssize_t *)dst = PyCompatEnum_AsSsize_t(pObj);
270+
*(_senum_t *)dst = PyCompatEnum_AsSsize_t(pObj);
268271
} else {
269-
*(size_t *)dst = PyCompatEnum_AsSize_t(pObj);
272+
*(_uenum_t *)dst = PyCompatEnum_AsSize_t(pObj);
270273
}
271274
return PyErr_Occurred() != NULL ? -1 : 0;
272275
}
273276

274277
static inline PyObject *PyCompatEnum_AsObject(PyObject *pEnumType, void *src,
275278
int is_signed) {
276279
if (is_signed) {
277-
return PyCompatEnum_FromSsize_t(pEnumType, *(Py_ssize_t *)src);
280+
return PyCompatEnum_FromSsize_t(pEnumType, *(_senum_t *)src);
278281
} else {
279-
return PyCompatEnum_FromSize_t(pEnumType, *(size_t *)src);
282+
return PyCompatEnum_FromSize_t(pEnumType, *(_uenum_t *)src);
280283
}
281284
}
282285

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# This file is part of icspacket.
2+
# Copyright (C) 2025-present MatrixEditor @ github
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
"""\
17+
IEEE 1815 - Distributed Network Protocol (DNP3)
18+
===============================================
19+
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.
24+
25+
-- IEEE 1815
26+
"""
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# This file is part of icspacket.
2+
# Copyright (C) 2025-present MatrixEditor @ github
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
# pyright: reportGeneralTypeIssues=false, reportUninitializedInstanceVariable=false, reportInvalidTypeForm=false
17+
from caterpillar.shared import getstruct
18+
from caterpillar.shortcuts import struct, bitfield
19+
from caterpillar.fields import Bytes
20+
from caterpillar.context import CTX_OBJECT
21+
from caterpillar.model import unpack, EndGroup
22+
23+
from icspacket.proto.dnp3.const import (
24+
FunctionCode,
25+
APDU_RESP_FUNC_MAX,
26+
APDU_RESP_FUNC_MIN,
27+
)
28+
29+
30+
# /4.2.2.4 Application control octet
31+
# Provides information needed to construct and reassemble multiple fragment
32+
# messages and to indicate whether the receiver's Application Layer shall return
33+
# an Application Layer confirmation message.
34+
@bitfield
35+
class ApplicationControl:
36+
"""Represents the DNP3 Application Control octet (see DNP3 standard §4.2.2.4).
37+
38+
This octet carries control information required for managing multi-fragment
39+
Application Layer messages, including message boundaries, sequencing, and
40+
whether acknowledgments are required.
41+
"""
42+
43+
# /4.2.2.4.1 FIR field
44+
first_fragment: 1 = False
45+
"""Indicates whether this is the **first fragment** of a multi-fragment message."""
46+
47+
# /4.2.2.4.2 FIN field
48+
final_fragment: 1 = False
49+
"""Indicates whether this is the **final fragment** of a multi-fragment message."""
50+
51+
# /4.2.2.4.3 CON field
52+
need_confirmation: 1 = False
53+
"""Specifies whether the receiver's Application Layer shall return an
54+
**Application Layer confirmation message**."""
55+
56+
# /4.2.2.4.4 UNS field
57+
unsolicited_response: 1 = False
58+
"""Marks the fragment as containing an **unsolicited response** or a
59+
confirmation of an unsolicited response."""
60+
61+
# /4.2.2.4.5 SEQ field
62+
sequence: 4 = 0
63+
"""Message **sequence number** used to verify fragment ordering and detect
64+
duplicate fragments. Values increment modulo 16."""
65+
66+
67+
def _apdu_is_response(context) -> bool:
68+
"""Determine if the current APDU context corresponds to a **response message**.
69+
70+
In DNP3, responses from outstations use function codes in the range
71+
``129-255``.
72+
73+
:param context: Parsing or decoding context that includes an APDU object.
74+
:type context: dict
75+
:return: ``True`` if the APDU is a response, ``False`` otherwise.
76+
:rtype: bool
77+
"""
78+
obj = context[CTX_OBJECT]
79+
return APDU_RESP_FUNC_MIN <= obj.function <= APDU_RESP_FUNC_MAX
80+
81+
82+
# /4.2.2.6 Internal indications
83+
# The two bytes of the internal indication contain certain states and error
84+
# conditions within the outstation.
85+
@bitfield
86+
class IIN:
87+
"""Represents the DNP3 Internal Indications (IIN) bitfield (§4.2.2.6).
88+
89+
This 2-byte structure communicates the outstation's internal states and
90+
error conditions, such as pending events, device restarts, or unsupported
91+
function codes.
92+
"""
93+
94+
broadcast: 1 = 0
95+
"""A broadcast message was received."""
96+
97+
class_1_events: 1 = False
98+
"""Indicates unreported **Class 1 events** are pending at the outstation."""
99+
100+
class_2_events: 1 = False
101+
"""Indicates unreported **Class 2 events** are pending at the outstation."""
102+
103+
class_3_events: 1 = False
104+
"""Indicates unreported **Class 3 events** are pending at the outstation."""
105+
106+
need_time: 1 = False
107+
"""Indicates that the outstation requires **time synchronization**."""
108+
109+
local_control: 1 = False
110+
"""Indicates one or more of the outstation's points are in **local control
111+
mode**."""
112+
113+
device_trouble: 1 = False
114+
"""An abnormal, device-specific condition exists in the outstation."""
115+
116+
device_restart: (1, EndGroup) = False
117+
"""Indicates that the outstation has **restarted**."""
118+
119+
# Second byte
120+
121+
no_func_code_support: 1 = False
122+
"""The outstation does not support the requested **function code**."""
123+
124+
object_unknown: 1 = False
125+
"""The outstation does not support the requested **object(s)** in the request."""
126+
127+
parameter_error: 1 = False
128+
"""A **parameter error** was detected in the request."""
129+
130+
event_buffer_overflow: 1 = False
131+
"""An **event buffer overflow** occurred, and at least one unconfirmed
132+
event was lost."""
133+
134+
already_executing: 1 = False
135+
"""The requested operation is already executing. Support for this field is
136+
optional."""
137+
138+
config_corrupt: 1 = False
139+
"""The outstation detected **corrupt configuration data**. Support is optional."""
140+
141+
# Reserved: 2 bits
142+
143+
144+
# /4.2.2 Application Layer fragment structure
145+
# Request and response fragments are similar and can be represented by a single APDU structure.
146+
@struct
147+
class APDU:
148+
"""Represents the **Application Protocol Data Unit (APDU)** in DNP3 (§4.2.2).
149+
150+
APDUs encapsulate Application Layer fragments exchanged between masters and
151+
outstations. Both request and response fragments share the same structural
152+
format, consisting of an application control octet, a function code, internal
153+
indications, and object headers.
154+
"""
155+
156+
control: ApplicationControl
157+
"""Application control octet providing fragment sequencing and acknowledgment
158+
control.
159+
"""
160+
161+
function: FunctionCode
162+
"""Function code octet indicating the operation requested or responded to.
163+
Values range from ``0-128`` for requests and ``129-255`` for responses.
164+
"""
165+
166+
iin: getstruct(IIN) // _apdu_is_response
167+
"""Internal indications (IIN) structure, included only in **response APDUs**.
168+
Encodes device states and error conditions.
169+
170+
Parsing is conditional on the APDU being a response (function code ≥ 129).
171+
"""
172+
173+
objects: Bytes(...)
174+
"""Application objects included in the fragment. These represent the payload
175+
of the APDU and are parsed separately according to object headers.
176+
177+
:meta: May include measurement data, control commands, or file operations.
178+
"""
179+
180+
@staticmethod
181+
def from_octets(octets: bytes):
182+
return unpack(APDU, octets)

0 commit comments

Comments
 (0)