Skip to content

Commit 89ad93e

Browse files
committed
Fix issues #79 #93 #101 #105
1 parent ca82d63 commit 89ad93e

10 files changed

Lines changed: 374 additions & 25 deletions

File tree

HISTORY.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,20 @@ and this project adheres to [Calendar Versioning](https://calver.org/)
1414
layer can be disconnected right away.
1515

1616
### Changed
17+
* Renamed `TcpTransport` to `IPTransport` to reflect IP-wrapper semantics and kept
18+
`TcpTransport` as a backward-compatible alias.
1719

1820
### Deprecated
1921

2022
### Removed
2123

2224
### Fixed
25+
* Fixed RLRQ context handling so unciphered associations omit user information, and
26+
ciphered associations reuse the original proposed xDLMS context from AARQ.
27+
* Fixed HDLC transport timeout handling so repeated empty serial reads no longer loop
28+
forever and now raise a communication timeout error.
29+
* Implemented encoding and parsing rules for `DateData` and `TimeData`, including
30+
support for `datetime` inputs and all-ones wildcard values mapping to `None`.
2331

2432
### Security
2533

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,20 @@ A simple example of reading invocation counters using a public client:
4949

5050
```python
5151
from dlms_cosem.client import DlmsClient
52-
from dlms_cosem.io import TcpTransport, BlockingTcpIO
52+
from dlms_cosem.io import IPTransport, BlockingTcpIO
5353
from dlms_cosem.security import NoSecurityAuthentication
5454
from dlms_cosem import enumerations, cosem
5555

5656
tcp_io = BlockingTcpIO(host="localhost", port=4059)
57-
tcp_transport = TcpTransport(io=tcp_io, server_logical_address=1, client_logical_address=16)
58-
client = DlmsClient(transport=tcp_transport, authentication=NoSecurityAuthentication())
57+
ip_transport = IPTransport(io=tcp_io, server_logical_address=1, client_logical_address=16)
58+
client = DlmsClient(transport=ip_transport, authentication=NoSecurityAuthentication())
5959
with client.session() as dlms_client:
6060
data = dlms_client.get(
6161
cosem.CosemAttribute(interface=enumerations.CosemInterface.DATA,
6262
instance=cosem.Obis(0, 0, 0x2B, 1, 0), attribute=2, ))
6363
```
6464

65+
`TcpTransport` is kept as a backward-compatible alias of `IPTransport`.
6566

6667
Look at the different files in the `examples` folder get a better feel on how to fully
6768
use the library.

dlms_cosem/connection.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,13 @@ class DlmsConnection:
209209
)
210210
)
211211

212+
# Keep a copy of the context proposed in AARQ so the same context can be reused in
213+
# RLRQ when ciphering is enabled.
214+
proposed_initiate_request: Optional[xdlms.InitiateRequest] = attr.ib(
215+
default=None,
216+
init=False,
217+
)
218+
212219
settings: DlmsConnectionSettings = attr.ib(
213220
default=DlmsConnectionSettings(),
214221
converter=attr.converters.default_if_none(factory=DlmsConnectionSettings),
@@ -291,6 +298,7 @@ def send(self, event) -> bytes:
291298
)
292299

293300
self.state.process_event(event)
301+
self.register_proposed_context(event)
294302
LOG.debug(f"Preparing to send DLMS Request", request=event)
295303

296304
if self.use_protection:
@@ -398,6 +406,39 @@ def next_event(self):
398406
def clear_buffer(self):
399407
self.buffer = bytearray()
400408

