nwp500-python 8.1.1__tar.gz → 8.1.3__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 (201) hide show
  1. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/CHANGELOG.rst +18 -1
  2. {nwp500_python-8.1.1/src/nwp500_python.egg-info → nwp500_python-8.1.3}/PKG-INFO +1 -3
  3. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/README.rst +0 -2
  4. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/client.py +37 -2
  5. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/periodic.py +0 -6
  6. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/reconnection.py +37 -4
  7. {nwp500_python-8.1.1 → nwp500_python-8.1.3/src/nwp500_python.egg-info}/PKG-INFO +1 -3
  8. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
  9. nwp500_python-8.1.3/tests/test_mqtt_clean_session_resume.py +190 -0
  10. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_mqtt_reconnection_storm.py +155 -9
  11. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.coveragerc +0 -0
  12. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  13. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.github/copilot-instructions.md +0 -0
  14. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.github/workflows/ci.yml +0 -0
  15. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.github/workflows/release.yml +0 -0
  16. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.gitignore +0 -0
  17. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.pre-commit-config.yaml +0 -0
  18. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/.readthedocs.yml +0 -0
  19. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/AUTHORS.rst +0 -0
  20. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/CONTRIBUTING.rst +0 -0
  21. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/LICENSE.txt +0 -0
  22. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/Makefile +0 -0
  23. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/RELEASE.md +0 -0
  24. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/Makefile +0 -0
  25. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/_static/.gitignore +0 -0
  26. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/conf.py +0 -0
  27. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/explanation/advanced-features.rst +0 -0
  28. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/explanation/architecture.rst +0 -0
  29. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/explanation/index.rst +0 -0
  30. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/authenticate.rst +0 -0
  31. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/auto-recovery.rst +0 -0
  32. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/diagnose-mqtt.rst +0 -0
  33. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/home-assistant.rst +0 -0
  34. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/index.rst +0 -0
  35. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/maintenance.rst +0 -0
  36. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/manage-units.rst +0 -0
  37. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/monitor-status.rst +0 -0
  38. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/optimize-tou.rst +0 -0
  39. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/queue-commands.rst +0 -0
  40. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/schedule-operation.rst +0 -0
  41. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/how-to/track-energy.rst +0 -0
  42. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/index.rst +0 -0
  43. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/openapi.yaml +0 -0
  44. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/authors.rst +0 -0
  45. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/changelog.rst +0 -0
  46. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/contributing.rst +0 -0
  47. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/history.rst +0 -0
  48. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/project/license.rst +0 -0
  49. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/configuration.rst +0 -0
  50. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/enumerations.rst +0 -0
  51. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/index.rst +0 -0
  52. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/installation.rst +0 -0
  53. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/data_conversions.rst +0 -0
  54. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/device_features.rst +0 -0
  55. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/device_status.rst +0 -0
  56. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/error_codes.rst +0 -0
  57. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  58. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/quick_reference.rst +0 -0
  59. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/protocol/rest_api.rst +0 -0
  60. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/api_client.rst +0 -0
  61. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/auth_client.rst +0 -0
  62. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/cli.rst +0 -0
  63. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/events.rst +0 -0
  64. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/exceptions.rst +0 -0
  65. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/models.rst +0 -0
  66. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/reference/python_api/mqtt_client.rst +0 -0
  67. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/requirements.txt +0 -0
  68. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/docs/tutorials/getting-started.rst +0 -0
  69. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/.ruff.toml +0 -0
  70. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/README.md +0 -0
  71. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/air_filter_reset.py +0 -0
  72. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/anti_legionella.py +0 -0
  73. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/auto_recovery.py +0 -0
  74. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/combined_callbacks.py +0 -0
  75. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/demand_response.py +0 -0
  76. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/device_capabilities.py +0 -0
  77. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/device_status_debug.py +0 -0
  78. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/energy_analytics.py +0 -0
  79. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/error_code_demo.py +0 -0
  80. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/firmware_payload_capture.py +0 -0
  81. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/mqtt_diagnostics.py +0 -0
  82. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/power_control.py +0 -0
  83. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/recirculation_control.py +0 -0
  84. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/reconnection_demo.py +0 -0
  85. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/reservation_schedule.py +0 -0
  86. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/simple_auto_recovery.py +0 -0
  87. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/token_restoration.py +0 -0
  88. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/tou_openei.py +0 -0
  89. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/tou_schedule.py +0 -0
  90. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/advanced/water_reservation.py +0 -0
  91. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/beginner/01_authentication.py +0 -0
  92. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/beginner/02_list_devices.py +0 -0
  93. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/beginner/03_get_status.py +0 -0
  94. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/beginner/04_set_temperature.py +0 -0
  95. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/advanced_auth_patterns.py +0 -0
  96. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/command_queue.py +0 -0
  97. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/device_status_callback.py +0 -0
  98. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/error_handling.py +0 -0
  99. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/event_driven_control.py +0 -0
  100. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/improved_auth.py +0 -0
  101. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/legacy_auth_constructor.py +0 -0
  102. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  103. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/periodic_requests.py +0 -0
  104. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/set_mode.py +0 -0
  105. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/intermediate/vacation_mode.py +0 -0
  106. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/mask.py +0 -0
  107. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/periodic_device_info.py +0 -0
  108. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/simple_periodic_info.py +0 -0
  109. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/test_api_client.py +0 -0
  110. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/test_mqtt_connection.py +0 -0
  111. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/test_mqtt_messaging.py +0 -0
  112. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/examples/testing/test_periodic_minimal.py +0 -0
  113. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/pyproject.toml +0 -0
  114. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/README.md +0 -0
  115. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/bump_version.py +0 -0
  116. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/diagnose_mqtt_connection.py +0 -0
  117. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/extract_changelog.py +0 -0
  118. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/format.py +0 -0
  119. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/lint.py +0 -0
  120. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/setup-dev.py +0 -0
  121. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/scripts/validate_version.py +0 -0
  122. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/setup.cfg +0 -0
  123. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/setup.py +0 -0
  124. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/__init__.py +0 -0
  125. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/_base.py +0 -0
  126. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/api_client.py +0 -0
  127. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/auth.py +0 -0
  128. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/__init__.py +0 -0
  129. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/__main__.py +0 -0
  130. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/commands.py +0 -0
  131. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/handlers.py +0 -0
  132. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/monitoring.py +0 -0
  133. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/output_formatters.py +0 -0
  134. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/rich_output.py +0 -0
  135. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/cli/token_storage.py +0 -0
  136. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/command_decorators.py +0 -0
  137. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/config.py +0 -0
  138. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/converters.py +0 -0
  139. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/device_capabilities.py +0 -0
  140. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/device_info_cache.py +0 -0
  141. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/encoding.py +0 -0
  142. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/enums.py +0 -0
  143. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/events.py +0 -0
  144. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/exceptions.py +0 -0
  145. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/factory.py +0 -0
  146. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/field_factory.py +0 -0
  147. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/__init__.py +0 -0
  148. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/_converters.py +0 -0
  149. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/device.py +0 -0
  150. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/energy.py +0 -0
  151. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/feature.py +0 -0
  152. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/mqtt_models.py +0 -0
  153. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/schedule.py +0 -0
  154. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/status.py +0 -0
  155. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/models/tou.py +0 -0
  156. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/__init__.py +0 -0
  157. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/command_queue.py +0 -0
  158. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/connection.py +0 -0
  159. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/control.py +0 -0
  160. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/diagnostics.py +0 -0
  161. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/state_tracker.py +0 -0
  162. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/subscriptions.py +0 -0
  163. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt/utils.py +0 -0
  164. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/mqtt_events.py +0 -0
  165. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/openei.py +0 -0
  166. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/py.typed +0 -0
  167. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/reservations.py +0 -0
  168. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/temperature.py +0 -0
  169. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/topic_builder.py +0 -0
  170. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/unit_system.py +0 -0
  171. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500/utils.py +0 -0
  172. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  173. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  174. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  175. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/requires.txt +0 -0
  176. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/top_level.txt +0 -0
  177. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/conftest.py +0 -0
  178. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_api_helpers.py +0 -0
  179. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_auth.py +0 -0
  180. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_bug_fixes.py +0 -0
  181. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_cli_basic.py +0 -0
  182. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_cli_commands.py +0 -0
  183. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_command_decorators.py +0 -0
  184. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_command_queue.py +0 -0
  185. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_device_capabilities.py +0 -0
  186. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_device_info_cache.py +0 -0
  187. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_events.py +0 -0
  188. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_exceptions.py +0 -0
  189. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_model_converters.py +0 -0
  190. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_models.py +0 -0
  191. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_mqtt_client_init.py +0 -0
  192. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_mqtt_hypothesis.py +0 -0
  193. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_mqtt_reconnection.py +0 -0
  194. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_multi_device.py +0 -0
  195. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_openei.py +0 -0
  196. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_reservations.py +0 -0
  197. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_temperature_converters.py +0 -0
  198. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_tou_api.py +0 -0
  199. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_unit_switching.py +0 -0
  200. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tests/test_utils.py +0 -0
  201. {nwp500_python-8.1.1 → nwp500_python-8.1.3}/tox.ini +0 -0
