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 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()