409+
@staticmethod
410+
def copy_initiate_request(
411+
initiate_request: xdlms.InitiateRequest,
412+
) -> xdlms.InitiateRequest:
413+
return xdlms.InitiateRequest(
414+
proposed_conformance=Conformance(
415+
**attr.asdict(initiate_request.proposed_conformance)
416+
),
417+
proposed_quality_of_service=initiate_request.proposed_quality_of_service,
418+
client_max_receive_pdu_size=initiate_request.client_max_receive_pdu_size,
419+
proposed_dlms_version_number=initiate_request.proposed_dlms_version_number,
420+
response_allowed=initiate_request.response_allowed,
421+
dedicated_key=initiate_request.dedicated_key,
422+
)
423+
424+
def register_proposed_context(self, event: Any) -> None:
425+
"""
426+
Keep track of the context proposed in AARQ so it can be reused in RLRQ.
427+
"""
428+
if not isinstance(event, acse.ApplicationAssociationRequest):
429+
return
430+
431+
if not event.user_information:
432+
self.proposed_initiate_request = None
433+
return
434+
435+
if not isinstance(event.user_information.content, xdlms.InitiateRequest):
436+
return
437+
438+
self.proposed_initiate_request = self.copy_initiate_request(
439+
event.user_information.content
440+
)
441+
401442
@property
402443
def use_protection(self) -> bool:
403444
"""
@@ -587,19 +628,33 @@ def get_aarq(self) -> acse.ApplicationAssociationRequest:
587628
user_information=acse.UserInformation(content=initiate_request),
588629
)
589630

590-
def get_rlrq(self) -> acse.ReleaseRequest:
631+
def get_rlrq_initiate_request(self) -> xdlms.InitiateRequest:
591632
"""
592-
Returns a ReleaseRequestApdu to release the current association if one should be used.
633+
Return the original context proposed in AARQ when available.
593634
"""
594635

595-
initiate_request = xdlms.InitiateRequest(
636+
if self.proposed_initiate_request is not None:
637+
return self.copy_initiate_request(self.proposed_initiate_request)
638+
639+
return xdlms.InitiateRequest(
596640
proposed_conformance=self.conformance,
597641
client_max_receive_pdu_size=self.max_pdu_size,
598642
)
599643

644+
def get_rlrq(self) -> acse.ReleaseRequest:
645+
"""
646+
Returns a ReleaseRequestApdu to release the current association if one should be used.
647+
"""
648+
649+
user_information = None
650+
651+
if self.use_protection:
652+
initiate_request = self.get_rlrq_initiate_request()
653+
user_information = acse.UserInformation(content=initiate_request)
654+
600655
return acse.ReleaseRequest(
601656
reason=enums.ReleaseRequestReason.NORMAL,
602-
user_information=acse.UserInformation(content=initiate_request),
657+
user_information=user_information,
603658
)
604659

605660
def update_negotiated_parameters(

dlms_cosem/dlms_data.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,40 @@
1010
VARIABLE_LENGTH = -1
1111

1212

13+
def convert_datetime_to_date(
14+
in_value: Optional[Union[datetime.datetime, datetime.date]],
15+
) -> Optional[datetime.date]:
16+
if in_value is None:
17+
return None
18+
19+
if isinstance(in_value, datetime.datetime):
20+
return in_value.date()
21+
22+
if isinstance(in_value, datetime.date):
23+
return in_value
24+
25+
raise ValueError(
26+
f"DateData.value can only be datetime.date, datetime.datetime or None. Got {type(in_value)!r}"
27+
)
28+
29+
30+
def convert_datetime_to_time(
31+
in_value: Optional[Union[datetime.datetime, datetime.time]],
32+
) -> Optional[datetime.time]:
33+
if in_value is None:
34+
return None
35+
36+
if isinstance(in_value, datetime.datetime):
37+
return in_value.time()
38+
39+
if isinstance(in_value, datetime.time):
40+
return in_value
41+
42+
raise ValueError(
43+
f"TimeData.value can only be datetime.time, datetime.datetime or None. Got {type(in_value)!r}"
44+
)
45+
46+
1347
class AbstractDlmsData(abc.ABC):
1448
@classmethod
1549
@abc.abstractmethod
@@ -365,12 +399,26 @@ class DateData(BaseDlmsData):
365399
TAG = 26
366400
LENGTH = 5
367401

402+
value: Optional[datetime.date] = attr.ib(
403+
default=None, converter=convert_datetime_to_date
404+
)
405+
368406
@classmethod
369407
def from_bytes(cls, bytes_data: bytes):
370408
if len(bytes_data) != cls.LENGTH:
371409
raise ValueError(f"Date should be 5 bytes long, got {len(bytes_data)}")
410+
411+
if bytes_data == b"\xff\xff\xff\xff\xff":
412+
return cls(None)
413+
372414
return cls(time.date_from_bytes(bytes_data))
373415

416+
def value_to_bytes(self) -> bytes:
417+
if self.value is None:
418+
return b"\xff\xff\xff\xff\xff"
419+
420+
return time.date_to_bytes(self.value)
421+
374422

375423
@attr.s(auto_attribs=True)
376424
class TimeData(BaseDlmsData):
@@ -379,12 +427,26 @@ class TimeData(BaseDlmsData):
379427
TAG = 27
380428
LENGTH = 4
381429

430+
value: Optional[datetime.time] = attr.ib(
431+
default=None, converter=convert_datetime_to_time
432+
)
433+
382434
@classmethod
383435
def from_bytes(cls, bytes_data: bytes):
384436
if len(bytes_data) != cls.LENGTH:
385437
raise ValueError(f"Time should be 4 bytes long, got {len(bytes_data)}")
438+
439+
if bytes_data == b"\xff\xff\xff\xff":
440+
return cls(None)
441+
386442
return cls(time.time_from_bytes(bytes_data))
387443

444+
def value_to_bytes(self) -> bytes:
445+
if self.value is None:
446+
return b"\xff\xff\xff\xff"
447+
448+
return time.time_to_bytes(self.value)
449+
388450

389451
@attr.s(auto_attribs=True)
390452
class DontCareData(BaseDlmsData):

dlms_cosem/io.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import socket
44
import sys
5+
import time
56
from typing import Optional, Tuple
67

78
from dlms_cosem.hdlc import connection, address, state, frames
@@ -192,9 +193,11 @@ def recv_until(self, end: bytes) -> bytes:
192193

193194

194195
@attr.s(auto_attribs=True)
195-
class TcpTransport:
196+
class IPTransport:
196197
"""
197-
A TCP transport.
198+
A DLMS IP-wrapper transport.
199+
200+
Can be used over any IP-based IO implementation (for example TCP or UDP).
198201
"""
199202

200203
client_logical_address: int
@@ -245,6 +248,10 @@ def recv_response(self) -> bytes:
245248
return data
246249

247250

251+
# Backward compatible alias.
252+
TcpTransport = IPTransport
253+
254+
248255
@attr.s(auto_attribs=True)
249256
class HdlcTransport:
250257
"""
@@ -334,13 +341,24 @@ def next_event(self):
334341
:return:
335342
"""
336343

344+
timeout_at = time.monotonic() + self.timeout
345+
337346
while True:
338347
# If we already have a complete event buffered internally, just
339348
# return that. Otherwise, read some data, add it to the internal
340349
# buffer, and then try again.
341350
event = self.hdlc_connection.next_event()
342351
if event is state.NEED_DATA:
343-
self.hdlc_connection.receive_data(self.recv_frame())
352+
if time.monotonic() >= timeout_at:
353+
raise exceptions.CommunicationError(
354+
f"Timed out waiting for HDLC response after {self.timeout} seconds"
355+
)
356+
357+
frame_data = self.recv_frame()
358+
if not frame_data:
359+
continue
360+
361+
self.hdlc_connection.receive_data(frame_data)
344362
continue
345363
return event
346364

tests/test_blocking_tcp_transport.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from dlms_cosem.io import BlockingTcpIO, TcpTransport
5+
from dlms_cosem.io import BlockingTcpIO, IPTransport, TcpTransport
66
from dlms_cosem.exceptions import CommunicationError
77

88

@@ -18,7 +18,7 @@ def test_can_connect(self):
1818
server_socket.bind((self.host, self.port))
1919
server_socket.listen(1)
2020
io = BlockingTcpIO(host=self.host, port=self.port)
21-
transport = TcpTransport(
21+
transport = IPTransport(
2222
self.client_logical_address, self.server_logical_address, io
2323
)
2424
transport.connect()
@@ -30,7 +30,7 @@ def test_connect_on_connected_raises(self):
3030
server_socket.listen(1)
3131

3232
io = BlockingTcpIO(host=self.host, port=self.port)
33-
transport = TcpTransport(
33+
transport = IPTransport(
3434
self.client_logical_address, self.server_logical_address, io
3535
)
3636
transport.connect()
@@ -39,7 +39,7 @@ def test_connect_on_connected_raises(self):
3939

4040
def test_cant_connect_raises_communications_error(self):
4141
io = BlockingTcpIO(host=self.host, port=self.port)
42-
transport = TcpTransport(
42+
transport = IPTransport(
4343
self.client_logical_address, self.server_logical_address, io
4444
)
4545
with pytest.raises(CommunicationError):
@@ -51,7 +51,7 @@ def test_disconnect(self):
5151
server_socket.listen(1)
5252

5353
io = BlockingTcpIO(host=self.host, port=self.port)
54-
transport = TcpTransport(
54+
transport = IPTransport(
5555
self.client_logical_address, self.server_logical_address, io
5656
)
5757
transport.connect()
@@ -64,10 +64,14 @@ def test_disconnect_is_noop_if_disconnected(self):
6464
server_socket.listen(1)
6565

6666
io = BlockingTcpIO(host=self.host, port=self.port)
67-
transport = TcpTransport(
67+
transport = IPTransport(
6868
self.client_logical_address, self.server_logical_address, io
6969
)
7070
transport.connect()
7171
transport.disconnect()
7272
transport.disconnect()
7373
assert transport.io.tcp_socket is None
74+
75+
76+
def test_tcp_transport_is_kept_as_alias_for_backwards_compatibility():
77+
assert TcpTransport is IPTransport

0 commit comments

Comments
 (0)