@@ -5,9 +5,26 @@ Changelog
5
5
  Unreleased
6
6
  ==========
7
7
 
8
- Version 8.1.1 (2026-05-18)
8
+ Version 8.1.3 (2026-06-15)
9
9
  ==========================
10
10
 
11
+ Bug Fixes
12
+ ---------
13
+ - **Fix MQTT reconnection storm caused by non-thread-safe Task.cancel()**: The
14
+ ``on_connection_resumed`` callback is invoked from an AWS IoT SDK background
15
+ thread. It was calling ``asyncio.Task.cancel()`` directly on that thread, which
16
+ is not thread-safe. When the event loop was busy at the moment of cancellation
17
+ (e.g. the sleeping task's timer callback had already been enqueued), the
18
+ cancellation was silently dropped. The stale ``_reconnect_with_backoff`` task
19
+ would then complete its sleep, call ``_reconnect_func``, and tear down an
20
+ otherwise healthy connection — restarting the entire disconnect → reconnect →
21
+ ``AWS_ERROR_MQTT_UNEXPECTED_HANGUP`` cycle. Fixed by replacing the direct
22
+ ``task.cancel()`` call with a ``_cancel_pending_reconnect()`` coroutine scheduled
23
+ via ``_schedule_coroutine``, so the cancellation runs on the event loop where
24
+ asyncio operations are safe. The method also uses an identity check before
25
+ clearing ``_reconnect_task`` to avoid wiping a newer task created during the
26
+ await, and clears stale references to already-done tasks.
27
+
11
28
  Version 8.1.0 (2026-05-16)
12
29
  ==========================
13
30
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 8.1.1
3
+ Version: 8.1.3
4
4
  Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
5
5
  Home-page: https://github.com/eman/nwp500-python
6
6
  Author: Emmanuel Levijarvi
@@ -89,8 +89,6 @@ Quick Example
89
89
  Documentation
90
90
  =============
91
91
 
92
- The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
93
-
94
92
  * `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
95
93
  * `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
96
94
  * `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
@@ -47,8 +47,6 @@ Quick Example
47
47
  Documentation
48
48
  =============
49
49
 
50
- The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
51
-
52
50
  * `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
53
51
  * `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
54
52
  * `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
@@ -364,8 +364,20 @@ class NavienMqttClient(EventEmitter):
364
364
  )
