microdlms 0.1.0__py3-none-any.whl
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.
- microdlms/__init__.py +64 -0
- microdlms/apdu.py +203 -0
- microdlms/axdr.py +125 -0
- microdlms/client.py +299 -0
- microdlms/exceptions.py +34 -0
- microdlms/hdlc.py +562 -0
- microdlms/obis.py +93 -0
- microdlms/py.typed +2 -0
- microdlms/session.py +296 -0
- microdlms/types.py +248 -0
- microdlms-0.1.0.dist-info/METADATA +285 -0
- microdlms-0.1.0.dist-info/RECORD +14 -0
- microdlms-0.1.0.dist-info/WHEEL +4 -0
- microdlms-0.1.0.dist-info/licenses/LICENSE +21 -0
microdlms/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""microdlms: Zero-core-dependency DLMS/COSEM meter client for Python 3.14+."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from microdlms.axdr import decode_axdr
|
|
6
|
+
from microdlms.client import AsyncMeterClient
|
|
7
|
+
from microdlms.exceptions import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
CommunicationError,
|
|
10
|
+
DataAccessError,
|
|
11
|
+
MicrodlmsError,
|
|
12
|
+
ProtocolError,
|
|
13
|
+
)
|
|
14
|
+
from microdlms.obis import CosemAttribute, ObisCode
|
|
15
|
+
from microdlms.session import DlmsSession, SessionState
|
|
16
|
+
from microdlms.types import (
|
|
17
|
+
AssociatedEvent,
|
|
18
|
+
AssociationDiagnostic,
|
|
19
|
+
AssociationRejectedEvent,
|
|
20
|
+
AssociationResult,
|
|
21
|
+
ConnectedEvent,
|
|
22
|
+
CosemDateTime,
|
|
23
|
+
DataAccessResult,
|
|
24
|
+
DisconnectedEvent,
|
|
25
|
+
GetAccessErrorEvent,
|
|
26
|
+
GetResponseEvent,
|
|
27
|
+
InterfaceClass,
|
|
28
|
+
ProtocolErrorEvent,
|
|
29
|
+
ReleasedEvent,
|
|
30
|
+
ScalerUnit,
|
|
31
|
+
SessionEvent,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"AsyncMeterClient",
|
|
39
|
+
"DlmsSession",
|
|
40
|
+
"SessionState",
|
|
41
|
+
"ObisCode",
|
|
42
|
+
"CosemAttribute",
|
|
43
|
+
"InterfaceClass",
|
|
44
|
+
"ScalerUnit",
|
|
45
|
+
"CosemDateTime",
|
|
46
|
+
"AssociationResult",
|
|
47
|
+
"AssociationDiagnostic",
|
|
48
|
+
"DataAccessResult",
|
|
49
|
+
"ConnectedEvent",
|
|
50
|
+
"AssociatedEvent",
|
|
51
|
+
"AssociationRejectedEvent",
|
|
52
|
+
"GetResponseEvent",
|
|
53
|
+
"GetAccessErrorEvent",
|
|
54
|
+
"ReleasedEvent",
|
|
55
|
+
"DisconnectedEvent",
|
|
56
|
+
"ProtocolErrorEvent",
|
|
57
|
+
"SessionEvent",
|
|
58
|
+
"MicrodlmsError",
|
|
59
|
+
"CommunicationError",
|
|
60
|
+
"AuthenticationError",
|
|
61
|
+
"DataAccessError",
|
|
62
|
+
"ProtocolError",
|
|
63
|
+
"decode_axdr",
|
|
64
|
+
]
|
microdlms/apdu.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""DLMS/COSEM Application Protocol Data Unit (APDU) codecs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Final
|
|
6
|
+
|
|
7
|
+
from microdlms.axdr import decode_axdr, encode_axdr_length
|
|
8
|
+
from microdlms.exceptions import ProtocolError
|
|
9
|
+
from microdlms.obis import CosemAttribute
|
|
10
|
+
from microdlms.types import AssociationResult, DataAccessResult
|
|
11
|
+
|
|
12
|
+
TAG_AARQ: Final = 0x60
|
|
13
|
+
TAG_AARE: Final = 0x61
|
|
14
|
+
TAG_RLRQ: Final = 0x62
|
|
15
|
+
TAG_RLRE: Final = 0x63
|
|
16
|
+
TAG_GET_REQUEST: Final = 0xC0
|
|
17
|
+
TAG_GET_RESPONSE: Final = 0xC4
|
|
18
|
+
|
|
19
|
+
# Default system title matching captured fixtures if none provided
|
|
20
|
+
DEFAULT_SYSTEM_TITLE: Final = bytes.fromhex("7574691cc56a7264")
|
|
21
|
+
|
|
22
|
+
# Logical Name referencing without ciphering OID: 2.16.756.5.8.1.1
|
|
23
|
+
APP_CONTEXT_LN_NO_CIPHER: Final = bytes.fromhex("060760857405080101")
|
|
24
|
+
|
|
25
|
+
# Low-Level Security (LLS) Mechanism name OID: 2.16.756.5.8.2.1
|
|
26
|
+
MECHANISM_NAME_LLS: Final = bytes.fromhex("60857405080201")
|
|
27
|
+
|
|
28
|
+
# Default proposed xDLMS InitiateRequest conformance: 00 20 52, max receive PDU 24575
|
|
29
|
+
DEFAULT_INITIATE_REQUEST: Final = bytes.fromhex("01000000065f1f040020525fffff")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def encode_aarq(
|
|
33
|
+
password: str | bytes | None = None,
|
|
34
|
+
system_title: bytes | None = None,
|
|
35
|
+
) -> bytes:
|
|
36
|
+
"""Encode an Application Association Request (AARQ) APDU."""
|
|
37
|
+
effective_system_title = system_title if system_title is not None else DEFAULT_SYSTEM_TITLE
|
|
38
|
+
if len(effective_system_title) != 8:
|
|
39
|
+
message = f"System title must be exactly 8 bytes, got {len(effective_system_title)}"
|
|
40
|
+
raise ValueError(message)
|
|
41
|
+
|
|
42
|
+
parts: list[bytes] = []
|
|
43
|
+
|
|
44
|
+
# 1. Application-context-name [1]
|
|
45
|
+
parts.append(b"\xa1\x09" + APP_CONTEXT_LN_NO_CIPHER)
|
|
46
|
+
|
|
47
|
+
# 2. Calling-AP-title [6]
|
|
48
|
+
parts.append(b"\xa6\x0a\x04\x08" + effective_system_title)
|
|
49
|
+
|
|
50
|
+
# 3. Authentication (Low-Level Security if password provided)
|
|
51
|
+
if password is not None:
|
|
52
|
+
password_bytes = password.encode("utf-8") if isinstance(password, str) else password
|
|
53
|
+
if len(password_bytes) > 0:
|
|
54
|
+
# ACSE requirements [10]: authentication bit
|
|
55
|
+
parts.append(b"\x8a\x02\x07\x80")
|
|
56
|
+
# Mechanism-name [11]: LLS OID
|
|
57
|
+
parts.append(b"\x8b\x07" + MECHANISM_NAME_LLS)
|
|
58
|
+
# Calling-authentication-value [12] -> [0] primitive
|
|
59
|
+
value_field = b"\x80" + encode_axdr_length(len(password_bytes)) + password_bytes
|
|
60
|
+
parts.append(b"\xac" + encode_axdr_length(len(value_field)) + value_field)
|
|
61
|
+
|
|
62
|
+
# 4. User-information [30] -> OCTET STRING wrapping xDLMS InitiateRequest
|
|
63
|
+
user_information = (
|
|
64
|
+
b"\x04" + encode_axdr_length(len(DEFAULT_INITIATE_REQUEST)) + DEFAULT_INITIATE_REQUEST
|
|
65
|
+
)
|
|
66
|
+
parts.append(b"\xbe" + encode_axdr_length(len(user_information)) + user_information)
|
|
67
|
+
|
|
68
|
+
body = b"".join(parts)
|
|
69
|
+
return bytes([TAG_AARQ]) + encode_axdr_length(len(body)) + body
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_ber_tlv(data: bytes, position: int) -> tuple[int, bytes, int] | None:
|
|
73
|
+
"""Read a single BER TLV element returning (tag, value_bytes, next_position) or None."""
|
|
74
|
+
if position + 2 > len(data):
|
|
75
|
+
return None
|
|
76
|
+
tag = data[position]
|
|
77
|
+
position += 1
|
|
78
|
+
length_byte = data[position]
|
|
79
|
+
position += 1
|
|
80
|
+
if length_byte < 0x80:
|
|
81
|
+
length = length_byte
|
|
82
|
+
else:
|
|
83
|
+
length_bytes_count = length_byte & 0x7F
|
|
84
|
+
if position + length_bytes_count > len(data):
|
|
85
|
+
return None
|
|
86
|
+
length = int.from_bytes(data[position : position + length_bytes_count], "big")
|
|
87
|
+
position += length_bytes_count
|
|
88
|
+
|
|
89
|
+
if position + length > len(data):
|
|
90
|
+
return None
|
|
91
|
+
chunk = data[position : position + length]
|
|
92
|
+
return tag, chunk, position + length
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_aare_result(chunk: bytes) -> AssociationResult:
|
|
96
|
+
"""Parse AssociationResult enum from result field bytes."""
|
|
97
|
+
try:
|
|
98
|
+
return AssociationResult(chunk[2])
|
|
99
|
+
except ValueError, IndexError:
|
|
100
|
+
return AssociationResult.REJECTED_PERMANENT
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_aare_diagnostic(chunk: bytes) -> int:
|
|
104
|
+
"""Parse diagnostic integer from result-source-diagnostic field bytes."""
|
|
105
|
+
if len(chunk) >= 5 and chunk[2] == 0x02 and chunk[3] == 0x01:
|
|
106
|
+
return chunk[4]
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def decode_aare(apdu: bytes) -> tuple[AssociationResult, int]:
|
|
111
|
+
"""Decode an Application Association Response (AARE) APDU into (result, diagnostic)."""
|
|
112
|
+
if len(apdu) < 3 or apdu[0] != TAG_AARE:
|
|
113
|
+
message = f"Invalid AARE APDU: {apdu.hex()}"
|
|
114
|
+
raise ProtocolError(message)
|
|
115
|
+
|
|
116
|
+
position = 1
|
|
117
|
+
# Read length of AARE
|
|
118
|
+
initial_length = apdu[position]
|
|
119
|
+
position += 1
|
|
120
|
+
if initial_length >= 0x80:
|
|
121
|
+
length_bytes_count = initial_length & 0x7F
|
|
122
|
+
position += length_bytes_count
|
|
123
|
+
|
|
124
|
+
result: AssociationResult | None = None
|
|
125
|
+
diagnostic: int = 0
|
|
126
|
+
|
|
127
|
+
while position < len(apdu):
|
|
128
|
+
tlv = _read_ber_tlv(apdu, position)
|
|
129
|
+
if tlv is None:
|
|
130
|
+
break
|
|
131
|
+
tag, chunk, position = tlv
|
|
132
|
+
|
|
133
|
+
if tag == 0xA2: # result [2]
|
|
134
|
+
result = _parse_aare_result(chunk)
|
|
135
|
+
elif tag == 0xA3: # result-source-diagnostic [3]
|
|
136
|
+
diagnostic = _parse_aare_diagnostic(chunk)
|
|
137
|
+
|
|
138
|
+
if result is None:
|
|
139
|
+
message = f"AARE APDU missing result field: {apdu.hex()}"
|
|
140
|
+
raise ProtocolError(message)
|
|
141
|
+
|
|
142
|
+
return result, diagnostic
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def encode_get_request_normal(
|
|
146
|
+
attribute: CosemAttribute,
|
|
147
|
+
invoke_id: int = 0,
|
|
148
|
+
priority: int = 0xC0,
|
|
149
|
+
) -> bytes:
|
|
150
|
+
"""Encode a Get-Request-Normal APDU querying a COSEM attribute."""
|
|
151
|
+
invoke_id_byte = (priority & 0xF0) | (invoke_id & 0x0F)
|
|
152
|
+
# Tag 0xC0 (Get-Request), Choice 0x01 (Get-Request-Normal)
|
|
153
|
+
# Followed by invoke_id_and_priority (1B), attribute descriptor (9B), access_selection (1B)
|
|
154
|
+
return bytes([TAG_GET_REQUEST, 0x01, invoke_id_byte]) + attribute.to_bytes() + bytes([0x00])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def decode_get_response_normal(apdu: bytes) -> tuple[int, bool, Any]:
|
|
158
|
+
"""Decode a Get-Response-Normal APDU into (invoke_id, is_success, value_or_error_code)."""
|
|
159
|
+
if len(apdu) < 4 or apdu[0] != TAG_GET_RESPONSE:
|
|
160
|
+
message = f"Invalid Get-Response APDU: {apdu.hex()}"
|
|
161
|
+
raise ProtocolError(message)
|
|
162
|
+
|
|
163
|
+
choice = apdu[1]
|
|
164
|
+
if choice != 0x01:
|
|
165
|
+
message = f"Unsupported Get-Response type 0x{choice:02X} (only Normal=0x01 is supported)"
|
|
166
|
+
raise ProtocolError(message)
|
|
167
|
+
|
|
168
|
+
invoke_id = apdu[2] & 0x0F
|
|
169
|
+
result_type = apdu[3]
|
|
170
|
+
|
|
171
|
+
if result_type == 0x00:
|
|
172
|
+
# Success: A-XDR data follows immediately at offset 4
|
|
173
|
+
decoded_value = decode_axdr(apdu[4:])
|
|
174
|
+
return invoke_id, True, decoded_value
|
|
175
|
+
|
|
176
|
+
if result_type == 0x01:
|
|
177
|
+
# DataAccessResult error code at offset 4
|
|
178
|
+
if len(apdu) < 5:
|
|
179
|
+
message = "Get-Response-Normal error result missing error code"
|
|
180
|
+
raise ProtocolError(message)
|
|
181
|
+
try:
|
|
182
|
+
error_code = DataAccessResult(apdu[4])
|
|
183
|
+
except ValueError:
|
|
184
|
+
error_code = DataAccessResult.OTHER_REASON
|
|
185
|
+
return invoke_id, False, error_code
|
|
186
|
+
|
|
187
|
+
message = f"Unknown GetDataResult choice: 0x{result_type:02X}"
|
|
188
|
+
raise ProtocolError(message)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def encode_rlrq(reason: int = 0) -> bytes:
|
|
192
|
+
"""Encode an ACSE Release Request (RLRQ) APDU."""
|
|
193
|
+
return bytes([TAG_RLRQ, 0x03, 0x80, 0x01, reason])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def decode_rlre(apdu: bytes) -> int:
|
|
197
|
+
"""Decode an ACSE Release Response (RLRE) APDU into release reason code."""
|
|
198
|
+
if len(apdu) < 2 or apdu[0] != TAG_RLRE:
|
|
199
|
+
message = f"Invalid RLRE APDU: {apdu.hex()}"
|
|
200
|
+
raise ProtocolError(message)
|
|
201
|
+
if len(apdu) >= 5 and apdu[2] == 0x80 and apdu[3] == 0x01:
|
|
202
|
+
return apdu[4]
|
|
203
|
+
return 0
|
microdlms/axdr.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Linear recursive-descent A-XDR codec for DLMS/COSEM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from microdlms.exceptions import ProtocolError
|
|
8
|
+
|
|
9
|
+
_INTEGER_TAG_SPECS: dict[int, tuple[int, bool]] = {
|
|
10
|
+
0x05: (4, True), # double-long (int32 signed)
|
|
11
|
+
0x06: (4, False), # double-long-unsigned (uint32 unsigned)
|
|
12
|
+
0x0F: (1, True), # integer (int8 signed)
|
|
13
|
+
0x10: (2, True), # long (int16 signed)
|
|
14
|
+
0x11: (1, False), # unsigned (uint8 unsigned)
|
|
15
|
+
0x12: (2, False), # long-unsigned (uint16 unsigned)
|
|
16
|
+
0x14: (8, True), # long64 (int64 signed)
|
|
17
|
+
0x15: (8, False), # long64-unsigned (uint64 unsigned)
|
|
18
|
+
0x16: (1, False), # enum (uint8 unsigned)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AxdrReader:
|
|
23
|
+
"""Linear recursive-descent parser for DLMS A-XDR encoded data."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, data: bytes) -> None:
|
|
26
|
+
self._data = data
|
|
27
|
+
self._position = 0
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def remaining(self) -> int:
|
|
31
|
+
"""Number of bytes remaining in the buffer."""
|
|
32
|
+
return len(self._data) - self._position
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def position(self) -> int:
|
|
36
|
+
"""Current parse offset."""
|
|
37
|
+
return self._position
|
|
38
|
+
|
|
39
|
+
def read_byte(self) -> int:
|
|
40
|
+
"""Read a single byte from the buffer."""
|
|
41
|
+
if self._position >= len(self._data):
|
|
42
|
+
message = f"Unexpected end of data at offset {self._position}"
|
|
43
|
+
raise ProtocolError(message)
|
|
44
|
+
byte = self._data[self._position]
|
|
45
|
+
self._position += 1
|
|
46
|
+
return byte
|
|
47
|
+
|
|
48
|
+
def read_bytes(self, count: int) -> bytes:
|
|
49
|
+
"""Read exact number of bytes from the buffer."""
|
|
50
|
+
if count < 0:
|
|
51
|
+
message = f"Invalid negative byte count: {count}"
|
|
52
|
+
raise ProtocolError(message)
|
|
53
|
+
if self._position + count > len(self._data):
|
|
54
|
+
message = (
|
|
55
|
+
f"Unexpected end of data: needed {count} bytes at offset {self._position}, "
|
|
56
|
+
f"only {self.remaining} available"
|
|
57
|
+
)
|
|
58
|
+
raise ProtocolError(message)
|
|
59
|
+
chunk = self._data[self._position : self._position + count]
|
|
60
|
+
self._position += count
|
|
61
|
+
return chunk
|
|
62
|
+
|
|
63
|
+
def read_length(self) -> int:
|
|
64
|
+
"""Read A-XDR length field supporting single-byte and multi-byte encoding."""
|
|
65
|
+
initial = self.read_byte()
|
|
66
|
+
if initial < 0x80:
|
|
67
|
+
return initial
|
|
68
|
+
length_bytes_count = initial & 0x7F
|
|
69
|
+
if length_bytes_count == 0:
|
|
70
|
+
message = "Indefinite length encoding is not allowed in DLMS A-XDR"
|
|
71
|
+
raise ProtocolError(message)
|
|
72
|
+
length_bytes = self.read_bytes(length_bytes_count)
|
|
73
|
+
return int.from_bytes(length_bytes, "big", signed=False)
|
|
74
|
+
|
|
75
|
+
def decode_value(self) -> Any:
|
|
76
|
+
"""Decode the next typed value from the buffer."""
|
|
77
|
+
tag = self.read_byte()
|
|
78
|
+
|
|
79
|
+
# 0x00: null-data
|
|
80
|
+
if tag == 0x00:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# 0x01: array, 0x02: structure
|
|
84
|
+
if tag in (0x01, 0x02):
|
|
85
|
+
count = self.read_length()
|
|
86
|
+
return [self.decode_value() for _ in range(count)]
|
|
87
|
+
|
|
88
|
+
# 0x03: boolean
|
|
89
|
+
if tag == 0x03:
|
|
90
|
+
return self.read_byte() != 0
|
|
91
|
+
|
|
92
|
+
# 0x04: bit-string
|
|
93
|
+
if tag == 0x04:
|
|
94
|
+
bit_count = self.read_length()
|
|
95
|
+
byte_count = (bit_count + 7) // 8
|
|
96
|
+
return self.read_bytes(byte_count)
|
|
97
|
+
|
|
98
|
+
# 0x09: octet-string, 0x0A: visible-string
|
|
99
|
+
if tag in (0x09, 0x0A):
|
|
100
|
+
length = self.read_length()
|
|
101
|
+
raw = self.read_bytes(length)
|
|
102
|
+
return raw.decode("utf-8", errors="replace") if tag == 0x0A else raw
|
|
103
|
+
|
|
104
|
+
int_spec = _INTEGER_TAG_SPECS.get(tag)
|
|
105
|
+
if int_spec is not None:
|
|
106
|
+
byte_length, is_signed = int_spec
|
|
107
|
+
return int.from_bytes(self.read_bytes(byte_length), "big", signed=is_signed)
|
|
108
|
+
|
|
109
|
+
message = f"Unsupported or unknown A-XDR tag 0x{tag:02X} at offset {self._position - 1}"
|
|
110
|
+
raise ProtocolError(message)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def decode_axdr(data: bytes) -> Any:
|
|
114
|
+
"""Convenience helper to decode a single A-XDR value from bytes."""
|
|
115
|
+
reader = AxdrReader(data)
|
|
116
|
+
value = reader.decode_value()
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def encode_axdr_length(length: int) -> bytes:
|
|
121
|
+
"""Encode an A-XDR length value."""
|
|
122
|
+
if length < 0x80:
|
|
123
|
+
return bytes([length])
|
|
124
|
+
length_bytes = length.to_bytes((length.bit_length() + 7) // 8, "big")
|
|
125
|
+
return bytes([0x80 | len(length_bytes)]) + length_bytes
|
microdlms/client.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Asynchronous DLMS meter client powered by serialx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, Self
|
|
9
|
+
|
|
10
|
+
import serialx
|
|
11
|
+
|
|
12
|
+
from microdlms.exceptions import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
CommunicationError,
|
|
15
|
+
DataAccessError,
|
|
16
|
+
)
|
|
17
|
+
from microdlms.obis import CosemAttribute
|
|
18
|
+
from microdlms.session import DlmsSession, SessionState
|
|
19
|
+
from microdlms.types import (
|
|
20
|
+
AssociatedEvent,
|
|
21
|
+
AssociationRejectedEvent,
|
|
22
|
+
ConnectedEvent,
|
|
23
|
+
DisconnectedEvent,
|
|
24
|
+
GetAccessErrorEvent,
|
|
25
|
+
GetResponseEvent,
|
|
26
|
+
ProtocolErrorEvent,
|
|
27
|
+
SessionEvent,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncMeterClient:
|
|
32
|
+
"""High-level asynchronous client for reading DLMS/COSEM meters over serial or TCP sockets."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
port: str,
|
|
37
|
+
baudrate: int = 9600,
|
|
38
|
+
password: str | bytes | None = None,
|
|
39
|
+
client_logical_address: int = 32,
|
|
40
|
+
server_logical_address: int = 1,
|
|
41
|
+
server_physical_address: int | None = None,
|
|
42
|
+
timeout: float = 10.0,
|
|
43
|
+
system_title: bytes | None = None,
|
|
44
|
+
retries: int = 3,
|
|
45
|
+
retry_delay: float = 0.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.port = port
|
|
48
|
+
self.baudrate = baudrate
|
|
49
|
+
self.password = password
|
|
50
|
+
self.client_logical_address = client_logical_address
|
|
51
|
+
self.server_logical_address = server_logical_address
|
|
52
|
+
self.server_physical_address = server_physical_address
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
self.system_title = system_title
|
|
55
|
+
self.retries = retries
|
|
56
|
+
self.retry_delay = retry_delay
|
|
57
|
+
|
|
58
|
+
self._session = DlmsSession(
|
|
59
|
+
client_address=client_logical_address,
|
|
60
|
+
server_logical_address=server_logical_address,
|
|
61
|
+
server_physical_address=server_physical_address,
|
|
62
|
+
system_title=system_title,
|
|
63
|
+
)
|
|
64
|
+
self._reader: asyncio.StreamReader | None = None
|
|
65
|
+
self._writer: Any = None
|
|
66
|
+
self._event_queue: list[SessionEvent] = []
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_connected(self) -> bool:
|
|
70
|
+
"""True if client is connected and application association is active."""
|
|
71
|
+
return self._session.state == SessionState.ASSOCIATED
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def session(self) -> DlmsSession:
|
|
75
|
+
"""Underlying sans-I/O session state machine."""
|
|
76
|
+
return self._session
|
|
77
|
+
|
|
78
|
+
async def connect(self) -> None:
|
|
79
|
+
"""Establish physical serial/socket connection, HDLC link, and DLMS association."""
|
|
80
|
+
if self._writer is not None:
|
|
81
|
+
if self.is_connected:
|
|
82
|
+
return
|
|
83
|
+
await self.disconnect()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
self._reader, self._writer = await serialx.open_serial_connection(
|
|
87
|
+
url=self.port,
|
|
88
|
+
baudrate=self.baudrate,
|
|
89
|
+
)
|
|
90
|
+
except Exception as error:
|
|
91
|
+
message = f"Failed to open connection to {self.port}: {error}"
|
|
92
|
+
raise CommunicationError(message) from error
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Step 1: Establish HDLC physical link (SNRM -> UA)
|
|
96
|
+
self._session.initiate_connect()
|
|
97
|
+
await self._flush_outbound()
|
|
98
|
+
event = await self._wait_for_event(ConnectedEvent, ProtocolErrorEvent)
|
|
99
|
+
if isinstance(event, ProtocolErrorEvent):
|
|
100
|
+
message = f"HDLC connect failed: {event.message}"
|
|
101
|
+
raise CommunicationError(message)
|
|
102
|
+
|
|
103
|
+
# Step 2: Establish application association (AARQ -> AARE)
|
|
104
|
+
self._session.initiate_association(
|
|
105
|
+
password=self.password, system_title=self.system_title
|
|
106
|
+
)
|
|
107
|
+
await self._flush_outbound()
|
|
108
|
+
event = await self._wait_for_event(
|
|
109
|
+
AssociatedEvent,
|
|
110
|
+
AssociationRejectedEvent,
|
|
111
|
+
ProtocolErrorEvent,
|
|
112
|
+
)
|
|
113
|
+
if isinstance(event, AssociationRejectedEvent):
|
|
114
|
+
message = (
|
|
115
|
+
f"Association rejected by meter: result={event.result.name} "
|
|
116
|
+
f"(diagnostic={event.diagnostic})"
|
|
117
|
+
)
|
|
118
|
+
raise AuthenticationError(message)
|
|
119
|
+
if isinstance(event, ProtocolErrorEvent):
|
|
120
|
+
message = f"Association failed: {event.message}"
|
|
121
|
+
raise CommunicationError(message)
|
|
122
|
+
except Exception:
|
|
123
|
+
await self.disconnect()
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
def _handle_get_response_event(self, event: SessionEvent) -> Any:
|
|
127
|
+
"""Process event received in response to attribute get request."""
|
|
128
|
+
if isinstance(event, GetResponseEvent):
|
|
129
|
+
return event.value
|
|
130
|
+
if isinstance(event, GetAccessErrorEvent):
|
|
131
|
+
message = f"Meter returned error: {event.error_code.name} ({event.error_code.value})"
|
|
132
|
+
raise DataAccessError(error_code=event.error_code, message=message)
|
|
133
|
+
if isinstance(event, ProtocolErrorEvent):
|
|
134
|
+
message = f"Protocol error during get: {event.message}"
|
|
135
|
+
raise CommunicationError(message)
|
|
136
|
+
message = f"Unexpected event received during get: {type(event).__name__}"
|
|
137
|
+
raise CommunicationError(message)
|
|
138
|
+
|
|
139
|
+
async def _execute_get_attempt(
|
|
140
|
+
self,
|
|
141
|
+
attribute: CosemAttribute,
|
|
142
|
+
pending_invoke_ids: set[int],
|
|
143
|
+
timeout: float | None,
|
|
144
|
+
) -> Any:
|
|
145
|
+
"""Send a single attribute request and await its response event."""
|
|
146
|
+
invoke_id = self._session.request_attribute(attribute)
|
|
147
|
+
pending_invoke_ids.add(invoke_id)
|
|
148
|
+
await self._flush_outbound()
|
|
149
|
+
|
|
150
|
+
event = await self._wait_for_event(
|
|
151
|
+
GetResponseEvent,
|
|
152
|
+
GetAccessErrorEvent,
|
|
153
|
+
ProtocolErrorEvent,
|
|
154
|
+
timeout=timeout,
|
|
155
|
+
predicate=lambda matched_event: (
|
|
156
|
+
not isinstance(matched_event, (GetResponseEvent, GetAccessErrorEvent))
|
|
157
|
+
or matched_event.invoke_id in pending_invoke_ids
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
return self._handle_get_response_event(event)
|
|
161
|
+
|
|
162
|
+
async def _prepare_next_retry(self, attempt: int, delay: float) -> None:
|
|
163
|
+
"""Wait retry delay and clean transient protocol errors before retrying."""
|
|
164
|
+
if attempt > 0:
|
|
165
|
+
if delay > 0:
|
|
166
|
+
await asyncio.sleep(delay)
|
|
167
|
+
self._event_queue = [
|
|
168
|
+
event for event in self._event_queue if not isinstance(event, ProtocolErrorEvent)
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def _raise_get_error(
|
|
172
|
+
self,
|
|
173
|
+
error: CommunicationError,
|
|
174
|
+
primary_error: Exception | None,
|
|
175
|
+
attempt: int,
|
|
176
|
+
effective_retries: int,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Raise primary or current communication error if disconnected or out of retries."""
|
|
179
|
+
if not self.is_connected or attempt == effective_retries:
|
|
180
|
+
final_error = primary_error if primary_error is not None else error
|
|
181
|
+
raise final_error from error
|
|
182
|
+
|
|
183
|
+
async def get(
|
|
184
|
+
self,
|
|
185
|
+
attribute: CosemAttribute,
|
|
186
|
+
retries: int | None = None,
|
|
187
|
+
retry_delay: float | None = None,
|
|
188
|
+
timeout: float | None = None,
|
|
189
|
+
) -> Any:
|
|
190
|
+
"""Query a COSEM attribute from the meter and return its decoded Python value."""
|
|
191
|
+
if not self.is_connected:
|
|
192
|
+
message = "Client is not connected. Call connect() first."
|
|
193
|
+
raise CommunicationError(message)
|
|
194
|
+
|
|
195
|
+
effective_retries = max(0, self.retries if retries is None else retries)
|
|
196
|
+
effective_delay = self.retry_delay if retry_delay is None else retry_delay
|
|
197
|
+
primary_error: Exception | None = None
|
|
198
|
+
pending_invoke_ids: set[int] = set()
|
|
199
|
+
|
|
200
|
+
for attempt in range(effective_retries + 1):
|
|
201
|
+
await self._prepare_next_retry(attempt, effective_delay)
|
|
202
|
+
try:
|
|
203
|
+
return await self._execute_get_attempt(attribute, pending_invoke_ids, timeout)
|
|
204
|
+
except CommunicationError as error:
|
|
205
|
+
if primary_error is None:
|
|
206
|
+
primary_error = error
|
|
207
|
+
self._raise_get_error(error, primary_error, attempt, effective_retries)
|
|
208
|
+
|
|
209
|
+
async def disconnect(self) -> None:
|
|
210
|
+
"""Gracefully release HDLC connection and close serial/socket transport."""
|
|
211
|
+
try:
|
|
212
|
+
if self._writer is not None and self._session.state in (
|
|
213
|
+
SessionState.CONNECTED,
|
|
214
|
+
SessionState.ASSOCIATED,
|
|
215
|
+
):
|
|
216
|
+
self._session.initiate_disconnect()
|
|
217
|
+
await self._flush_outbound()
|
|
218
|
+
with contextlib.suppress(Exception):
|
|
219
|
+
await self._wait_for_event(DisconnectedEvent, timeout=min(self.timeout, 2.0))
|
|
220
|
+
finally:
|
|
221
|
+
if self._writer is not None:
|
|
222
|
+
with contextlib.suppress(Exception):
|
|
223
|
+
self._writer.close()
|
|
224
|
+
await self._writer.wait_closed()
|
|
225
|
+
self._writer = None
|
|
226
|
+
self._reader = None
|
|
227
|
+
self._event_queue.clear()
|
|
228
|
+
|
|
229
|
+
async def _flush_outbound(self) -> None:
|
|
230
|
+
"""Transmit any pending bytes from the session outbound buffer."""
|
|
231
|
+
outbound_bytes = self._session.data_to_send()
|
|
232
|
+
if outbound_bytes and self._writer is not None:
|
|
233
|
+
self._writer.write(outbound_bytes)
|
|
234
|
+
await self._writer.drain()
|
|
235
|
+
|
|
236
|
+
def _extract_matching_event(
|
|
237
|
+
self,
|
|
238
|
+
expected_types: tuple[type[SessionEvent], ...],
|
|
239
|
+
predicate: Callable[[SessionEvent], bool] | None,
|
|
240
|
+
) -> SessionEvent | None:
|
|
241
|
+
"""Scan event queue for an expected event matching predicate or abort on disconnect."""
|
|
242
|
+
index = 0
|
|
243
|
+
while index < len(self._event_queue):
|
|
244
|
+
event = self._event_queue[index]
|
|
245
|
+
if isinstance(event, DisconnectedEvent) and DisconnectedEvent not in expected_types:
|
|
246
|
+
self._event_queue.pop(index)
|
|
247
|
+
message = "Connection disconnected by meter"
|
|
248
|
+
raise CommunicationError(message)
|
|
249
|
+
if isinstance(event, expected_types):
|
|
250
|
+
if predicate is None or predicate(event):
|
|
251
|
+
return self._event_queue.pop(index)
|
|
252
|
+
self._event_queue.pop(index)
|
|
253
|
+
continue
|
|
254
|
+
index += 1
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
async def _wait_for_event(
|
|
258
|
+
self,
|
|
259
|
+
*expected_types: type[SessionEvent],
|
|
260
|
+
timeout: float | None = None,
|
|
261
|
+
predicate: Callable[[SessionEvent], bool] | None = None,
|
|
262
|
+
) -> SessionEvent:
|
|
263
|
+
"""Read incoming serial bytes until an expected session event arrives."""
|
|
264
|
+
cached_event = self._extract_matching_event(expected_types, predicate)
|
|
265
|
+
if cached_event is not None:
|
|
266
|
+
return cached_event
|
|
267
|
+
|
|
268
|
+
if self._reader is None:
|
|
269
|
+
message = "Reader is not initialized"
|
|
270
|
+
raise CommunicationError(message)
|
|
271
|
+
|
|
272
|
+
effective_timeout = timeout if timeout is not None else self.timeout
|
|
273
|
+
try:
|
|
274
|
+
async with asyncio.timeout(effective_timeout):
|
|
275
|
+
while True:
|
|
276
|
+
data = await self._reader.read(1024)
|
|
277
|
+
if not data:
|
|
278
|
+
message = "Connection closed by peer / EOF encountered"
|
|
279
|
+
raise CommunicationError(message)
|
|
280
|
+
|
|
281
|
+
self._event_queue.extend(self._session.receive_bytes(data))
|
|
282
|
+
matching = self._extract_matching_event(expected_types, predicate)
|
|
283
|
+
if matching is not None:
|
|
284
|
+
return matching
|
|
285
|
+
except TimeoutError as error:
|
|
286
|
+
message = f"Timeout ({effective_timeout}s) waiting for meter response"
|
|
287
|
+
raise CommunicationError(message) from error
|
|
288
|
+
|
|
289
|
+
async def __aenter__(self) -> Self:
|
|
290
|
+
await self.connect()
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
async def __aexit__(
|
|
294
|
+
self,
|
|
295
|
+
exception_type: type[BaseException] | None,
|
|
296
|
+
exception_value: BaseException | None,
|
|
297
|
+
traceback: Any,
|
|
298
|
+
) -> None:
|
|
299
|
+
await self.disconnect()
|