python-selve-new 2.5.11__tar.gz → 2.5.12__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 (111) hide show
  1. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/CHANGELOG.md +13 -0
  2. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/PKG-INFO +1 -1
  3. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/python_selve_new.egg-info/PKG-INFO +1 -1
  4. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/python_selve_new.egg-info/SOURCES.txt +1 -0
  5. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/__init__.py +78 -41
  6. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/_version.py +3 -3
  7. python_selve_new-2.5.12/tests/unit/test_perf_improvements.py +194 -0
  8. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_selve_advanced_coverage.py +1 -1
  9. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_selve_core_coverage.py +1 -1
  10. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_selve_edge_cases.py +1 -1
  11. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_selve_init_comprehensive.py +2 -2
  12. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_selve_init_simple.py +1 -1
  13. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.github/FUNDING.yml +0 -0
  14. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.github/architect.chatmode.md +0 -0
  15. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.github/ask.chatmode.md +0 -0
  16. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.github/code.chatmode.md +0 -0
  17. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.github/debug.chatmode.md +0 -0
  18. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.github/workflows/python-publish.yml +0 -0
  19. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.github/workflows/tests.yml +0 -0
  20. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.gitignore +0 -0
  21. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.idea/.gitignore +0 -0
  22. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  23. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.idea/misc.xml +0 -0
  24. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.idea/modules.xml +0 -0
  25. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.idea/python-selve-new.iml +0 -0
  26. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/.idea/vcs.xml +0 -0
  27. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/LICENSE +0 -0
  28. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/README.md +0 -0
  29. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/coverage_xdist_example.txt +0 -0
  30. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/debug_response.py +0 -0
  31. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/debug_test.bat +0 -0
  32. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/direct_hardware_test.bat +0 -0
  33. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/direct_hardware_test.py +0 -0
  34. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/generate_coverage.bat +0 -0
  35. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/package.sh +0 -0
  36. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/pyproject.toml +0 -0
  37. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/python_selve_new.egg-info/dependency_links.txt +0 -0
  38. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/python_selve_new.egg-info/requires.txt +0 -0
  39. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/python_selve_new.egg-info/top_level.txt +0 -0
  40. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/release.py +0 -0
  41. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/run_all_tests.bat +0 -0
  42. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/run_hardware_tests.bat +0 -0
  43. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/run_integration_tests.bat +0 -0
  44. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/run_mock_tests.bat +0 -0
  45. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/run_single_test.bat +0 -0
  46. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/run_tests.bat +0 -0
  47. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/__init__.py +0 -0
  48. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/command.py +0 -0
  49. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/device.py +0 -0
  50. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/event.py +0 -0
  51. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/firmware.py +0 -0
  52. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/group.py +0 -0
  53. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/iveo.py +0 -0
  54. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/param.py +0 -0
  55. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/senSim.py +0 -0
  56. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/sender.py +0 -0
  57. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/sensor.py +0 -0
  58. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/commands/service.py +0 -0
  59. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/device.py +0 -0
  60. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/gateway.py +0 -0
  61. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/group.py +0 -0
  62. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/iveo.py +0 -0
  63. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/senSim.py +0 -0
  64. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/sender.py +0 -0
  65. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/sensor.py +0 -0
  66. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/util/__init__.py +0 -0
  67. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/util/errors.py +0 -0
  68. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/util/protocol.py +0 -0
  69. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/selve/util/serial_transport.py +0 -0
  70. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/setup.cfg +0 -0
  71. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/setup.py +0 -0
  72. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/setup_and_test.bat +0 -0
  73. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/__init__.py +0 -0
  74. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/conftest.py +0 -0
  75. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/integration/README.md +0 -0
  76. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/integration/__init__.py +0 -0
  77. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/integration/conftest.py +0 -0
  78. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/integration/test_device_integration.py +0 -0
  79. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/integration/test_selve_gateway_integration.py +0 -0
  80. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/integration/test_selve_hardware.py +0 -0
  81. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/integration/test_selve_integration.py +0 -0
  82. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/test_import.py +0 -0
  83. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/test_replacement.py +0 -0
  84. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/__init__.py +0 -0
  85. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/mock_utils.py +0 -0
  86. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_command_coverage.py +0 -0
  87. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_commands.py +0 -0
  88. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_device.py +0 -0
  89. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_device_classes_coverage.py +0 -0
  90. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_device_commands_extended.py +0 -0
  91. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_gateway_configuration_issues.py +0 -0
  92. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_gateway_error_handling_fixed.py +0 -0
  93. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_group.py +0 -0
  94. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_group_commands.py +0 -0
  95. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_missing_components.py +0 -0
  96. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_mock_commands.py +0 -0
  97. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_mock_devices_and_groups.py +0 -0
  98. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_mock_sensors_and_senders.py +0 -0
  99. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_param_commands_extended.py +0 -0
  100. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_port_discovery.py +0 -0
  101. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_selve_gateway.py +0 -0
  102. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_selve_init_response_coverage.py +0 -0
  103. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_selve_main_class_extensive.py +0 -0
  104. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_sender_commands_extended.py +0 -0
  105. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_sensim_commands_extended.py +0 -0
  106. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_sensor_commands_extended.py +0 -0
  107. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_serial_transport.py +0 -0
  108. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_service_command_errors.py +0 -0
  109. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_service_commands.py +0 -0
  110. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_util.py +0 -0
  111. {python_selve_new-2.5.11 → python_selve_new-2.5.12}/tests/unit/test_utility_coverage.py +0 -0
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.5.12] - 2026-06-12
6
+
7
+ ### Changed
8
+ - **Response-paced command transmission**: the TX loop now waits for the previous command's response (with a 5s safety timeout) instead of sleeping a fixed 100ms after every serial write. Command round-trips drop from ~130ms to gateway speed (~30-40ms); full device discovery is roughly 3x faster. Verified against live gateway hardware.
9
+ - **Device-aware update callbacks**: `register_callback` callbacks may now accept the changed device as a single positional argument, so consumers can update only the affected entity. Parameterless callbacks keep working unchanged. Callbacks are now fired only when a device actually changed (from `addOrUpdateDevice`), no longer after every gateway response, and a failing callback no longer breaks the dispatch of the remaining ones.
10
+ - Hot-path logging now uses lazy `%s` formatting; the malformed-XML-header workaround only runs when the broken header is actually present.
11
+
12
+ ### Fixed
13
+ - **Gateway error responses stalled callers for 10s**: a fault reply from the gateway never resolved the pending command future, so `executeCommandSyncWithResponse` always ran into its full 10s timeout (and another 10s on retry). Error responses now resolve the waiting future with `False` immediately.
14
+ - `updateAllDevices()` crashed with `AttributeError`: it iterated dict keys (ints) instead of device objects.
15
+ - `processTeachResponse` was called without `await`, so teach/scan result processing (including the event callback and event queue delivery) never actually ran.
16
+ - Sender events were stored into the sensor device registry (`SelveTypes.SENSOR` instead of `SelveTypes.SENDER`), overwriting sensors that shared the same id.
17
+
5
18
  ## [2.5.0] - 2026-02-11