365
365
  )
366
366
 
367
- # Send any queued commands
368
- if self.config.enable_command_queue and self._command_queue:
367
+ # When the broker starts a clean session (session_present=False), all
368
+ # previous subscriptions have been dropped server-side. We must
369
+ # re-establish them before any device data can flow. This covers the
370
+ # common case where the AWS IoT SDK auto-reconnects internally before
371
+ # the MqttReconnectionHandler fires its own reconnect path — in that
372
+ # scenario the reconnect handler sees _connected==True and exits early,
373
+ # so resubscribe_all() would never be called without this block.
374
+ #
375
+ # When session_present=False, we must resubscribe before sending queued
376
+ # commands to ensure subscriptions are restored before device responses
377
+ # are processed. Use a composite coroutine to enforce ordering.
378
+ if not session_present and self._subscription_manager:
379
+ self._schedule_coroutine(self._handle_clean_session_resume())
380
+ elif self.config.enable_command_queue and self._command_queue:
369
381
  self._schedule_coroutine(self._send_queued_commands_internal())
370
382
 
371
383
  async def _send_queued_commands_internal(self) -> None:
@@ -377,6 +389,29 @@ class NavienMqttClient(EventEmitter):
377
389
  self._connection_manager.publish, lambda: self._connected
378
390
  )
379
391
 
