aioads 0.1.0.dev6__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.
Files changed (98) hide show
  1. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/PKG-INFO +1 -1
  2. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/transport.py +52 -2
  3. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/pyproject.toml +1 -1
  4. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_transport.py +69 -0
  5. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/.github/dependabot.yml +0 -0
  6. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/.github/workflows/ci.yml +0 -0
  7. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/.github/workflows/publish.yml +0 -0
  8. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/.gitignore +0 -0
  9. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/.vscode/settings.json +0 -0
  10. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/LICENSE +0 -0
  11. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/README.md +0 -0
  12. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ads_client.py +0 -0
  13. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ads_error_codes.py +0 -0
  14. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ads_notifications.py +0 -0
  15. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ads_symbol_cache.py +0 -0
  16. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ads_symbol_parser.py +0 -0
  17. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ams_address.py +0 -0
  18. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ams_header.py +0 -0
  19. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ams_service_port.py +0 -0
  20. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/ams_tcp_header.py +0 -0
  21. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_add_notification.py +0 -0
  22. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_command.py +0 -0
  23. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_delete_notification.py +0 -0
  24. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_read.py +0 -0
  25. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_read_device_info.py +0 -0
  26. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_read_state.py +0 -0
  27. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_read_write.py +0 -0
  28. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_write.py +0 -0
  29. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/ads_write_state.py +0 -0
  30. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/commands/errors.py +0 -0
  31. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/errors.py +0 -0
  32. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_enable_route.py +0 -0
  33. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_function.py +0 -0
  34. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_sum_read.py +0 -0
  35. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_sum_read_write.py +0 -0
  36. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_datatype_by_name.py +0 -0
  37. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_datatype_upload.py +0 -0
  38. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_info_by_name_ex.py +0 -0
  39. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_table_version.py +0 -0
  40. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_upload.py +0 -0
  41. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/functions/ads_symbol_upload_info.py +0 -0
  42. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/py.typed +0 -0
  43. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/stream.py +0 -0
  44. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/aioads/utils/local_ip.py +0 -0
  45. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/docs/transmission_mode.md +0 -0
  46. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/docs/unittest_style_guide.html +0 -0
  47. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/examples/read_cmd_reuse_mqtt.py +0 -0
  48. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/examples/read_cycles.py +0 -0
  49. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/examples/read_cycles_mqtt.py +0 -0
  50. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/examples/read_multiple.py +0 -0
  51. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/examples/read_multiple_mqtt.py +0 -0
  52. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/examples/read_single.py +0 -0
  53. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/pdm.lock +0 -0
  54. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/__init__.py +0 -0
  55. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/builders.py +0 -0
  56. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/integration/README.md +0 -0
  57. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/integration/__init__.py +0 -0
  58. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/integration/base.py +0 -0
  59. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/integration/config.example.toml +0 -0
  60. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/integration/config.py +0 -0
  61. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/integration/test_connection.py +0 -0
  62. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/integration/test_performance.py +0 -0
  63. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/integration/test_read_symbols.py +0 -0
  64. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/__init__.py +0 -0
  65. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/__init__.py +0 -0
  66. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_add_notification.py +0 -0
  67. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_command.py +0 -0
  68. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_delete_notification.py +0 -0
  69. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read.py +0 -0
  70. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_device_info.py +0 -0
  71. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_state.py +0 -0
  72. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_read_write.py +0 -0
  73. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_write.py +0 -0
  74. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_ads_write_state.py +0 -0
  75. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/commands/test_errors.py +0 -0
  76. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/__init__.py +0 -0
  77. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_enable_route.py +0 -0
  78. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_function.py +0 -0
  79. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_sum_read.py +0 -0
  80. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_sum_read_write.py +0 -0
  81. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_datatype_by_name.py +0 -0
  82. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_datatype_upload.py +0 -0
  83. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_info_by_name_ex.py +0 -0
  84. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_table_version.py +0 -0
  85. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_upload.py +0 -0
  86. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/functions/test_ads_symbol_upload_info.py +0 -0
  87. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_ads_client.py +0 -0
  88. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_ads_error_codes.py +0 -0
  89. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_ads_notifications.py +0 -0
  90. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_ads_symbol_cache.py +0 -0
  91. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_ads_symbol_parser.py +0 -0
  92. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_ams_address.py +0 -0
  93. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_ams_header.py +0 -0
  94. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_ams_tcp_header.py +0 -0
  95. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_errors.py +0 -0
  96. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/test_stream.py +0 -0
  97. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/utils/__init__.py +0 -0
  98. {aioads-0.1.0.dev6 → aioads-0.1.0.dev7}/tests/unit/utils/test_local_ip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioads
3
- Version: 0.1.0.dev6
3
+ Version: 0.1.0.dev7
4
4
  Summary: An asynchronous Python library for communicating with Beckhoff TwinCAT PLCs
5
5
  Author-email: MkKiefer <102972583+MkKiefer@users.noreply.github.com>
6
6
  License: MIT
@@ -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: #pylint: disable=try-except-raise
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: #pylint: disable=try-except-raise
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(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioads"
3
- version = "0.1.0.dev6"
3
+ version = "0.1.0.dev7"
4
4
  description = "An asynchronous Python library for communicating with Beckhoff TwinCAT PLCs"
5
5
  authors = [
6
6
  { name = "MkKiefer", email = "102972583+MkKiefer@users.noreply.github.com" },
@@ -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