6
19
 
7
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-selve-new
3
- Version: 2.5.11
3
+ Version: 2.5.12
4
4
  Summary: Python library for interfacing with selve devices using the USB-RF controller. Written completely new.
5
5
  Home-page: https://github.com/Kannix2005/python-selve-new
6
6
  Author: Stefan Altheimer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-selve-new
3
- Version: 2.5.11
3
+ Version: 2.5.12
4
4
  Summary: Python library for interfacing with selve devices using the USB-RF controller. Written completely new.
5
5
  Home-page: https://github.com/Kannix2005/python-selve-new
6
6
  Author: Stefan Altheimer
@@ -89,6 +89,7 @@ tests/unit/test_mock_commands.py
89
89
  tests/unit/test_mock_devices_and_groups.py
90
90
  tests/unit/test_mock_sensors_and_senders.py
91
91
  tests/unit/test_param_commands_extended.py
92
+ tests/unit/test_perf_improvements.py
92
93
  tests/unit/test_port_discovery.py
93
94
  tests/unit/test_selve_advanced_coverage.py
94
95
  tests/unit/test_selve_core_coverage.py
@@ -12,6 +12,7 @@ except ImportError:
12
12
  __version__ = "unknown"
13
13
 
14
14
  import asyncio
15
+ import inspect
15
16
  import logging
16
17
  import time
17
18
  from collections import deque
@@ -60,7 +61,8 @@ class Selve:
60
61
 
61
62
  def __init__(self, port=None, discover=True, develop=False, logger=None, loop=None):
62
63
  # Gateway state
63
- self._callbacks = set()
64
+ # callback -> whether it accepts the changed device as argument
65
+ self._callbacks: dict = {}
64
66
  self._eventCallbacks = set()
65
67
  self.lastLogEvent = None
66
68
  self.state = None
@@ -301,6 +303,17 @@ class Selve:
301
303
  self._pending_futures.append(future)
302
304
 
303
305
  await self._sendCommandToGateway(command)
306
+
307
+ if future is not None:
308
+ # Pace transmissions by the response instead of a fixed
309
+ # delay: the gateway answers in order, so the next command
310
+ # may go out as soon as this one's response has arrived.
311
+ # A timeout keeps a lost response from stalling the queue.
312
+ await asyncio.wait({future}, timeout=5)
313
+ else:
314
+ # Fire-and-forget commands still produce a gateway reply;
315
+ # keep a small gap so the gateway is not flooded.
316
+ await asyncio.sleep(0.05)
304
317
  except asyncio.CancelledError:
305
318
  break
306
319
  except Exception as e:
@@ -325,17 +338,13 @@ class Selve:
325
338
  try:
326
339
  resp = await self.processResponse(msg)
327
340
  if isinstance(resp, ErrorResponse):
328
- resp = False
329
-
330
- if resp not in (False, True, None):
331
- while self._pending_futures:
332
- fut = self._pending_futures.popleft()
333
- if fut.cancelled():
334
- continue
335
- if not fut.done():
336
- fut.set_result(resp)
337
- break
338
- else:
341
+ # A fault is the gateway's reply to a command: resolve the
342
+ # waiting future with False right away instead of letting
343
+ # the caller run into its 10s timeout.
344
+ if not self._resolve_next_future(False):
345
+ self._LOGGER.debug("(Selve RX): error response without pending future -> %s", resp)
346
+ elif resp not in (False, True, None):
347
+ if not self._resolve_next_future(resp):
339
348
  self._LOGGER.debug("(Selve RX): response without pending future -> %s", resp)
340
349
  self.rxQ.task_done()
341
350
  except Exception as e:
@@ -346,6 +355,17 @@ class Selve:
346
355
  pass
347
356
 
348
357
 
358
+ def _resolve_next_future(self, result):
359
+ """Resolve the oldest pending command future with the given result."""
360
+ while self._pending_futures:
361
+ fut = self._pending_futures.popleft()
362
+ if fut.cancelled():
363
+ continue
364
+ if not fut.done():
365
+ fut.set_result(result)
366
+ return True
367
+ return False
368
+
349
369
  async def stopWorker(self):
350
370
  self._LOGGER.debug("Stopping worker")
351
371
  self._pauseWorker.set()
@@ -386,13 +406,37 @@ class Selve:
386
406
  return True
387
407
 
388
408
 
389
- def register_callback(self, callback: Callable[[], None]) -> None:
390
- """Register callback, called when Roller changes state."""
391
- self._callbacks.add(callback)
409
+ def register_callback(self, callback: Callable) -> None:
410
+ """Register callback, called when a device changes state.
392
411
 
393
- def remove_callback(self, callback: Callable[[], None]) -> None:
412
+ The callback may optionally accept the changed device as its single
413
+ positional argument; parameterless callbacks keep working and are
414
+ invoked without arguments.
415
+ """
416
+ accepts_device = True
417
+ try:
418
+ inspect.signature(callback).bind(None)
419
+ except TypeError:
420
+ accepts_device = False
421
+ except ValueError:
422
+ # Builtins without introspectable signature: assume no args
423
+ accepts_device = False
424
+ self._callbacks[callback] = accepts_device
425
+
426
+ def remove_callback(self, callback: Callable) -> None:
394
427
  """Remove previously registered callback."""
395
- self._callbacks.discard(callback)
428
+ self._callbacks.pop(callback, None)
429
+
430
+ def _fire_callbacks(self, device=None) -> None:
431
+ """Notify registered callbacks, passing the changed device if known."""
432
+ for callback, accepts_device in list(self._callbacks.items()):
433
+ try:
434
+ if accepts_device:
435
+ callback(device)
436
+ else:
437
+ callback()
438
+ except Exception:
439
+ self._LOGGER.exception("Error in update callback")
396
440
 
397
441
  def register_event_callback(self, callback: Callable[[], None]) -> None:
398
442
  """Register callback, called when other events take place."""
@@ -418,7 +462,7 @@ class Selve:
418
462
 
419
463
  async def _sendCommandToGateway(self, command: Command):
420
464
  commandstr = command.serializeToXML()
421
- self._LOGGER.debug('Gateway writing: ' + str(commandstr))
465
+ self._LOGGER.debug('Gateway writing: %s', commandstr)
422
466
  try:
423
467
  if self._transport is None:
424
468
  if self._port is None:
@@ -426,11 +470,9 @@ class Selve:
426
470
  await self._build_transport(self._port)
427
471
 
428
472
  await self._transport.write(commandstr)
429
- # small pause to give the gateway time to answer
430
- await asyncio.sleep(0.1)
431
473
 
432
474
  except (OSError, IOError) as se:
433
- self._LOGGER.info('Serial error, trying to reconnect once... ' + str(se))
475
+ self._LOGGER.info('Serial error, trying to reconnect once... %s', se)
434
476
  await self.recover()
435
477
 
436
478
  try:
@@ -438,13 +480,12 @@ class Selve:
438
480
  if self._transport is None and self._port is not None:
439
481
  await self._build_transport(self._port)
440
482
  await self._transport.write(commandstr)
441
- await asyncio.sleep(0.1)
442
-
483
+
443
484
  except Exception as e:
444
- self._LOGGER.error("error communicating: " + str(e) + " ; Please restart the integration!")
485
+ self._LOGGER.error("error communicating: %s ; Please restart the integration!", e)
445
486
 
446
487
  except Exception as e:
447
- self._LOGGER.error("error communicating: " + str(e) + " ; Please restart the integration!")
488
+ self._LOGGER.error("error communicating: %s ; Please restart the integration!", e)
448
489
 
449
490
  async def processResponse(self, xmlstr):
450
491
  """Processes an XML String into a response object. Returns False if something went wrong or the gateway returned an error."""
@@ -453,11 +494,13 @@ class Selve:
453
494
  # return the ready to eat response
454
495
 
455
496
  # The selve device sometimes answers a badformed header. This is a patch
456
- xmlstr = str(xmlstr).replace('<?xml version="1.0"? encoding="UTF-8">', '<?xml version="1.0" encoding="UTF-8"?>')
497
+ xmlstr = str(xmlstr)
498
+ if '<?xml version="1.0"? encoding="UTF-8">' in xmlstr:
499
+ xmlstr = xmlstr.replace('<?xml version="1.0"? encoding="UTF-8">', '<?xml version="1.0" encoding="UTF-8"?>')
457
500
  try:
458
501
  res = untangle.parse(xmlstr)
459
502
  except Exception as e:
460
- self._LOGGER.error("Error in XML: " + str(e) + " : " + xmlstr)
503
+ self._LOGGER.error("Error in XML: %s : %s", e, xmlstr)
461
504
  return False
462
505
  try:
463
506
  if not hasattr(res, 'methodResponse') and not hasattr(res, 'methodCall'):
@@ -492,16 +535,14 @@ class Selve:
492
535
  if isinstance(response, SenderTeachResultResponse) \
493
536
  or isinstance(response, SensorTeachResultResponse)\
494
537
  or isinstance(response, DeviceScanResultResponse):
495
- self.processTeachResponse(response)
538
+ await self.processTeachResponse(response)
496
539
  return True
497
540
 
498
- for callback in self._callbacks:
499
- callback()
500
541
  return response
501
542
 
502
543
 
503
544
  except Exception as e:
504
- self._LOGGER.error("Error in response processing: " + str(e) + " : " + xmlstr)
545
+ self._LOGGER.error("Error in response processing: %s : %s", e, xmlstr)
505
546
  return False
506
547
 
507
548
  def create_error(self, obj):
@@ -901,13 +942,13 @@ class Selve:
901
942
 
902
943
 
903
944
  async def updateAllDevices(self):
904
- for device in self.devices[SelveTypes.DEVICE.value]:
945
+ for device in list(self.devices[SelveTypes.DEVICE.value].values()):
905
946
  await self.updateCommeoDeviceValues(device.id)
906
- for sensor in self.devices[SelveTypes.SENSOR.value]:
947
+ for sensor in list(self.devices[SelveTypes.SENSOR.value].values()):
907
948
  await self.updateSensorValuesAsync(sensor.id)
908
- for senSim in self.devices[SelveTypes.SENSIM.value]:
949
+ for senSim in list(self.devices[SelveTypes.SENSIM.value].values()):
909
950
  await self.updateSenSimValuesAsync(senSim.id)
910
- for sender in self.devices[SelveTypes.SENDER.value]:
951
+ for sender in list(self.devices[SelveTypes.SENDER.value].values()):
911
952
  await self.updateSenderValuesAsync(sender.id)
912
953
 
913
954
 
@@ -917,8 +958,7 @@ class Selve:
917
958
  # add in gateway
918
959
 
919
960
  # if there is a callback for updates, call it
920
- for callback in self._callbacks:
921
- callback()
961
+ self._fire_callbacks(device)
922
962
 
923
963
  def getDevice(self, id: int, type: SelveTypes) -> SelveDevice | SelveSensor | SelveSender | SelveGroup | SelveSenSim | None:
924
964
  if id in self.devices[type.value]:
@@ -1057,7 +1097,7 @@ class Selve:
1057
1097
 
1058
1098
  sender.lastEvent = response.event
1059
1099
  sender.name = response.senderName
1060
- self.addOrUpdateDevice(sender, SelveTypes.SENSOR)
1100
+ self.addOrUpdateDevice(sender, SelveTypes.SENDER)
1061
1101
 
1062
1102
  if isinstance(response, LogEventResponse):
1063
1103
  self.lastLogEvent = response
@@ -1118,9 +1158,6 @@ class Selve:
1118
1158
  dev.unreachable = True
1119
1159
  self.addOrUpdateDevice(dev, SelveTypes.DEVICE)
1120
1160
 
1121
- for callback in self._callbacks:
1122
- callback()
1123
-
1124
1161
 
1125
1162
  ### Service
1126
1163
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.5.11'
22
- __version_tuple__ = version_tuple = (2, 5, 11)
21
+ __version__ = version = '2.5.12'
22
+ __version_tuple__ = version_tuple = (2, 5, 12)
23
23
 
24
- __commit_id__ = commit_id = 'g91d08a72a'
24
+ __commit_id__ = commit_id = 'g62288d04e'
@@ -0,0 +1,194 @@
1
+ """
2
+ Tests for the performance improvements:
3
+ - response-paced TX loop (no fixed 100ms delay)
4
+ - ErrorResponse resolves the pending future immediately
5
+ - callbacks receive the changed device (with zero-arg compatibility)
6
+ - updateAllDevices iterates device objects, not ids
7
+ """
8
+ import asyncio
9
+ import time
10
+ from unittest.mock import Mock, AsyncMock
11
+
12
+ import pytest
13
+
14
+ from selve import Selve
15
+ from selve.commands.service import ServicePing
16
+ from selve.util.errors import ErrorResponse
17
+ from selve.util.protocol import SelveTypes
18
+
19
+
20
+ def _make_selve():
21
+ return Selve(port=None, discover=False, develop=False, logger=Mock())
22
+
23
+
24
+ class TestResolveNextFuture:
25
+ def test_resolves_oldest_pending_future(self):
26
+ selve = _make_selve()
27
+ loop = asyncio.new_event_loop()
28
+ try:
29
+ fut1 = loop.create_future()
30
+ fut2 = loop.create_future()
31
+ selve._pending_futures.append(fut1)
32
+ selve._pending_futures.append(fut2)
33
+
34
+ assert selve._resolve_next_future("result") is True
35
+ assert fut1.result() == "result"
36
+ assert not fut2.done()
37
+ finally:
38
+ loop.close()
39
+
40
+ def test_skips_cancelled_futures(self):
41
+ selve = _make_selve()
42
+ loop = asyncio.new_event_loop()
43
+ try:
44
+ fut1 = loop.create_future()
45
+ fut1.cancel()
46
+ fut2 = loop.create_future()
47
+ selve._pending_futures.append(fut1)
48
+ selve._pending_futures.append(fut2)
49
+
50
+ assert selve._resolve_next_future(False) is True
51
+ assert fut2.result() is False
52
+ finally:
53
+ loop.close()
54
+
55
+ def test_returns_false_without_pending_future(self):
56
+ selve = _make_selve()
57
+ assert selve._resolve_next_future("x") is False
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_error_response_resolves_future_immediately():
62
+ """A gateway fault must resolve the waiting command future with False
63
+ instead of letting the caller run into its 10s timeout."""
64
+ selve = _make_selve()
65
+ selve.rxQ = asyncio.Queue()
66
+
67
+ async def fake_process(msg):
68
+ return ErrorResponse("Method not supported", 2)
69
+
70
+ selve.processResponse = fake_process
71
+
72
+ future = asyncio.get_running_loop().create_future()
73
+ selve._pending_futures.append(future)
74
+ await selve.rxQ.put("<dummy/>")
75
+
76
+ task = asyncio.create_task(selve._dispatch_loop())
77
+ try:
78
+ for _ in range(100):
79
+ if future.done():
80
+ break
81
+ await asyncio.sleep(0.01)
82
+ assert future.done(), "future was not resolved by the error response"
83
+ assert future.result() is False
84
+ finally:
85
+ selve._stopThread.set()
86
+ task.cancel()
87
+ try:
88
+ await task
89
+ except asyncio.CancelledError:
90
+ pass
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_tx_loop_waits_for_response_before_next_command():
95
+ """The TX loop must not send the next command before the previous
96
+ command's response future is resolved."""
97
+ selve = _make_selve()
98
+ selve.txQ = asyncio.Queue()
99
+ send_times = []
100
+
101
+ async def fake_send(command):
102
+ send_times.append(time.monotonic())
103
+
104
+ selve._sendCommandToGateway = fake_send
105
+
106
+ loop = asyncio.get_running_loop()
107
+ fut1 = loop.create_future()
108
+ fut2 = loop.create_future()
109
+ await selve.txQ.put((ServicePing(), fut1))
110
+ await selve.txQ.put((ServicePing(), fut2))
111
+
112
+ task = asyncio.create_task(selve._tx_loop())
113
+ try:
114
+ await asyncio.sleep(0.15)
115
+ # only the first command may have been sent so far
116
+ assert len(send_times) == 1
117
+ fut1.set_result("pong")
118
+ await asyncio.sleep(0.1)
119
+ assert len(send_times) == 2
120
+ finally:
121
+ selve._stopThread.set()
122
+ task.cancel()
123
+ try:
124
+ await task
125
+ except asyncio.CancelledError:
126
+ pass
127
+
128
+
129
+ class TestDeviceCallbacks:
130
+ def test_callback_receives_device(self):
131
+ selve = _make_selve()
132
+ received = []
133
+ selve.register_callback(lambda device: received.append(device))
134
+
135
+ sentinel = object()
136
+ selve._fire_callbacks(sentinel)
137
+ assert received == [sentinel]
138
+
139
+ def test_zero_arg_callback_still_works(self):
140
+ selve = _make_selve()
141
+ calls = []
142
+
143
+ def legacy_callback():
144
+ calls.append(1)
145
+
146
+ selve.register_callback(legacy_callback)
147
+ selve._fire_callbacks(object())
148
+ assert calls == [1]
149
+
150
+ def test_failing_callback_does_not_break_others(self):
151
+ selve = _make_selve()
152
+ received = []
153
+
154
+ def bad_callback(device):
155
+ raise RuntimeError("boom")
156
+
157
+ selve.register_callback(bad_callback)
158
+ selve.register_callback(lambda device: received.append(device))
159
+ selve._fire_callbacks("dev")
160
+ assert received == ["dev"]
161
+
162
+ def test_remove_callback(self):
163
+ selve = _make_selve()
164
+ cb = lambda device: None
165
+ selve.register_callback(cb)
166
+ selve.remove_callback(cb)
167
+ assert cb not in selve._callbacks
168
+
169
+ def test_add_or_update_device_fires_callback_with_device(self):
170
+ selve = _make_selve()
171
+ received = []
172
+ selve.register_callback(lambda device: received.append(device))
173
+
174
+ dev = Mock()
175
+ dev.id = 7
176
+ selve.addOrUpdateDevice(dev, SelveTypes.DEVICE)
177
+ assert received == [dev]
178
+ assert selve.devices[SelveTypes.DEVICE.value][7] is dev
179
+
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_update_all_devices_iterates_device_objects():
183
+ selve = _make_selve()
184
+ dev = Mock()
185
+ dev.id = 3
186
+ selve.devices[SelveTypes.DEVICE.value][3] = dev
187
+
188
+ selve.updateCommeoDeviceValues = AsyncMock()
189
+ selve.updateSensorValuesAsync = AsyncMock()
190
+ selve.updateSenSimValuesAsync = AsyncMock()
191
+ selve.updateSenderValuesAsync = AsyncMock()
192
+
193
+ await selve.updateAllDevices()
194
+ selve.updateCommeoDeviceValues.assert_awaited_once_with(3)
@@ -58,7 +58,7 @@ class TestSelveAdvancedCoverage(unittest.TestCase):
58
58
  gateway = Selve(logger=self.mock_logger)