392
+ async def _handle_clean_session_resume(self) -> None:
393
+ """
394
+ Handle clean session reconnection with ordered resubscription.
395
+
396
+ When session_present=False (clean session), the broker has dropped all
397
+ subscriptions. This method ensures subscriptions are restored BEFORE
398
+ sending any queued commands, preventing commands from being processed
399
+ before their subscriptions are re-established.
400
+ """
401
+ if not self._subscription_manager or not self._connection_manager:
402
+ return
403
+
404
+ if not self._connection_manager.connection:
405
+ return
406
+
407
+ self._subscription_manager.update_connection(
408
+ self._connection_manager.connection
409
+ )
410
+ await self._subscription_manager.resubscribe_all()
411
+
412
+ if self.config.enable_command_queue and self._command_queue:
413
+ await self._send_queued_commands_internal()
414
+
380
415
  async def _active_reconnect(self) -> None:
381
416
  """
382
417
  Actively trigger a reconnection attempt.
@@ -173,12 +173,6 @@ class MqttPeriodicRequestManager:
173
173
  await self._request_device_info(device)
174
174
  elif request_type == PeriodicRequestType.DEVICE_STATUS:
175
175
  await self._request_device_status(device)
176
- else:
177
- _logger.error(
178
- "Unknown periodic request type: %s",
179
- request_type,
180
- )
181
- break
182
176
 
183
177
  _logger.debug(
184
178
  "Sent periodic %s request for %s",
@@ -128,9 +128,42 @@ class MqttReconnectionHandler:
128
128
  # Reset reconnection attempts on successful connection
129
129
  self._reconnect_attempts = 0
130
130
 
131
- # Cancel any pending reconnection task
132
- if self._reconnect_task and not self._reconnect_task.done():
133
- self._reconnect_task.cancel()
131
+ # Schedule cancellation of any pending reconnect task on the event loop.
132
+ # This method is called from an AWS SDK background thread; asyncio's
133
+ # Task.cancel() is NOT thread-safe when invoked directly from a
134
+ # non-event-loop thread. If the event loop is busy (e.g. the sleeping
135
+ # task's timer callback was already queued) the cancellation can be
136
+ # silently dropped, leaving the stale _reconnect_with_backoff loop
137
+ # alive. That loop then completes its sleep and calls _reconnect_func,
138
+ # tearing down a perfectly healthy connection and restarting the
139
+ # disconnect/reconnect cycle.
140
+ self._schedule_coroutine(self._cancel_pending_reconnect())
141
+
142
+ async def _cancel_pending_reconnect(self) -> None:
143
+ """Cancel any pending reconnect task.
144
+
145
+ Must be called on the event loop (via _schedule_coroutine) so that
146
+ asyncio Task operations are thread-safe.
147
+
148
+ Uses an identity check before clearing _reconnect_task to avoid
149
+ accidentally wiping a new task that was created while the cancelled
150
+ task was being awaited. Also clears stale references to already-done
151
+ tasks so the handler never holds on to finished task objects.
152
+ """
153
+ task = self._reconnect_task
154
+ if task is None:
155
+ return
156
+ if task.done():
157
+ # Clear stale reference to an already-finished task.
158
+ if self._reconnect_task is task:
159
+ self._reconnect_task = None
160
+ return
161
+ task.cancel()
162
+ with contextlib.suppress(asyncio.CancelledError):
163
+ await task
164
+ # Only clear the reference if it still points to the same task.
165
+ # A new reconnect task may have been created while we were awaiting.
166
+ if self._reconnect_task is task:
134
167
  self._reconnect_task = None
135
168
 
136
169
  async def _start_reconnect_task(self) -> None:
@@ -142,7 +175,7 @@ class MqttReconnectionHandler:
142
175
 
143
176
  The is_connected guard is re-checked here because this coroutine may
144
177
  be queued via run_coroutine_threadsafe and run after the connection
145
- has already been restored (e.g. by on_connection_resumed cancelling
178
+ has already been restored (e.g. by _cancel_pending_reconnect clearing
146
179
  _reconnect_task), in which case starting a new backoff loop would
147
180
  incorrectly tear down a healthy connection.
148
181
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 8.1.1
3
+ Version: 8.1.3
4
4
  Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
5
5
  Home-page: https://github.com/eman/nwp500-python
6
6
  Author: Emmanuel Levijarvi
@@ -89,8 +89,6 @@ Quick Example
89
89
  Documentation
90
90
  =============
91
91
 
92
- The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
93
-
94
92
  * `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
95
93
  * `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
96
94
  * `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
@@ -186,6 +186,7 @@ tests/test_events.py
186
186
  tests/test_exceptions.py
187
187
  tests/test_model_converters.py
188
188
  tests/test_models.py
189
+ tests/test_mqtt_clean_session_resume.py
189
190
  tests/test_mqtt_client_init.py
190
191
  tests/test_mqtt_hypothesis.py
191
192
  tests/test_mqtt_reconnection.py
@@ -0,0 +1,190 @@
1
+ """Tests for MQTT client clean session reconnection handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock, MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from nwp500.auth import AuthenticationResponse, AuthTokens, UserInfo
10
+ from nwp500.mqtt import NavienMqttClient
11
+
12
+
13
+ @pytest.fixture
14
+ def auth_client_with_valid_tokens():
15
+ """Create an auth client with valid tokens."""
16
+ from nwp500.auth import NavienAuthClient
17
+
18
+ auth_client = NavienAuthClient("test@example.com", "password")
19
+ valid_tokens = AuthTokens(
20
+ id_token="test_id",
21
+ access_token="test_access",
22
+ refresh_token="test_refresh",
23
+ authentication_expires_in=3600,
24
+ access_key_id="test_key_id",
25
+ secret_key="test_secret_key",
26
+ session_token="test_session",
27
+ authorization_expires_in=3600,
28
+ )
29
+ auth_client._auth_response = AuthenticationResponse(
30
+ user_info=UserInfo(user_first_name="Test", user_last_name="User"),
31
+ tokens=valid_tokens,
32
+ )
33
+ return auth_client
34
+
35
+
36
+ class TestMqttCleanSessionResume:
37
+ """Tests for clean session (session_present=False) reconnection handling."""
38
+
39
+ @pytest.mark.asyncio(loop_scope="function")
40
+ async def test_on_connection_resumed_with_clean_session_resubscribes(
41
+ self, auth_client_with_valid_tokens
42
+ ):
43
+ """Resubscribe when session_present=False on connection resume."""
44
+ client = NavienMqttClient(auth_client_with_valid_tokens)
45
+
46
+ # Mock the components
47
+ mock_subscription_manager = AsyncMock()
48
+ mock_subscription_manager.resubscribe_all = AsyncMock()
49
+ client._subscription_manager = mock_subscription_manager
50
+
51
+ mock_connection_manager = MagicMock()
52
+ mock_connection = MagicMock()
53
+ mock_connection_manager.connection = mock_connection
54
+ client._connection_manager = mock_connection_manager
55
+
56
+ # Mock the event emitter and diagnostics
57
+ client.emit = AsyncMock()
58
+ client._diagnostics = MagicMock()
59
+ client._diagnostics.record_connection_success = AsyncMock()
60
+
61
+ # Call with session_present=False (clean session)
62
+ client._on_connection_resumed_internal(
63
+ connection=mock_connection, return_code=0, session_present=False
64
+ )
65
+
66
+ # Give the scheduled coroutine time to run
67
+ import asyncio
68
+
69
+ await asyncio.sleep(0.1)
70
+
71
+ # Verify resubscribe_all was called
72
+ mock_subscription_manager.update_connection.assert_called_once_with(
73
+ mock_connection
74
+ )
75
+ # The resubscribe should be scheduled via _schedule_coroutine
76
+ # We need to wait for it or check the internal state
77
+
78
+ @pytest.mark.asyncio(loop_scope="function")
79
+ async def test_resubscribe_before_queued_commands(
80
+ self, auth_client_with_valid_tokens
81
+ ):
82
+ """Resubscribe completes before queued commands are sent."""
83
+ client = NavienMqttClient(auth_client_with_valid_tokens)
84
+
85
+ # Track call order
86
+ call_order = []
87
+
88
+ # Mock the components
89
+ mock_subscription_manager = MagicMock()
90
+ mock_subscription_manager.resubscribe_all = AsyncMock(
91
+ side_effect=lambda: call_order.append("resubscribe")
92
+ )
93
+ client._subscription_manager = mock_subscription_manager
94
+
95
+ mock_connection_manager = MagicMock()
96
+ mock_connection = MagicMock()
97
+ mock_connection_manager.connection = mock_connection
98
+ client._connection_manager = mock_connection_manager
99
+
100
+ # Mock command queue
101
+ client._command_queue = AsyncMock()
102
+ client.config.enable_command_queue = True
103
+
104
+ # Mock send_queued_commands to track it's called after resubscribe
105
+ original_send = client._send_queued_commands_internal
106
+
107
+ async def mock_send():
108
+ call_order.append("send_queued")
109
+ await original_send()
110
+
111
+ client._send_queued_commands_internal = mock_send
112
+
113
+ # Call the method
114
+ await client._handle_clean_session_resume()
115
+
116
+ # Verify subscription manager was updated with connection
117
+ mock_subscription_manager.update_connection.assert_called_once_with(
118
+ mock_connection
119
+ )
120
+
121
+ # Verify resubscribe was called before queued commands
122
+ assert call_order == ["resubscribe", "send_queued"]
123
+
124
+ @pytest.mark.asyncio(loop_scope="function")
125
+ async def test_skip_when_no_subscription_manager(
126
+ self, auth_client_with_valid_tokens
127
+ ):
128
+ """Return early if subscription_manager is None."""
129
+ client = NavienMqttClient(auth_client_with_valid_tokens)
130
+ client._subscription_manager = None
131
+
132
+ # Should not raise
133
+ await client._handle_clean_session_resume()
134
+
135
+ @pytest.mark.asyncio(loop_scope="function")
136
+ async def test_handle_clean_session_resume_skips_when_no_connection(
137
+ self, auth_client_with_valid_tokens
138
+ ):
139
+ """Return early if connection is None."""
140
+ client = NavienMqttClient(auth_client_with_valid_tokens)
141
+
142
+ mock_subscription_manager = MagicMock()
143
+ client._subscription_manager = mock_subscription_manager
144
+
145
+ mock_connection_manager = MagicMock()
146
+ mock_connection_manager.connection = None
147
+ client._connection_manager = mock_connection_manager
148
+
149
+ # Should not raise
150
+ await client._handle_clean_session_resume()
151
+
152
+ # Should not try to update connection
153
+ mock_subscription_manager.update_connection.assert_not_called()
154
+
155
+ @pytest.mark.asyncio(loop_scope="function")
156
+ async def test_on_connection_resumed_with_session_sends_queued_commands(
157
+ self, auth_client_with_valid_tokens
158
+ ):
159
+ """Send queued commands normally when session_present=True."""
160
+ client = NavienMqttClient(auth_client_with_valid_tokens)
161
+
162
+ # Mock the components
163
+ mock_command_queue = AsyncMock()
164
+ client._command_queue = mock_command_queue
165
+ client.config.enable_command_queue = True
166
+
167
+ # Mock the event emitter and diagnostics
168
+ client.emit = AsyncMock()
169
+ client._diagnostics = MagicMock()
170
+ client._diagnostics.record_connection_success = AsyncMock()
171
+
172
+ # Mock connection
173
+ mock_connection = MagicMock()
174
+
175
+ # Patch _send_queued_commands_internal to track if called
176
+ with patch.object(
177
+ client, "_send_queued_commands_internal", new_callable=AsyncMock
178
+ ):
179
+ # Call with session_present=True (session resumed)
180
+ client._on_connection_resumed_internal(
181
+ connection=mock_connection, return_code=0, session_present=True
182
+ )
183
+
184
+ # Give the scheduled coroutine time to run
185
+ import asyncio
186
+
187
+ await asyncio.sleep(0.1)
188
+
189
+ # Verify send_queued_commands_internal was scheduled
190
+ # (it will be called through _schedule_coroutine)
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Tests for the MQTT reconnection storm fix.
3
3
 
