Skip to content

Commit cdafa0a

Browse files
authored
Small fixes (#110)
* Ensure system title in GGC APDU is in bytes * Fix issues #79 #93 #101 #105 * Fix license metadata in setup.py
1 parent db9a52a commit cdafa0a

15 files changed

Lines changed: 564 additions & 30 deletions

HISTORY.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,33 @@ 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`.
31+
* Corrected package metadata license in `setup.py` to match the Business Source
32+
License 1.1 used by the project.
2333

2434
### Security
2535

36+
## 25.1.0 - 2025-03-26
37+
38+
### Added
39+
* `use_rlrq_rlre` added to DlmsConnectionSettings with default to `True`. If `False` no ReleaseRequest is sent to
40+
server/device and lower layer can be disconnected right away.
41+
42+
### Fixed
43+
* Missing imports in io.py
2644

2745
## 24.1.0 - 2024-01-22
2846

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.

devscripts/read_data_into_csv.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import csv
2+
import logging
3+
from datetime import datetime, timedelta, timezone
4+
from pprint import pprint
5+
from time import sleep
6+
7+
from dateutil import parser as dateparser
8+
9+
from dlms_cosem.utils import parse_as_dlms_data
10+
from dlms_cosem import a_xdr, cosem, enumerations
11+
from dlms_cosem.security import (
12+
NoSecurityAuthentication,
13+
HighLevelSecurityGmacAuthentication,
14+
)
15+
from dlms_cosem.client import DlmsClient
16+
from dlms_cosem.io import BlockingTcpIO, TcpTransport
17+
from dlms_cosem.cosem import selective_access
18+
from dlms_cosem.cosem.selective_access import RangeDescriptor
19+
from dlms_cosem.parsers import ProfileGenericBufferParser
20+
from dlms_cosem.protocol.xdlms.conformance import Conformance
21+
22+
# set up logging so you get a bit nicer printout of what is happening.
23+
logging.basicConfig(
24+
level=logging.DEBUG,
25+
format="%(asctime)s,%(msecs)d : %(levelname)s : %(message)s",
26+
datefmt="%H:%M:%S",
27+
)
28+
29+
c = Conformance(
30+
general_protection=False,
31+
general_block_transfer=False,
32+
delta_value_encoding=False,
33+
attribute_0_supported_with_set=False,
34+
priority_management_supported=False,
35+
attribute_0_supported_with_get=False,
36+
block_transfer_with_get_or_read=True,
37+
block_transfer_with_set_or_write=False,
38+
block_transfer_with_action=True,
39+
multiple_references=True,
40+
data_notification=False,
41+
access=False,
42+
get=True,
43+
set=True,
44+
selective_access=True,
45+
event_notification=False,
46+
action=True,
47+
)
48+
49+
encryption_key = bytes.fromhex("990EB3136F283EDB44A79F15F0BFCC21")
50+
authentication_key = bytes.fromhex("EC29E2F4BD7D697394B190827CE3DD9A")
51+
auth = enumerations.AuthenticationMechanism.HLS_GMAC
52+
host = "100.119.108.3"
53+
port = 4059
54+
55+
56+
tcp_io = BlockingTcpIO(host=host, port=port)
57+
public_tcp_transport = TcpTransport(
58+
client_logical_address=16,
59+
server_logical_address=1,
60+
io=tcp_io,
61+
)
62+
public_client = DlmsClient(
63+
transport=public_tcp_transport, authentication=NoSecurityAuthentication()
64+
)
65+
66+
67+
with public_client.session() as client:
68+
69+
response_data = client.get(
70+
cosem.CosemAttribute(
71+
interface=enumerations.CosemInterface.DATA,
72+
instance=cosem.Obis(0, 0, 0x2B, 1, 0),
73+
attribute=2,
74+
)
75+
)
76+
data_decoder = a_xdr.AXdrDecoder(
77+
encoding_conf=a_xdr.EncodingConf(
78+
attributes=[a_xdr.Sequence(attribute_name="data")]
79+
)
80+
)
81+
invocation_counter = data_decoder.decode(response_data)["data"]
82+
print(f"meter_initial_invocation_counter = {invocation_counter}")
83+
84+
# we are not reusing the socket as of now. We just need to give the meter some time to
85+
# close the connection on its side
86+
sleep(2)
87+
88+
tcp_io = BlockingTcpIO(host=host, port=port)
89+
management_tcp_transport = TcpTransport(
90+
client_logical_address=1,
91+
server_logical_address=1,
92+
io=tcp_io,
93+
)
94+
95+
management_client = DlmsClient(
96+
transport=management_tcp_transport,
97+
authentication=HighLevelSecurityGmacAuthentication(challenge_length=32),
98+
encryption_key=encryption_key,
99+
authentication_key=authentication_key,
100+
client_initial_invocation_counter=invocation_counter + 1,
101+
)
102+
103+
104+
with management_client.session() as client:
105+
read_to = dateparser.parse("2024-08-15T00:00:00-01:00")
106+
read_from = read_to - timedelta(days=500)
107+
profile = client.get(
108+
cosem.CosemAttribute(
109+
interface=enumerations.CosemInterface.PROFILE_GENERIC,
110+
instance=cosem.Obis(1, 0, 99, 2, 0),
111+
attribute=2,
112+
),
113+
access_descriptor=RangeDescriptor(
114+
restricting_object=selective_access.CaptureObject(
115+
cosem_attribute=cosem.CosemAttribute(
116+
interface=enumerations.CosemInterface.CLOCK,
117+
instance=cosem.Obis.from_string("0.0.1.0.0.255"),
118+
attribute=2,
119+
),
120+
data_index=0,
121+
),
122+
from_value=read_from,
123+
to_value=read_to,
124+
),
125+
)
126+
127+
time_error = datetime.now(tz=timezone.utc) - read_to
128+
129+
130+
parser = ProfileGenericBufferParser(
131+
capture_objects=[
132+
cosem.CosemAttribute(
133+
interface=enumerations.CosemInterface.CLOCK,
134+
instance=cosem.Obis(0, 0, 1, 0, 0, 255),
135+
attribute=2,
136+
),
137+
cosem.CosemAttribute(
138+
interface=enumerations.CosemInterface.DATA,
139+
instance=cosem.Obis(0, 0, 96, 10, 1, 255),
140+
attribute=2,
141+
),
142+
cosem.CosemAttribute(
143+
interface=enumerations.CosemInterface.REGISTER,
144+
instance=cosem.Obis(1, 0, 1, 8, 0, 255),
145+
attribute=2,
146+
),
147+
cosem.CosemAttribute(
148+
interface=enumerations.CosemInterface.REGISTER,
149+
instance=cosem.Obis(1, 0, 2, 8, 0, 255),
150+
attribute=2,
151+
),
152+
],
153+
capture_period=60,
154+
)
155+
#result = parser.parse_bytes(profile)
156+
result=None
157+
pprint(profile)
158+
data = parse_as_dlms_data(profile)
159+
160+
161+
with open("data_output.csv", "w", newline="") as csvfile:
162+
writer = csv.DictWriter(csvfile, fieldnames=["value"])
163+
writer.writeheader()
164+
for row in data:
165+
writer.writerow({"value": row[2]})
166+

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(

0 commit comments

Comments
 (0)