59
59
 
60
60
  # Test that error callbacks are properly initialized
61
- self.assertIsInstance(gateway._callbacks, set)
61
+ self.assertIsInstance(gateway._callbacks, dict)
62
62
  self.assertIsInstance(gateway._eventCallbacks, set)
63
63
 
64
64
  # Test device container structure
@@ -99,7 +99,7 @@ class TestSelveCoreClasses(unittest.TestCase):
99
99
  self.assertEqual(len(gateway._callbacks), 0)
100
100
  self.assertEqual(len(gateway._eventCallbacks), 0)
101
101
 
102
- self.assertIsInstance(gateway._callbacks, set)
102
+ self.assertIsInstance(gateway._callbacks, dict)
103
103
  self.assertIsInstance(gateway._eventCallbacks, set)
104
104
 
105
105
  def test_state_properties(self):
@@ -176,7 +176,7 @@ class TestSelveEdgeCases:
176
176
  # Check data structures - txQ might be None initially
177
177
  assert selve.txQ is None or isinstance(selve.txQ, list)
178
178
  assert isinstance(selve.devices, dict)
179
- assert isinstance(selve._callbacks, set)
179
+ assert isinstance(selve._callbacks, dict)
180
180
  assert isinstance(selve._eventCallbacks, set)
181
181
 
182
182
  def test_serial_attribute_handling(self):
@@ -47,7 +47,7 @@ class TestSelveInit:
47
47
  assert selve._port == "COM5"
48
48
  assert selve._LOGGER == logger
49
49
  assert selve.loop == loop
50
- assert isinstance(selve._callbacks, set)
50
+ assert isinstance(selve._callbacks, dict)
51
51
  assert isinstance(selve._eventCallbacks, set)
52
52
  assert selve.utilization == 0
53
53
  assert selve.sendingBlocked == DutyMode.NOT_BLOCKED
@@ -63,7 +63,7 @@ class TestSelveInit:
63
63
  import logging
64
64
  assert isinstance(selve._LOGGER, logging.Logger)
65
65
  assert selve.loop is None
66
- assert isinstance(selve._callbacks, set)
66
+ assert isinstance(selve._callbacks, dict)
67
67
  assert isinstance(selve._eventCallbacks, set)
68
68
 
69
69
  def test_init_devices_structure(self):
@@ -18,7 +18,7 @@ class TestSelveInitSimple:
18
18
  """Test basic Selve initialization"""
19
19
  selve = Selve(port=None, discover=False, develop=False, logger=Mock())
20
20
  assert selve._port is None
21
- assert selve._callbacks == set()
21
+ assert selve._callbacks == {}
22
22
  assert selve._eventCallbacks == set()
23
23
  assert selve.utilization == 0
24
24
  assert len(selve.devices) == 6