4
- Two bugs were fixed:
4
+ Three bugs were fixed:
5
5
 
6
6
  Bug 1 — Stale interruption events fire after a resume clears _reconnect_task.
7
7
  The AWS SDK fires on_connection_interrupted callbacks from background threads
@@ -21,6 +21,18 @@ Bug 2 — Closing the old connection inside _active_reconnect / _deep_reconnect
21
21
  Fix: _actively_reconnecting flag suppresses the reconnection-handler
22
22
  delegation in _on_connection_interrupted_internal while the intentional
23
23
  teardown is in progress.
24
+
25
+ Bug 3 — on_connection_resumed calls Task.cancel() directly from an AWS SDK
26
+ background thread. asyncio.Task.cancel() is NOT thread-safe; when the event
27
+ loop is busy (e.g. the sleeping task's timer callback was already enqueued)
28
+ the cancellation can be silently dropped. The stale _reconnect_with_backoff
29
+ task then completes its sleep, calls _reconnect_func, and tears down an
30
+ otherwise healthy connection, restarting the entire
31
+ disconnect/reconnect cycle.
32
+
33
+ Fix: on_connection_resumed schedules _cancel_pending_reconnect() via
34
+ _schedule_coroutine so the cancellation runs on the event loop where asyncio
35
+ Task operations are safe.
24
36
  """
25
37
 
26
38
  from __future__ import annotations
@@ -177,10 +189,11 @@ class TestReconnectionHandlerIsConnectedGuard:
177
189
 
178
190
  1. Connection drops → on_connection_interrupted schedules
179
191
  _start_reconnect_task (coroutine A queued but not yet run).
180
- 2. Connection resumes → on_connection_resumed cancels task, sets
181
- _reconnect_task = None.
182
- 3. Coroutine A finally runs – without the fix it would see
183
- _reconnect_task=None and create a new backoff loop.
192
+ 2. Connection resumes → on_connection_resumed schedules
193
+ _cancel_pending_reconnect (no task to cancel yet since A hasn't run).
194
+ 3. Both queued coroutines finally run – without the is_connected guard
195
+ coroutine A would see _reconnect_task=None and create a new backoff
196
+ loop even though the client is now healthy.
184
197
  """
185
198
  state = {"connected": False}
186
199
  config = MqttConnectionConfig(auto_reconnect=True)
@@ -200,13 +213,16 @@ class TestReconnectionHandlerIsConnectedGuard:
200
213
  handler.on_connection_interrupted(Exception("dropped"))
201
214
  assert len(scheduled) == 1
202
215
 
203
- # Step 2: connection resumes before the scheduled coroutine runs
216
+ # Step 2: connection resumes before the scheduled coroutine runs.
217
+ # on_connection_resumed now schedules _cancel_pending_reconnect rather
218
+ # than calling task.cancel() directly (Bug 3 fix).
204
219
  state["connected"] = True
205
220
  handler.on_connection_resumed(return_code=0, session_present=False)
206
- assert handler._reconnect_task is None
221
+ assert handler._reconnect_task is None # no task was ever created
207
222
 
208
- # Step 3: the stale coroutine from step 1 runs now
209
- await scheduled[0]
223
+ # Step 3: run all scheduled coroutines
224
+ # (_start_reconnect_task + _cancel_pending_reconnect)
225
+ await asyncio.gather(*scheduled, return_exceptions=True)
210
226
 
211
227
  # With the fix, no new task must have been created
212
228
  assert handler._reconnect_task is None
@@ -232,6 +248,136 @@ class TestReconnectionHandlerIsConnectedGuard:
232
248
  await asyncio.gather(handler._reconnect_task, return_exceptions=True)
233
249
 
234
250
 
251
+ # ---------------------------------------------------------------------------
252
+ # Bug 3: on_connection_resumed must cancel via the event loop, not directly
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ class TestThreadSafeTaskCancellation:
257
+ """Bug 3 – on_connection_resumed must not call Task.cancel() from a thread.
258
+
259
+ asyncio.Task.cancel() is NOT thread-safe. When called from an AWS SDK
260
+ background thread, the cancellation can be silently dropped if the event
261
+ loop is busy (e.g. the sleep timer fires at the same moment). The stale
262
+ task then triggers a spurious reconnection.
263
+
264
+ Fix: on_connection_resumed schedules _cancel_pending_reconnect() via
265
+ _schedule_coroutine so the cancellation happens on the event loop.
266
+ """
267
+
268
+ @pytest.mark.asyncio(loop_scope="function")
269
+ async def test_on_connection_resumed_schedules_cancel_not_direct_call(
270
+ self,
271
+ ):
272
+ """
273
+ on_connection_resumed must schedule _cancel_pending_reconnect via
274
+ _schedule_coroutine rather than calling task.cancel() directly.
275
+ """
276
+ handler, scheduled = _make_handler(connected=False)
277
+
278
+ # Let a reconnect task start (sleeping in backoff)
279
+ handler.on_connection_interrupted(Exception("dropped"))
280
+ await asyncio.gather(*scheduled, return_exceptions=True)
281
+ scheduled.clear()
282
+
283
+ assert handler._reconnect_task is not None
284
+ assert not handler._reconnect_task.done()
285
+
286
+ # Simulate connection resuming from a background thread
287
+ handler.on_connection_resumed(return_code=0, session_present=True)
288
+
289
+ # _cancel_pending_reconnect must have been *scheduled*, not run yet
290
+ assert len(scheduled) == 1
291
+ # No direct Task.cancel() was called from the background thread:
292
+ # task.cancelling() == 0 proves no cancellation request is pending
293
+ # before the event loop runs _cancel_pending_reconnect.
294
+ assert handler._reconnect_task is not None
295
+ assert handler._reconnect_task.cancelling() == 0
296
+
297
+ # Now let the event loop process the cancellation
298
+ await asyncio.gather(*scheduled, return_exceptions=True)
299
+
300
+ # After the event-loop cancellation, the task must be cleared
301
+ assert handler._reconnect_task is None
302
+
303
+ @pytest.mark.asyncio(loop_scope="function")
304
+ async def test_cancel_pending_reconnect_is_idempotent_with_no_task(self):
305
+ """_cancel_pending_reconnect is a no-op when _reconnect_task is None."""
306
+ handler, _ = _make_handler(connected=True)
307
+ assert handler._reconnect_task is None
308
+
309
+ # Should not raise
310
+ await handler._cancel_pending_reconnect()
311
+
312
+ assert handler._reconnect_task is None
313
+
314
+ @pytest.mark.asyncio(loop_scope="function")
315
+ async def test_cancel_pending_reconnect_clears_completed_task(self):
316
+ """_cancel_pending_reconnect clears a stale reference to a done task."""
317
+ handler, scheduled = _make_handler(connected=False)
318
+
319
+ handler.on_connection_interrupted(Exception("dropped"))
320
+ await asyncio.gather(*scheduled, return_exceptions=True)
321
+ scheduled.clear()
322
+
323
+ task = handler._reconnect_task
324
+ assert task is not None
325
+
326
+ # Cancel and drain the task manually, leaving a stale reference
327
+ task.cancel()
328
+ await asyncio.gather(task, return_exceptions=True)
329
+ assert task.done()
330
+ assert handler._reconnect_task is task # stale reference still held
331
+
332
+ # _cancel_pending_reconnect must clear the stale reference
333
+ await handler._cancel_pending_reconnect()
334
+ assert handler._reconnect_task is None
335
+
336
+ @pytest.mark.asyncio(loop_scope="function")
337
+ async def test_resumed_then_interrupted_creates_new_task(self):
338
+ """
339
+ After resume cancels an existing task, a subsequent genuine drop must
340
+ still be able to start a fresh reconnect task.
341
+ """
342
+ state = {"connected": False}
343
+ config = MqttConnectionConfig(auto_reconnect=True)
344
+ scheduled = []
345
+
346
+ handler = MqttReconnectionHandler(
347
+ config=config,
348
+ is_connected_func=lambda: state["connected"],
349
+ schedule_coroutine_func=lambda coro: scheduled.append(
350
+ asyncio.ensure_future(coro)
351
+ ),
352
+ reconnect_func=AsyncMock(),
353
+ )
354
+ handler.enable()
355
+
356
+ # Connection drops → backoff task starts
357
+ handler.on_connection_interrupted(Exception("first drop"))
358
+ await asyncio.gather(*scheduled, return_exceptions=True)
359
+ scheduled.clear()
360
+ assert handler._reconnect_task is not None
361
+
362
+ # Connection resumes → cancel scheduled on loop
363
+ state["connected"] = True
364
+ handler.on_connection_resumed(return_code=0, session_present=True)
365
+ await asyncio.gather(*scheduled, return_exceptions=True)
366
+ scheduled.clear()
367
+ assert handler._reconnect_task is None
368
+
369
+ # Connection drops again (genuine)
370
+ state["connected"] = False
371
+ handler.on_connection_interrupted(Exception("second drop"))
372
+ await asyncio.gather(*scheduled, return_exceptions=True)
373
+ scheduled.clear()
374
+
375
+ # A new reconnect task must have been created
376
+ assert handler._reconnect_task is not None
377
+ handler._reconnect_task.cancel()
378
+ await asyncio.gather(handler._reconnect_task, return_exceptions=True)
379
+
380
+
235
381
  # ---------------------------------------------------------------------------
236
382
  # Bug 2: _actively_reconnecting suppresses spurious interrupt callbacks
237
383
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes