aioads 0.1.0.dev5__tar.gz → 0.1.0.dev7__tar.gz
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.
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/PKG-INFO +1 -1
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_sum_read.py +21 -16
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_sum_read_write.py +15 -13
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_datatype_by_name.py +8 -3
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_datatype_upload.py +4 -1
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_info_by_name_ex.py +30 -12
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_table_version.py +6 -5
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_upload.py +10 -4
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_upload_info.py +10 -8
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/transport.py +52 -2
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/pyproject.toml +1 -1
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_transport.py +69 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.github/dependabot.yml +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.github/workflows/ci.yml +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.github/workflows/publish.yml +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.gitignore +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/.vscode/settings.json +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/LICENSE +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/README.md +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_client.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_error_codes.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_notifications.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_symbol_cache.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ads_symbol_parser.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ams_address.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ams_header.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ams_service_port.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_add_notification.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_command.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_read.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_read_state.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_read_write.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_write.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/ads_write_state.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/commands/errors.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/errors.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_enable_route.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/functions/ads_function.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/py.typed +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/stream.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/aioads/utils/local_ip.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/docs/transmission_mode.md +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/docs/unittest_style_guide.html +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_cmd_reuse_mqtt.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_cycles.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_cycles_mqtt.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_multiple.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_multiple_mqtt.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/examples/read_single.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/pdm.lock +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/__init__.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/builders.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/README.md +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/__init__.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/base.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/config.example.toml +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/config.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/test_connection.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/test_performance.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/integration/test_read_symbols.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/__init__.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/__init__.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_add_notification.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_command.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_delete_notification.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_device_info.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_state.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_write.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_write.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_write_state.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/commands/test_errors.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/__init__.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_enable_route.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_function.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_sum_read.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_sum_read_write.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_client.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_error_codes.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_notifications.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_symbol_cache.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ads_symbol_parser.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ams_address.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ams_header.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_ams_tcp_header.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_errors.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/test_stream.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/utils/__init__.py +0 -0
- {aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/utils/test_local_ip.py +0 -0
|
@@ -5,13 +5,13 @@ sending multiple `AdsReadCommand` in a single command
|
|
|
5
5
|
https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_adsdll2/124830987.html&id=
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
|
|
9
8
|
from struct import Struct
|
|
10
9
|
from typing import Final
|
|
11
10
|
from aioads.ads_error_codes import AdsErrorCode
|
|
12
11
|
from aioads.ams_address import AmsAddress
|
|
13
12
|
from aioads.commands.ads_read import AdsReadCommand, AdsReadResponse
|
|
14
13
|
from aioads.commands.ads_read_write import AdsReadWriteCommand
|
|
14
|
+
from aioads.commands.errors import AdsCommandError
|
|
15
15
|
from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
|
|
16
16
|
from aioads.stream import AdsStream
|
|
17
17
|
from aioads.transport import ITransport
|
|
@@ -19,7 +19,7 @@ from aioads.transport import ITransport
|
|
|
19
19
|
|
|
20
20
|
class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
|
|
21
21
|
"""
|
|
22
|
-
Ads sum read command that allows batching up to 500 `AdsReadCommand` in a single call
|
|
22
|
+
Ads sum read command that allows batching up to 500 `AdsReadCommand` in a single call
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
ERROR_CODE_STRUCT_DEF: Final[Struct] = Struct("<I")
|
|
@@ -36,17 +36,15 @@ class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
|
|
|
36
36
|
|
|
37
37
|
def serialize(self) -> bytes:
|
|
38
38
|
"""
|
|
39
|
-
serialize all commands to bytes
|
|
39
|
+
serialize all commands to bytes
|
|
40
40
|
"""
|
|
41
41
|
return b"".join(command.serialize() for command in self.commands)
|
|
42
42
|
|
|
43
43
|
async def execute(self) -> list[tuple[AdsReadResponse, AdsStream]]:
|
|
44
44
|
if len(self.commands) == 0:
|
|
45
|
-
raise ValueError(
|
|
46
|
-
"At least one command is required for ADS Sum Read")
|
|
45
|
+
raise ValueError("At least one command is required for ADS Sum Read")
|
|
47
46
|
if len(self.commands) > 500:
|
|
48
|
-
raise ValueError(
|
|
49
|
-
"Too many commands for ADS Sum Read, maximum is 500")
|
|
47
|
+
raise ValueError("Too many commands for ADS Sum Read, maximum is 500")
|
|
50
48
|
|
|
51
49
|
payload = self.serialize()
|
|
52
50
|
command = AdsReadWriteCommand(
|
|
@@ -60,20 +58,27 @@ class AdsSumRead(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
|
|
|
60
58
|
write_length=len(payload),
|
|
61
59
|
write_data=payload,
|
|
62
60
|
)
|
|
63
|
-
|
|
61
|
+
header, read_payload = await command.request()
|
|
62
|
+
if not header.error_code.ok:
|
|
63
|
+
raise AdsCommandError(header.error_code, "Failed to execute ADS Sum Read")
|
|
64
64
|
error_stream = read_payload.sub_stream(
|
|
65
65
|
# 4 bytes per error code
|
|
66
|
-
len(self.commands)
|
|
66
|
+
len(self.commands)
|
|
67
|
+
* self.ERROR_CODE_STRUCT_DEF.size
|
|
68
|
+
)
|
|
67
69
|
|
|
68
70
|
response: list[tuple[AdsReadResponse, AdsStream]] = []
|
|
69
71
|
for cmd in self.commands:
|
|
70
72
|
error_code = AdsErrorCode(
|
|
71
|
-
error_stream.read_struct(self.ERROR_CODE_STRUCT_DEF)[0]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
error_stream.read_struct(self.ERROR_CODE_STRUCT_DEF)[0]
|
|
74
|
+
)
|
|
75
|
+
response.append(
|
|
76
|
+
(
|
|
77
|
+
AdsReadResponse(
|
|
78
|
+
error_code=error_code,
|
|
79
|
+
length=cmd.length,
|
|
80
|
+
),
|
|
81
|
+
read_payload.sub_stream(cmd.length),
|
|
82
|
+
)
|
|
78
83
|
)
|
|
79
84
|
return response
|
|
@@ -49,17 +49,17 @@ packet
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
"""
|
|
52
|
+
|
|
52
53
|
from aioads.ams_address import AmsAddress
|
|
53
54
|
from aioads.commands.ads_read import AdsReadResponse
|
|
54
55
|
from aioads.commands.ads_read_write import AdsReadWriteCommand
|
|
56
|
+
from aioads.commands.errors import AdsCommandError
|
|
55
57
|
from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
|
|
56
58
|
from aioads.stream import AdsStream
|
|
57
59
|
from aioads.transport import ITransport
|
|
58
60
|
|
|
59
61
|
|
|
60
|
-
class AdsSumReadWrite(
|
|
61
|
-
IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]
|
|
62
|
-
):
|
|
62
|
+
class AdsSumReadWrite(IAdsFunction[list[tuple[AdsReadResponse, AdsStream]]]):
|
|
63
63
|
"""
|
|
64
64
|
ADS Sum Read/Write function to send a READ/WRITE Command in batch.
|
|
65
65
|
This function allows multiple read/write commands to be sent in a single request,
|
|
@@ -92,11 +92,9 @@ class AdsSumReadWrite(
|
|
|
92
92
|
:return: An async generator yielding tuples of (AdsReadResponse, AdsStream) for each command.
|
|
93
93
|
"""
|
|
94
94
|
if len(self.commands) == 0:
|
|
95
|
-
raise ValueError(
|
|
96
|
-
"At least one command is required for ADS Sum Read/Write")
|
|
95
|
+
raise ValueError("At least one command is required for ADS Sum Read/Write")
|
|
97
96
|
if len(self.commands) > 500:
|
|
98
|
-
raise ValueError(
|
|
99
|
-
"Too many commands for ADS Sum Read/Write, maximum is 500")
|
|
97
|
+
raise ValueError("Too many commands for ADS Sum Read/Write, maximum is 500")
|
|
100
98
|
|
|
101
99
|
payload = self.serialize()
|
|
102
100
|
total_read_length = sum(
|
|
@@ -117,15 +115,19 @@ class AdsSumReadWrite(
|
|
|
117
115
|
write_length=total_write_length,
|
|
118
116
|
write_data=payload,
|
|
119
117
|
)
|
|
120
|
-
|
|
118
|
+
header, read_payload = await command.request()
|
|
119
|
+
if not header.error_code.ok:
|
|
120
|
+
raise AdsCommandError(
|
|
121
|
+
header.error_code, "Failed to execute ADS Sum Read/Write"
|
|
122
|
+
)
|
|
121
123
|
read_response_stream = read_payload.sub_stream(
|
|
122
|
-
len(self.commands) * AdsReadResponse.STRUCT_DEF.size
|
|
124
|
+
len(self.commands) * AdsReadResponse.STRUCT_DEF.size
|
|
125
|
+
)
|
|
123
126
|
|
|
124
127
|
response: list[tuple[AdsReadResponse, AdsStream]] = []
|
|
125
128
|
for _ in self.commands:
|
|
126
129
|
read_response = AdsReadResponse.deserialize(read_response_stream)
|
|
127
|
-
response.append(
|
|
128
|
-
read_response,
|
|
129
|
-
|
|
130
|
-
))
|
|
130
|
+
response.append(
|
|
131
|
+
(read_response, read_payload.sub_stream(read_response.length))
|
|
132
|
+
)
|
|
131
133
|
return response
|
|
@@ -36,11 +36,13 @@ packet
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
"""
|
|
39
|
+
|
|
39
40
|
from dataclasses import dataclass
|
|
40
41
|
from struct import Struct
|
|
41
42
|
from typing import ClassVar
|
|
42
43
|
from aioads.ams_address import AmsAddress
|
|
43
44
|
from aioads.commands.ads_read_write import AdsReadWriteCommand
|
|
45
|
+
from aioads.commands.errors import AdsCommandError
|
|
44
46
|
from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
|
|
45
47
|
from aioads.functions.ads_symbol_info_by_name_ex import (
|
|
46
48
|
AdsSymbolDataType,
|
|
@@ -75,8 +77,7 @@ class AdsDatatypeArrayInfo:
|
|
|
75
77
|
"""
|
|
76
78
|
Create `AdsDatatypeArrayInfo` from the `AdsStream`
|
|
77
79
|
"""
|
|
78
|
-
l_bound, e_elements = data.read_struct(
|
|
79
|
-
AdsDatatypeArrayInfo.STRUCT_DEF)
|
|
80
|
+
l_bound, e_elements = data.read_struct(AdsDatatypeArrayInfo.STRUCT_DEF)
|
|
80
81
|
return AdsDatatypeArrayInfo(
|
|
81
82
|
l_bound=l_bound,
|
|
82
83
|
e_elements=e_elements,
|
|
@@ -227,6 +228,10 @@ class AdsSymbolDataTypeByName(IAdsFunction[SymbolDataTypeResponse]):
|
|
|
227
228
|
write_length=len(payload),
|
|
228
229
|
read_length=0xFFFF, # Max read length
|
|
229
230
|
)
|
|
230
|
-
|
|
231
|
+
header, read_payload = await command.request()
|
|
232
|
+
if not header.error_code.ok:
|
|
233
|
+
raise AdsCommandError(
|
|
234
|
+
header.error_code, "Failed to read symbol datatype by name"
|
|
235
|
+
)
|
|
231
236
|
symbol_datatype = SymbolDataTypeResponse.deserialize(read_payload)
|
|
232
237
|
return symbol_datatype
|
|
@@ -6,6 +6,7 @@ https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_adsdll2/124
|
|
|
6
6
|
|
|
7
7
|
from aioads.ams_address import AmsAddress
|
|
8
8
|
from aioads.commands.ads_read import AdsReadCommand
|
|
9
|
+
from aioads.commands.errors import AdsCommandError
|
|
9
10
|
from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
|
|
10
11
|
from aioads.functions.ads_symbol_datatype_by_name import SymbolDataTypeResponse
|
|
11
12
|
from aioads.transport import ITransport
|
|
@@ -38,7 +39,9 @@ class AdsSymbolDataTypeUpload(IAdsFunction[list[SymbolDataTypeResponse]]):
|
|
|
38
39
|
idx_offset=0,
|
|
39
40
|
length=self.dt_size,
|
|
40
41
|
)
|
|
41
|
-
|
|
42
|
+
header, read_payload = await command.request()
|
|
43
|
+
if not header.error_code.ok:
|
|
44
|
+
raise AdsCommandError(header.error_code, "Failed to read datatype info")
|
|
42
45
|
|
|
43
46
|
symbol_datatypes = list[SymbolDataTypeResponse]()
|
|
44
47
|
start = read_payload.tell()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
This module provides models and ads function to get the symbol info (symbol metadata) by the variable / symbol name.
|
|
2
|
+
This module provides models and ads function to get the symbol info (symbol metadata) by the variable / symbol name.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
@@ -9,6 +9,7 @@ from typing import ClassVar
|
|
|
9
9
|
from aioads.ads_error_codes import AdsErrorCode
|
|
10
10
|
from aioads.ams_address import AmsAddress
|
|
11
11
|
from aioads.commands.ads_read_write import AdsReadWriteCommand
|
|
12
|
+
from aioads.commands.errors import AdsCommandError
|
|
12
13
|
from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
|
|
13
14
|
from aioads.functions.ads_sum_read_write import AdsSumReadWrite
|
|
14
15
|
from aioads.stream import AdsStream
|
|
@@ -113,10 +114,8 @@ class SymbolInfo:
|
|
|
113
114
|
type_name_length,
|
|
114
115
|
comment_length,
|
|
115
116
|
) = data.read_struct(SymbolInfo.FIXED_STRUCT)
|
|
116
|
-
symbol_name = data.read(symbol_name_length + 1).rstrip(
|
|
117
|
-
|
|
118
|
-
type_name = data.read(type_name_length + 1).rstrip(
|
|
119
|
-
b"\x00").decode("cp1252")
|
|
117
|
+
symbol_name = data.read(symbol_name_length + 1).rstrip(b"\x00").decode("cp1252")
|
|
118
|
+
type_name = data.read(type_name_length + 1).rstrip(b"\x00").decode("cp1252")
|
|
120
119
|
comment = data.read(comment_length + 1).rstrip(b"\x00").decode("cp1252")
|
|
121
120
|
|
|
122
121
|
# Move the stream position to the end of the entry
|
|
@@ -133,6 +132,20 @@ class SymbolInfo:
|
|
|
133
132
|
comment=comment,
|
|
134
133
|
)
|
|
135
134
|
|
|
135
|
+
@classmethod
|
|
136
|
+
def void(cls) -> "SymbolInfo":
|
|
137
|
+
"""A void symbol info that can be used as a placeholder"""
|
|
138
|
+
return cls(
|
|
139
|
+
idx_group=0,
|
|
140
|
+
idx_offset=0,
|
|
141
|
+
idx_length=0,
|
|
142
|
+
data_type=AdsSymbolDataType.VOID,
|
|
143
|
+
symbol_flags=AdsSymbolFlags(0),
|
|
144
|
+
symbol_name="",
|
|
145
|
+
type_name="",
|
|
146
|
+
comment="",
|
|
147
|
+
)
|
|
148
|
+
|
|
136
149
|
|
|
137
150
|
class SymbolInfoByNameEx(IAdsFunction[SymbolInfo]):
|
|
138
151
|
"""
|
|
@@ -165,14 +178,14 @@ class SymbolInfoByNameEx(IAdsFunction[SymbolInfo]):
|
|
|
165
178
|
read_length=0xFFFF, # Max read length
|
|
166
179
|
write_data=payload,
|
|
167
180
|
)
|
|
168
|
-
|
|
181
|
+
header, read_payload = await command.request()
|
|
182
|
+
if not header.error_code.ok:
|
|
183
|
+
raise AdsCommandError(header.error_code, "Failed to read symbol info")
|
|
169
184
|
symbol_info = SymbolInfo.deserialize(read_payload)
|
|
170
185
|
return symbol_info
|
|
171
186
|
|
|
172
187
|
|
|
173
|
-
class SymbolInfoByNameExSumRead(
|
|
174
|
-
IAdsFunction[list[tuple[AdsErrorCode, SymbolInfo]]]
|
|
175
|
-
):
|
|
188
|
+
class SymbolInfoByNameExSumRead(IAdsFunction[list[tuple[AdsErrorCode, SymbolInfo]]]):
|
|
176
189
|
"""
|
|
177
190
|
This function utilizes the `AdsSumReadWrite` function to
|
|
178
191
|
send multiple `SymbolInfoByNameEx` function calls in a batch.
|
|
@@ -219,9 +232,14 @@ class SymbolInfoByNameExSumRead(
|
|
|
219
232
|
command = AdsSumReadWrite(
|
|
220
233
|
transport=self.transport,
|
|
221
234
|
ams_address=self.ams_address,
|
|
222
|
-
commands=commands[batch_start:batch_start + self.BATCH_SIZE],
|
|
235
|
+
commands=commands[batch_start : batch_start + self.BATCH_SIZE],
|
|
223
236
|
)
|
|
224
|
-
for
|
|
237
|
+
for header, read_payload in await command.execute():
|
|
238
|
+
# If we have an error and no payload we return a void symbol info
|
|
239
|
+
# we raise no exception to let the client handle the error for each symbol individually
|
|
240
|
+
if not header.error_code.ok and read_payload.length == 0:
|
|
241
|
+
response.append((header.error_code, SymbolInfo.void()))
|
|
242
|
+
continue
|
|
225
243
|
symbol_info = SymbolInfo.deserialize(read_payload)
|
|
226
|
-
response.append((
|
|
244
|
+
response.append((header.error_code, symbol_info))
|
|
227
245
|
return response
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
This module contains the ads function call for requesting the `SymbolTableVersion`.
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
This can be used to monitoring, on change the index offsets in symbol metadata can change
|
|
5
|
+
This can be used to monitoring, on change the index offsets in symbol metadata can change
|
|
6
6
|
and we need to re resolve the new address where we can read this symbol, if we don't do this
|
|
7
7
|
we read a random memory array we most likely can't parse.
|
|
8
8
|
https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_adsdll2/124830987.html&id=
|
|
@@ -42,8 +42,9 @@ class SymbolTableVersion(IAdsFunction[int]):
|
|
|
42
42
|
idx_offset=0,
|
|
43
43
|
length=1,
|
|
44
44
|
)
|
|
45
|
-
|
|
46
|
-
if not
|
|
47
|
-
raise AdsCommandError(
|
|
48
|
-
|
|
45
|
+
header, read_stream = await command.request()
|
|
46
|
+
if not header.error_code.ok:
|
|
47
|
+
raise AdsCommandError(
|
|
48
|
+
header.error_code, "Failed to read symbol table version"
|
|
49
|
+
)
|
|
49
50
|
return read_stream.read_struct(self.VERSION_STRUCT_DEF)[0]
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""
|
|
2
|
-
This module contains a ads function to acquire the symbol infos (metadata) of the global / root symbols.
|
|
2
|
+
This module contains a ads function to acquire the symbol infos (metadata) of the global / root symbols.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from aioads.ams_address import AmsAddress
|
|
6
6
|
from aioads.commands.ads_read import AdsReadCommand
|
|
7
|
+
from aioads.commands.errors import AdsCommandError
|
|
7
8
|
from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
|
|
8
9
|
from aioads.functions.ads_symbol_info_by_name_ex import SymbolInfo
|
|
9
10
|
from aioads.transport import ITransport
|
|
@@ -11,8 +12,8 @@ from aioads.transport import ITransport
|
|
|
11
12
|
|
|
12
13
|
class AdsSymbolUpload(IAdsFunction[list[SymbolInfo]]):
|
|
13
14
|
"""
|
|
14
|
-
Ads Function to read all global symbols.
|
|
15
|
-
Hint:
|
|
15
|
+
Ads Function to read all global symbols.
|
|
16
|
+
Hint:
|
|
16
17
|
Only the top most symbol information are returned, to get the full ads tree
|
|
17
18
|
you need to resolve it from this over the datatypes that can be requested with `AdsSymbolDataTypeUpload`
|
|
18
19
|
"""
|
|
@@ -36,7 +37,12 @@ class AdsSymbolUpload(IAdsFunction[list[SymbolInfo]]):
|
|
|
36
37
|
idx_offset=0,
|
|
37
38
|
length=self.tree_size,
|
|
38
39
|
)
|
|
39
|
-
|
|
40
|
+
header, read_payload = await command.request()
|
|
41
|
+
if not header.error_code.ok:
|
|
42
|
+
raise AdsCommandError(
|
|
43
|
+
header.error_code, "Failed to request symbol upload info"
|
|
44
|
+
)
|
|
45
|
+
|
|
40
46
|
symbol_infos = list[SymbolInfo]()
|
|
41
47
|
|
|
42
48
|
start = read_payload.tell()
|
|
@@ -10,6 +10,7 @@ from typing import ClassVar
|
|
|
10
10
|
|
|
11
11
|
from aioads.ams_address import AmsAddress
|
|
12
12
|
from aioads.commands.ads_read import AdsReadCommand
|
|
13
|
+
from aioads.commands.errors import AdsCommandError
|
|
13
14
|
from aioads.functions.ads_function import AdsFunctionSymbolGroup, IAdsFunction
|
|
14
15
|
|
|
15
16
|
from aioads.stream import AdsStream
|
|
@@ -46,11 +47,9 @@ class SymbolUploadInfo2Response:
|
|
|
46
47
|
@classmethod
|
|
47
48
|
def deserialize(cls, data: AdsStream) -> "SymbolUploadInfo2Response":
|
|
48
49
|
"""
|
|
49
|
-
Create `SymbolUploadInfo2Response` from the `AdsStream`
|
|
50
|
+
Create `SymbolUploadInfo2Response` from the `AdsStream`
|
|
50
51
|
"""
|
|
51
|
-
parsed: tuple[
|
|
52
|
-
int, int, int, int, int, int
|
|
53
|
-
] = data.read_struct(cls.STRUCT_DEF)
|
|
52
|
+
parsed: tuple[int, int, int, int, int, int] = data.read_struct(cls.STRUCT_DEF)
|
|
54
53
|
return cls(
|
|
55
54
|
symbol_cnt=parsed[0],
|
|
56
55
|
symbol_size=parsed[1],
|
|
@@ -63,7 +62,7 @@ class SymbolUploadInfo2Response:
|
|
|
63
62
|
|
|
64
63
|
class AdsSymbolUploadInfo2(IAdsFunction[SymbolUploadInfo2Response]):
|
|
65
64
|
"""
|
|
66
|
-
Function to acquire basic symbol information
|
|
65
|
+
Function to acquire basic symbol information
|
|
67
66
|
"""
|
|
68
67
|
|
|
69
68
|
def __init__(self, transport: ITransport, ams_address: AmsAddress) -> None:
|
|
@@ -83,7 +82,10 @@ class AdsSymbolUploadInfo2(IAdsFunction[SymbolUploadInfo2Response]):
|
|
|
83
82
|
# 6 x 4 bytes (We expect a struct with 6x UINT32)
|
|
84
83
|
length=SymbolUploadInfo2Response.STRUCT_DEF.size,
|
|
85
84
|
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
header, read_payload = await command.request()
|
|
86
|
+
if not header.error_code.ok:
|
|
87
|
+
raise AdsCommandError(
|
|
88
|
+
header.error_code, "Failed to request symbol upload info"
|
|
89
|
+
)
|
|
90
|
+
symbol_upload_info = SymbolUploadInfo2Response.deserialize(read_payload)
|
|
89
91
|
return symbol_upload_info
|
|
@@ -7,6 +7,7 @@ import enum
|
|
|
7
7
|
import logging
|
|
8
8
|
import random
|
|
9
9
|
import re
|
|
10
|
+
import socket
|
|
10
11
|
from abc import ABC
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from dataclasses import field
|
|
@@ -211,6 +212,22 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
211
212
|
RECONNECT_INITIAL_BACKOFF = 1.0
|
|
212
213
|
RECONNECT_MAX_BACKOFF = 30.0
|
|
213
214
|
|
|
215
|
+
# TCP keepalive / liveness tuning.
|
|
216
|
+
# Without these, a half-open connection (peer power-cut, cable pull, NAT
|
|
217
|
+
# idle-eviction) is never detected: no FIN/RST arrives, so the read loop
|
|
218
|
+
# blocks forever and the supervisor never reconnects. These force the kernel
|
|
219
|
+
# to probe an idle peer and to bound how long unacked writes wait, so a dead
|
|
220
|
+
# link surfaces as a socket error within ~KEEPALIVE_IDLE + KEEPALIVE_INTERVAL
|
|
221
|
+
# * KEEPALIVE_COUNT seconds.
|
|
222
|
+
KEEPALIVE_IDLE = 10
|
|
223
|
+
"""Seconds of idle before the kernel starts sending keepalive probes (Linux: TCP_KEEPIDLE)."""
|
|
224
|
+
KEEPALIVE_INTERVAL = 3
|
|
225
|
+
"""Seconds between keepalive probes (Linux: TCP_KEEPINTVL)."""
|
|
226
|
+
KEEPALIVE_COUNT = 3
|
|
227
|
+
"""Unanswered probes before the connection is considered dead (Linux: TCP_KEEPCNT)."""
|
|
228
|
+
USER_TIMEOUT_MS = 15_000
|
|
229
|
+
"""Milliseconds an unacked write may wait before the socket errors (Linux: TCP_USER_TIMEOUT)."""
|
|
230
|
+
|
|
214
231
|
def __init__(
|
|
215
232
|
self,
|
|
216
233
|
src_address: AmsAddress,
|
|
@@ -279,10 +296,43 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
279
296
|
reader, writer = await asyncio.wait_for(
|
|
280
297
|
asyncio.open_connection(self.ip, self.port), self.CONNECT_TIMEOUT
|
|
281
298
|
)
|
|
299
|
+
|
|
300
|
+
self._enable_keepalive(writer)
|
|
282
301
|
async with self._stream_lock:
|
|
283
302
|
self._stream = (reader, writer)
|
|
284
303
|
self._state = ConnectionState.CONNECTED
|
|
285
304
|
|
|
305
|
+
def _enable_keepalive(self, writer: asyncio.StreamWriter) -> None:
|
|
306
|
+
"""
|
|
307
|
+
Enable TCP keepalive so a half-open connection is detected
|
|
308
|
+
even when no FIN/RST is ever sent.
|
|
309
|
+
|
|
310
|
+
The per-option settings beyond SO_KEEPALIVE are platform specific; each
|
|
311
|
+
is applied only when the running platform exposes it, so this stays a
|
|
312
|
+
no-op on systems that lack the knob rather than raising.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
sock: socket.socket | None = writer.get_extra_info("socket")
|
|
316
|
+
if sock is None:
|
|
317
|
+
self.logger.warning(
|
|
318
|
+
"Failed to enable TCP keepalive: StreamWriter has no socket"
|
|
319
|
+
)
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
324
|
+
for opt_name, value in (
|
|
325
|
+
("TCP_KEEPIDLE", self.KEEPALIVE_IDLE),
|
|
326
|
+
("TCP_KEEPINTVL", self.KEEPALIVE_INTERVAL),
|
|
327
|
+
("TCP_KEEPCNT", self.KEEPALIVE_COUNT),
|
|
328
|
+
("TCP_USER_TIMEOUT", self.USER_TIMEOUT_MS),
|
|
329
|
+
):
|
|
330
|
+
opt = getattr(socket, opt_name, None)
|
|
331
|
+
if opt is not None:
|
|
332
|
+
sock.setsockopt(socket.IPPROTO_TCP, opt, value)
|
|
333
|
+
except OSError as e:
|
|
334
|
+
self.logger.warning("Failed to enable TCP keepalive: %s", e)
|
|
335
|
+
|
|
286
336
|
async def _teardown_stream(self, ex: BaseException):
|
|
287
337
|
"""
|
|
288
338
|
Fail all in-flight requests and close the current stream (if any).
|
|
@@ -364,7 +414,7 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
364
414
|
while self._state is not ConnectionState.CLOSED:
|
|
365
415
|
try:
|
|
366
416
|
await self._read_loop()
|
|
367
|
-
except asyncio.CancelledError:
|
|
417
|
+
except asyncio.CancelledError: # pylint: disable=try-except-raise
|
|
368
418
|
# Except everything... except a cancellation event
|
|
369
419
|
raise
|
|
370
420
|
except Exception as e:
|
|
@@ -382,7 +432,7 @@ class AdsTcpTransport(BaseTransport, ITransport):
|
|
|
382
432
|
self.logger.info("Reconnected to the remote")
|
|
383
433
|
backoff = self.RECONNECT_INITIAL_BACKOFF
|
|
384
434
|
break
|
|
385
|
-
except asyncio.CancelledError:
|
|
435
|
+
except asyncio.CancelledError: # pylint: disable=try-except-raise
|
|
386
436
|
raise
|
|
387
437
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
388
438
|
self.logger.info(
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import socket
|
|
5
6
|
import unittest
|
|
6
7
|
from unittest.mock import Mock
|
|
7
8
|
|
|
@@ -216,5 +217,73 @@ class TestAdsTcpTransport(unittest.IsolatedAsyncioTestCase):
|
|
|
216
217
|
self.assertIsInstance(ctx.exception.__cause__, asyncio.IncompleteReadError)
|
|
217
218
|
|
|
218
219
|
|
|
220
|
+
class TestAdsTcpTransportKeepalive(unittest.TestCase):
|
|
221
|
+
"""TCP keepalive / liveness configuration applied to the active socket."""
|
|
222
|
+
|
|
223
|
+
def setUp(self) -> None:
|
|
224
|
+
self.transport = AdsTcpTransport(
|
|
225
|
+
src_address=make_ams_address(), ip="127.0.0.1"
|
|
226
|
+
)
|
|
227
|
+
# A real AF_INET TCP socket whose options we can read back. It does not
|
|
228
|
+
# need to be connected to accept these socket options.
|
|
229
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
230
|
+
self.addCleanup(self.sock.close)
|
|
231
|
+
# A StreamWriter exposes its underlying socket via get_extra_info("socket").
|
|
232
|
+
self.writer = Mock()
|
|
233
|
+
self.writer.get_extra_info.return_value = self.sock
|
|
234
|
+
|
|
235
|
+
def test_enable_keepalive_turns_on_so_keepalive(self) -> None:
|
|
236
|
+
# Act
|
|
237
|
+
self.transport._enable_keepalive(self.writer)
|
|
238
|
+
|
|
239
|
+
# Assert
|
|
240
|
+
self.assertEqual(
|
|
241
|
+
self.sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE), 1
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
@unittest.skipUnless(
|
|
245
|
+
hasattr(socket, "TCP_KEEPIDLE"), "platform lacks TCP keepalive tuning"
|
|
246
|
+
)
|
|
247
|
+
def test_enable_keepalive_applies_configured_tuning_values(self) -> None:
|
|
248
|
+
# Act
|
|
249
|
+
self.transport._enable_keepalive(self.writer)
|
|
250
|
+
|
|
251
|
+
# Assert — each kernel knob reflects the transport's configured constant
|
|
252
|
+
expected = [
|
|
253
|
+
("TCP_KEEPIDLE", AdsTcpTransport.KEEPALIVE_IDLE),
|
|
254
|
+
("TCP_KEEPINTVL", AdsTcpTransport.KEEPALIVE_INTERVAL),
|
|
255
|
+
("TCP_KEEPCNT", AdsTcpTransport.KEEPALIVE_COUNT),
|
|
256
|
+
("TCP_USER_TIMEOUT", AdsTcpTransport.USER_TIMEOUT_MS),
|
|
257
|
+
]
|
|
258
|
+
for opt_name, value in expected:
|
|
259
|
+
opt = getattr(socket, opt_name, None)
|
|
260
|
+
if opt is None:
|
|
261
|
+
continue
|
|
262
|
+
with self.subTest(option=opt_name):
|
|
263
|
+
self.assertEqual(
|
|
264
|
+
self.sock.getsockopt(socket.IPPROTO_TCP, opt), value
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def test_enable_keepalive_without_socket_does_not_raise(self) -> None:
|
|
268
|
+
# Arrange — a StreamWriter without an underlying socket
|
|
269
|
+
self.writer.get_extra_info.return_value = None
|
|
270
|
+
|
|
271
|
+
# Act / Assert — keepalive is best-effort, so it is skipped quietly
|
|
272
|
+
try:
|
|
273
|
+
self.transport._enable_keepalive(self.writer)
|
|
274
|
+
except Exception as e: # pragma: no cover - failure path
|
|
275
|
+
self.fail(f"_enable_keepalive must not raise when no socket: {e}")
|
|
276
|
+
|
|
277
|
+
def test_enable_keepalive_on_closed_socket_does_not_raise(self) -> None:
|
|
278
|
+
# Arrange — a closed socket makes setsockopt fail with OSError
|
|
279
|
+
self.sock.close()
|
|
280
|
+
|
|
281
|
+
# Act / Assert — keepalive is best-effort, so the error is swallowed
|
|
282
|
+
try:
|
|
283
|
+
self.transport._enable_keepalive(self.writer)
|
|
284
|
+
except OSError:
|
|
285
|
+
self.fail("_enable_keepalive must not propagate socket errors")
|
|
286
|
+
|
|
287
|
+
|
|
219
288
|
if __name__ == "__main__":
|
|
220
289
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_datatype_by_name.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_datatype_upload.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py
RENAMED
|
File without changes
|
{aioads-0.1.0.dev5 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_table_version.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|