nwp500-python 8.1.0__tar.gz → 8.1.1__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 (200) hide show
  1. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/CHANGELOG.rst +3 -0
  2. {nwp500_python-8.1.0/src/nwp500_python.egg-info → nwp500_python-8.1.1}/PKG-INFO +1 -1
  3. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/client.py +37 -3
  4. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/reconnection.py +17 -2
  5. {nwp500_python-8.1.0 → nwp500_python-8.1.1/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  6. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
  7. nwp500_python-8.1.1/tests/test_mqtt_reconnection_storm.py +447 -0
  8. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.coveragerc +0 -0
  9. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  10. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.github/copilot-instructions.md +0 -0
  11. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.github/workflows/ci.yml +0 -0
  12. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.github/workflows/release.yml +0 -0
  13. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.gitignore +0 -0
  14. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.pre-commit-config.yaml +0 -0
  15. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.readthedocs.yml +0 -0
  16. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/AUTHORS.rst +0 -0
  17. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/CONTRIBUTING.rst +0 -0
  18. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/LICENSE.txt +0 -0
  19. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/Makefile +0 -0
  20. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/README.rst +0 -0
  21. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/RELEASE.md +0 -0
  22. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/Makefile +0 -0
  23. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/_static/.gitignore +0 -0
  24. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/conf.py +0 -0
  25. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/explanation/advanced-features.rst +0 -0
  26. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/explanation/architecture.rst +0 -0
  27. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/explanation/index.rst +0 -0
  28. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/authenticate.rst +0 -0
  29. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/auto-recovery.rst +0 -0
  30. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/diagnose-mqtt.rst +0 -0
  31. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/home-assistant.rst +0 -0
  32. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/index.rst +0 -0
  33. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/maintenance.rst +0 -0
  34. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/manage-units.rst +0 -0
  35. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/monitor-status.rst +0 -0
  36. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/optimize-tou.rst +0 -0
  37. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/queue-commands.rst +0 -0
  38. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/schedule-operation.rst +0 -0
  39. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/track-energy.rst +0 -0
  40. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/index.rst +0 -0
  41. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/openapi.yaml +0 -0
  42. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/authors.rst +0 -0
  43. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/changelog.rst +0 -0
  44. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/contributing.rst +0 -0
  45. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/history.rst +0 -0
  46. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/license.rst +0 -0
  47. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/configuration.rst +0 -0
  48. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/enumerations.rst +0 -0
  49. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/index.rst +0 -0
  50. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/installation.rst +0 -0
  51. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/data_conversions.rst +0 -0
  52. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/device_features.rst +0 -0
  53. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/device_status.rst +0 -0
  54. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/error_codes.rst +0 -0
  55. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  56. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/quick_reference.rst +0 -0
  57. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/rest_api.rst +0 -0
  58. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/api_client.rst +0 -0
  59. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/auth_client.rst +0 -0
  60. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/cli.rst +0 -0
  61. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/events.rst +0 -0
  62. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/exceptions.rst +0 -0
  63. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/models.rst +0 -0
  64. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/mqtt_client.rst +0 -0
  65. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/requirements.txt +0 -0
  66. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/tutorials/getting-started.rst +0 -0
  67. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/.ruff.toml +0 -0
  68. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/README.md +0 -0
  69. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/air_filter_reset.py +0 -0
  70. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/anti_legionella.py +0 -0
  71. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/auto_recovery.py +0 -0
  72. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/combined_callbacks.py +0 -0
  73. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/demand_response.py +0 -0
  74. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/device_capabilities.py +0 -0
  75. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/device_status_debug.py +0 -0
  76. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/energy_analytics.py +0 -0
  77. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/error_code_demo.py +0 -0
  78. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/firmware_payload_capture.py +0 -0
  79. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/mqtt_diagnostics.py +0 -0
  80. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/power_control.py +0 -0
  81. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/recirculation_control.py +0 -0
  82. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/reconnection_demo.py +0 -0
  83. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/reservation_schedule.py +0 -0
  84. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/simple_auto_recovery.py +0 -0
  85. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/token_restoration.py +0 -0
  86. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/tou_openei.py +0 -0
  87. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/tou_schedule.py +0 -0
  88. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/water_reservation.py +0 -0
  89. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/beginner/01_authentication.py +0 -0
  90. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/beginner/02_list_devices.py +0 -0
  91. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/beginner/03_get_status.py +0 -0
  92. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/beginner/04_set_temperature.py +0 -0
  93. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/advanced_auth_patterns.py +0 -0
  94. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/command_queue.py +0 -0
  95. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/device_status_callback.py +0 -0
  96. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/error_handling.py +0 -0
  97. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/event_driven_control.py +0 -0
  98. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/improved_auth.py +0 -0
  99. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/legacy_auth_constructor.py +0 -0
  100. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  101. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/periodic_requests.py +0 -0
  102. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/set_mode.py +0 -0
  103. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/vacation_mode.py +0 -0
  104. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/mask.py +0 -0
  105. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/periodic_device_info.py +0 -0
  106. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/simple_periodic_info.py +0 -0
  107. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/test_api_client.py +0 -0
  108. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/test_mqtt_connection.py +0 -0
  109. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/test_mqtt_messaging.py +0 -0
  110. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/test_periodic_minimal.py +0 -0
  111. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/pyproject.toml +0 -0
  112. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/README.md +0 -0
  113. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/bump_version.py +0 -0
  114. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/diagnose_mqtt_connection.py +0 -0
  115. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/extract_changelog.py +0 -0
  116. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/format.py +0 -0
  117. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/lint.py +0 -0
  118. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/setup-dev.py +0 -0
  119. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/validate_version.py +0 -0
  120. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/setup.cfg +0 -0
  121. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/setup.py +0 -0
  122. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/__init__.py +0 -0
  123. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/_base.py +0 -0
  124. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/api_client.py +0 -0
  125. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/auth.py +0 -0
  126. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/__init__.py +0 -0
  127. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/__main__.py +0 -0
  128. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/commands.py +0 -0
  129. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/handlers.py +0 -0
  130. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/monitoring.py +0 -0
  131. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/output_formatters.py +0 -0
  132. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/rich_output.py +0 -0
  133. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/token_storage.py +0 -0
  134. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/command_decorators.py +0 -0
  135. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/config.py +0 -0
  136. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/converters.py +0 -0
  137. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/device_capabilities.py +0 -0
  138. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/device_info_cache.py +0 -0
  139. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/encoding.py +0 -0
  140. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/enums.py +0 -0
  141. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/events.py +0 -0
  142. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/exceptions.py +0 -0
  143. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/factory.py +0 -0
  144. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/field_factory.py +0 -0
  145. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/__init__.py +0 -0
  146. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/_converters.py +0 -0
  147. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/device.py +0 -0
  148. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/energy.py +0 -0
  149. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/feature.py +0 -0
  150. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/mqtt_models.py +0 -0
  151. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/schedule.py +0 -0
  152. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/status.py +0 -0
  153. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/tou.py +0 -0
  154. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/__init__.py +0 -0
  155. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/command_queue.py +0 -0
  156. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/connection.py +0 -0
  157. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/control.py +0 -0
  158. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/diagnostics.py +0 -0
  159. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/periodic.py +0 -0
  160. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/state_tracker.py +0 -0
  161. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/subscriptions.py +0 -0
  162. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/utils.py +0 -0
  163. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt_events.py +0 -0
  164. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/openei.py +0 -0
  165. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/py.typed +0 -0
  166. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/reservations.py +0 -0
  167. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/temperature.py +0 -0
  168. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/topic_builder.py +0 -0
  169. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/unit_system.py +0 -0
  170. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/utils.py +0 -0
  171. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  172. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  173. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  174. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/requires.txt +0 -0
  175. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/top_level.txt +0 -0
  176. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/conftest.py +0 -0
  177. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_api_helpers.py +0 -0
  178. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_auth.py +0 -0
  179. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_bug_fixes.py +0 -0
  180. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_cli_basic.py +0 -0
  181. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_cli_commands.py +0 -0
  182. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_command_decorators.py +0 -0
  183. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_command_queue.py +0 -0
  184. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_device_capabilities.py +0 -0
  185. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_device_info_cache.py +0 -0
  186. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_events.py +0 -0
  187. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_exceptions.py +0 -0
  188. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_model_converters.py +0 -0
  189. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_models.py +0 -0
  190. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_mqtt_client_init.py +0 -0
  191. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_mqtt_hypothesis.py +0 -0
  192. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_mqtt_reconnection.py +0 -0
  193. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_multi_device.py +0 -0
  194. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_openei.py +0 -0
  195. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_reservations.py +0 -0
  196. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_temperature_converters.py +0 -0
  197. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_tou_api.py +0 -0
  198. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_unit_switching.py +0 -0
  199. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_utils.py +0 -0
  200. {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tox.ini +0 -0
@@ -5,6 +5,9 @@ Changelog
5
5
  Unreleased
6
6
  ==========
7
7
 
8
+ Version 8.1.1 (2026-05-18)
9
+ ==========================
10
+
8
11
  Version 8.1.0 (2026-05-16)
9
12
  ==========================
10
13
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 8.1.0
3
+ Version: 8.1.1
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
@@ -226,6 +226,11 @@ class NavienMqttClient(EventEmitter):
226
226
  # Connection state (simpler than checking _connection_manager)
227
227
  self._connection: mqtt.Connection | None = None
228
228
  self._connected = False
229
+ # Guards _active_reconnect / _deep_reconnect against re-entrancy.
230
+ # While True, _on_connection_interrupted_internal will not forward
231
+ # events to the reconnection handler, preventing the intentional
232
+ # teardown of the old connection from spawning a competing backoff loop.
233
+ self._actively_reconnecting = False
229
234
 
230
235
  _logger.info(
231
236
  f"Initialized MQTT client with ID: {self.config.client_id}"
@@ -276,8 +281,17 @@ class NavienMqttClient(EventEmitter):
276
281
  )
277
282
  )
278
283
 
279
- # Delegate to reconnection handler if available
280
- if self._reconnection_handler and self.config.auto_reconnect:
284
+ # Delegate to reconnection handler if available.
285
+ # Skip while _actively_reconnecting: the interruption was caused by
286
+ # _active_reconnect / _deep_reconnect intentionally closing the old
287
+ # connection. Forwarding it would queue a _start_reconnect_task
288
+ # coroutine that could fire after the new connection is up and the
289
+ # existing backoff task has been cancelled, spawning a competing loop.
290
+ if (
291
+ self._reconnection_handler
292
+ and self.config.auto_reconnect
293
+ and not self._actively_reconnecting
294
+ ):
281
295
  self._reconnection_handler.on_connection_interrupted(error)
282
296
 
283
297
  # Record diagnostic event
@@ -380,8 +394,13 @@ class NavienMqttClient(EventEmitter):
380
394
  _logger.debug("Already connected, skipping reconnection")
381
395
  return
382
396
 
397
+ if self._actively_reconnecting:
398
+ _logger.debug("Active reconnection already in progress, skipping")
399
+ return
400
+
383
401
  _logger.info("Attempting active reconnection...")
384
402
 
403
+ self._actively_reconnecting = True
385
404
  try:
386
405
  # Ensure tokens are still valid
387
406
  await self._auth_client.ensure_valid_token()
@@ -390,6 +409,9 @@ class NavienMqttClient(EventEmitter):
390
409
  if self._connection_manager:
391
410
  # Close old connection to stop SDK auto-reconnect and
392
411
  # prevent two connections with the same client ID.
412
+ # _actively_reconnecting suppresses the
413
+ # on_connection_interrupted callback that closing triggers,
414
+ # preventing a competing backoff loop from being spawned.
393
415
  _logger.debug("Recreating MQTT connection...")
394
416
  try:
395
417
  await self._connection_manager.close()
@@ -432,6 +454,8 @@ class NavienMqttClient(EventEmitter):
432
454
  f"Error during active reconnection: {e}", exc_info=True
433
455
  )
434
456
  raise
457
+ finally:
458
+ self._actively_reconnecting = False
435
459
 
436
460
  async def _deep_reconnect(self) -> None:
437
461
  """
@@ -451,13 +475,21 @@ class NavienMqttClient(EventEmitter):
451
475
  _logger.debug("Already connected, skipping deep reconnection")
452
476
  return
453
477
 
478
+ if self._actively_reconnecting:
479
+ _logger.debug("Active reconnection already in progress, skipping")
480
+ return
481
+
454
482
  _logger.warning(
455
483
  "Performing deep reconnection (full rebuild)... "
456
484
  "This may take longer."
457
485
  )
458
486
 
487
+ self._actively_reconnecting = True
459
488
  try:
460
- # Step 1: Clean up existing connection if any
489
+ # Step 1: Clean up existing connection if any.
490
+ # _actively_reconnecting suppresses the on_connection_interrupted
491
+ # callback that closing triggers, preventing a competing backoff
492
+ # loop from being spawned.
461
493
  if self._connection_manager:
462
494
  _logger.debug("Cleaning up old connection...")
463
495
  try:
@@ -534,6 +566,8 @@ class NavienMqttClient(EventEmitter):
534
566
  ) as e:
535
567
  _logger.error(f"Error during deep reconnection: {e}", exc_info=True)
536
568
  raise
569
+ finally:
570
+ self._actively_reconnecting = False
537
571
 
538
572
  async def connect(self) -> bool:
539
573
  """
@@ -94,11 +94,18 @@ class MqttReconnectionHandler:
94
94
  """
95
95
  _logger.warning(f"Connection interrupted: {error}")
96
96
 
97
- # Start automatic reconnection if enabled
97
+ # Start automatic reconnection if enabled.
98
+ # Also guard against stale interruption events that arrive after the
99
+ # connection has already been restored: these can be queued via
100
+ # run_coroutine_threadsafe and fire after on_connection_resumed has
101
+ # cancelled _reconnect_task (setting it to None), which would
102
+ # otherwise bypass the task-existence check and spawn a new backoff
103
+ # loop while the client is perfectly healthy.
98
104
  if (
99
105
  self.config.auto_reconnect
100
106
  and self._enabled
101
107
  and not self._manual_disconnect
108
+ and not self._is_connected_func()
102
109
  and (not self._reconnect_task or self._reconnect_task.done())
103
110
  ):
104
111
  _logger.info("Starting automatic reconnection...")
@@ -132,8 +139,16 @@ class MqttReconnectionHandler:
132
139
 
133
140
  This is a helper method to create the reconnect task from within
134
141
  a coroutine that's scheduled via _schedule_coroutine.
142
+
143
+ The is_connected guard is re-checked here because this coroutine may
144
+ be queued via run_coroutine_threadsafe and run after the connection
145
+ has already been restored (e.g. by on_connection_resumed cancelling
146
+ _reconnect_task), in which case starting a new backoff loop would
147
+ incorrectly tear down a healthy connection.
135
148
  """
136
- if not self._reconnect_task or self._reconnect_task.done():
149
+ if not self._is_connected_func() and (
150
+ not self._reconnect_task or self._reconnect_task.done()
151
+ ):
137
152
  self._reconnect_task = asyncio.create_task(
138
153
  self._reconnect_with_backoff()
139
154
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 8.1.0
3
+ Version: 8.1.1
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
@@ -189,6 +189,7 @@ tests/test_models.py
189
189
  tests/test_mqtt_client_init.py
190
190
  tests/test_mqtt_hypothesis.py
191
191
  tests/test_mqtt_reconnection.py
192
+ tests/test_mqtt_reconnection_storm.py
192
193
  tests/test_multi_device.py
193
194
  tests/test_openei.py
194
195
  tests/test_reservations.py
@@ -0,0 +1,447 @@
1
+ """
2
+ Tests for the MQTT reconnection storm fix.
3
+
4
+ Two bugs were fixed:
5
+
6
+ Bug 1 — Stale interruption events fire after a resume clears _reconnect_task.
7
+ The AWS SDK fires on_connection_interrupted callbacks from background threads
8
+ via run_coroutine_threadsafe. When on_connection_resumed cancels and nulls
9
+ _reconnect_task, queued _start_reconnect_task coroutines that haven't run yet
10
+ see _reconnect_task=None and spawn a new _reconnect_with_backoff task even
11
+ though the client is now healthy.
12
+
13
+ Fix: both on_connection_interrupted and _start_reconnect_task now check
14
+ is_connected_func() before starting a new backoff loop.
15
+
16
+ Bug 2 — Closing the old connection inside _active_reconnect / _deep_reconnect
17
+ fires _on_connection_interrupted_internal from a background SDK thread.
18
+ This queued another _start_reconnect_task coroutine that would fire after the
19
+ new connection was established, tearing it down immediately.
20
+
21
+ Fix: _actively_reconnecting flag suppresses the reconnection-handler
22
+ delegation in _on_connection_interrupted_internal while the intentional
23
+ teardown is in progress.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ from unittest.mock import AsyncMock, MagicMock, patch
30
+
31
+ import pytest
32
+
33
+ from nwp500.auth import (
34
+ AuthenticationResponse,
35
+ AuthTokens,
36
+ NavienAuthClient,
37
+ UserInfo,
38
+ )
39
+ from nwp500.mqtt.reconnection import MqttReconnectionHandler
40
+ from nwp500.mqtt.utils import MqttConnectionConfig
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Shared helpers
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ def _make_auth_client() -> NavienAuthClient:
48
+ client = NavienAuthClient("test@example.com", "password")
49
+ tokens = AuthTokens(
50
+ id_token="tok",
51
+ access_token="acc",
52
+ refresh_token="ref",
53
+ authentication_expires_in=3600,
54
+ access_key_id="key",
55
+ secret_key="secret",
56
+ session_token="sess",
57
+ authorization_expires_in=3600,
58
+ )
59
+ client._auth_response = AuthenticationResponse(
60
+ user_info=UserInfo(user_first_name="T", user_last_name="U"),
61
+ tokens=tokens,
62
+ )
63
+ return client
64
+
65
+
66
+ def _make_handler(
67
+ *,
68
+ connected: bool = False,
69
+ auto_reconnect: bool = True,
70
+ max_reconnect_attempts: int = -1,
71
+ ) -> tuple[MqttReconnectionHandler, list[asyncio.Task]]:
72
+ """Return a handler and a list that records every scheduled coroutine."""
73
+ config = MqttConnectionConfig(
74
+ auto_reconnect=auto_reconnect,
75
+ max_reconnect_attempts=max_reconnect_attempts,
76
+ )
77
+ scheduled: list[asyncio.Task] = []
78
+
79
+ def _schedule(coro): # replaces run_coroutine_threadsafe in real code
80
+ t = asyncio.ensure_future(coro)
81
+ scheduled.append(t)
82
+ return t
83
+
84
+ handler = MqttReconnectionHandler(
85
+ config=config,
86
+ is_connected_func=lambda: connected,
87
+ schedule_coroutine_func=_schedule,
88
+ reconnect_func=AsyncMock(),
89
+ deep_reconnect_func=None,
90
+ emit_event_func=None,
91
+ )
92
+ handler.enable()
93
+ return handler, scheduled
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Bug 1: on_connection_interrupted / _start_reconnect_task is_connected guard
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ class TestReconnectionHandlerIsConnectedGuard:
102
+ """Bug 1 – stale interrupt events must not start a loop when connected."""
103
+
104
+ @pytest.mark.asyncio(loop_scope="function")
105
+ async def test_on_connection_interrupted_does_not_start_task_when_connected(
106
+ self,
107
+ ):
108
+ """on_connection_interrupted is a no-op when connected."""
109
+ connected = True
110
+ config = MqttConnectionConfig(auto_reconnect=True)
111
+ scheduled = []
112
+
113
+ handler = MqttReconnectionHandler(
114
+ config=config,
115
+ is_connected_func=lambda: connected,
116
+ schedule_coroutine_func=lambda coro: scheduled.append(
117
+ asyncio.ensure_future(coro)
118
+ ),
119
+ reconnect_func=AsyncMock(),
120
+ )
121
+ handler.enable()
122
+
123
+ handler.on_connection_interrupted(Exception("dropped"))
124
+
125
+ # Nothing should have been scheduled
126
+ assert scheduled == []
127
+
128
+ @pytest.mark.asyncio(loop_scope="function")
129
+ async def test_on_connection_interrupted_starts_task_when_disconnected(
130
+ self,
131
+ ):
132
+ """on_connection_interrupted schedules a task when disconnected."""
133
+ handler, scheduled = _make_handler(connected=False)
134
+
135
+ handler.on_connection_interrupted(Exception("dropped"))
136
+
137
+ assert len(scheduled) == 1
138
+ # Clean up
139
+ scheduled[0].cancel()
140
+ await asyncio.gather(*scheduled, return_exceptions=True)
141
+
142
+ @pytest.mark.asyncio(loop_scope="function")
143
+ async def test_start_reconnect_task_no_op_when_connected(self):
144
+ """_start_reconnect_task must not create a Task when connected."""
145
+ connected = True
146
+ config = MqttConnectionConfig(auto_reconnect=True)
147
+
148
+ handler = MqttReconnectionHandler(
149
+ config=config,
150
+ is_connected_func=lambda: connected,
151
+ schedule_coroutine_func=lambda coro: asyncio.ensure_future(coro),
152
+ reconnect_func=AsyncMock(),
153
+ )
154
+ handler.enable()
155
+
156
+ await handler._start_reconnect_task()
157
+
158
+ assert handler._reconnect_task is None
159
+
160
+ @pytest.mark.asyncio(loop_scope="function")
161
+ async def test_start_reconnect_task_creates_task_when_disconnected(self):
162
+ """_start_reconnect_task creates a Task when genuinely disconnected."""
163
+ handler, _ = _make_handler(connected=False)
164
+
165
+ await handler._start_reconnect_task()
166
+
167
+ assert handler._reconnect_task is not None
168
+ assert not handler._reconnect_task.done()
169
+
170
+ handler._reconnect_task.cancel()
171
+ await asyncio.gather(handler._reconnect_task, return_exceptions=True)
172
+
173
+ @pytest.mark.asyncio(loop_scope="function")
174
+ async def test_stale_interrupt_after_resume_does_not_spawn_extra_task(self):
175
+ """
176
+ Simulate the race that caused the reconnection storm:
177
+
178
+ 1. Connection drops → on_connection_interrupted schedules
179
+ _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.
184
+ """
185
+ state = {"connected": False}
186
+ config = MqttConnectionConfig(auto_reconnect=True)
187
+ scheduled = []
188
+
189
+ handler = MqttReconnectionHandler(
190
+ config=config,
191
+ is_connected_func=lambda: state["connected"],
192
+ schedule_coroutine_func=lambda coro: scheduled.append(
193
+ asyncio.ensure_future(coro)
194
+ ),
195
+ reconnect_func=AsyncMock(),
196
+ )
197
+ handler.enable()
198
+
199
+ # Step 1: connection drops, schedule the coroutine but don't run it yet
200
+ handler.on_connection_interrupted(Exception("dropped"))
201
+ assert len(scheduled) == 1
202
+
203
+ # Step 2: connection resumes before the scheduled coroutine runs
204
+ state["connected"] = True
205
+ handler.on_connection_resumed(return_code=0, session_present=False)
206
+ assert handler._reconnect_task is None
207
+
208
+ # Step 3: the stale coroutine from step 1 runs now
209
+ await scheduled[0]
210
+
211
+ # With the fix, no new task must have been created
212
+ assert handler._reconnect_task is None
213
+
214
+ @pytest.mark.asyncio(loop_scope="function")
215
+ async def test_multiple_simultaneous_interrupts_create_only_one_task(self):
216
+ """
217
+ Multiple concurrent on_connection_interrupted calls (from different
218
+ SDK threads) must not spawn more than one backoff task.
219
+ """
220
+ handler, scheduled = _make_handler(connected=False)
221
+
222
+ # Simulate three rapid interruption callbacks
223
+ for _ in range(3):
224
+ handler.on_connection_interrupted(Exception("dropped"))
225
+
226
+ # Let all the scheduled coroutines run
227
+ await asyncio.gather(*scheduled, return_exceptions=True)
228
+
229
+ # Only one _reconnect_with_backoff task should exist
230
+ assert handler._reconnect_task is not None
231
+ handler._reconnect_task.cancel()
232
+ await asyncio.gather(handler._reconnect_task, return_exceptions=True)
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Bug 2: _actively_reconnecting suppresses spurious interrupt callbacks
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ class TestActivelyReconnectingFlag:
241
+ """Bug 2 – old connection teardown must not trigger a new backoff loop."""
242
+
243
+ def _make_mqtt_client(self) -> NavienMqttClient: # noqa: F821
244
+ from nwp500.mqtt import NavienMqttClient
245
+
246
+ return NavienMqttClient(_make_auth_client())
247
+
248
+ def test_actively_reconnecting_initialises_false(self):
249
+ """_actively_reconnecting starts False."""
250
+ client = self._make_mqtt_client()
251
+ assert client._actively_reconnecting is False
252
+
253
+ @pytest.mark.asyncio(loop_scope="function")
254
+ async def test_interrupted_internal_skips_handler_when_flag_set( # noqa: E501
255
+ self,
256
+ ):
257
+ """
258
+ While _actively_reconnecting is True (old connection being closed),
259
+ _on_connection_interrupted_internal must NOT forward the event to the
260
+ reconnection handler – preventing a competing backoff task.
261
+ """
262
+ from awscrt.exceptions import AwsCrtError
263
+
264
+ client = self._make_mqtt_client()
265
+
266
+ mock_handler = MagicMock()
267
+ client._reconnection_handler = mock_handler
268
+ client.config = MqttConnectionConfig(auto_reconnect=True)
269
+
270
+ client._actively_reconnecting = True # flag is set
271
+
272
+ error = AwsCrtError(
273
+ code=0,
274
+ name="AWS_ERROR_MQTT_UNEXPECTED_HANGUP",
275
+ message="hangup",
276
+ )
277
+ client._on_connection_interrupted_internal(
278
+ connection=MagicMock(), error=error
279
+ )
280
+
281
+ # The handler must NOT have been notified
282
+ mock_handler.on_connection_interrupted.assert_not_called()
283
+
284
+ @pytest.mark.asyncio(loop_scope="function")
285
+ async def test_on_connection_interrupted_internal_forwards_when_flag_clear(
286
+ self,
287
+ ):
288
+ """
289
+ When _actively_reconnecting is False (genuine drop), the event IS
290
+ forwarded to the reconnection handler.
291
+ """
292
+ from awscrt.exceptions import AwsCrtError
293
+
294
+ client = self._make_mqtt_client()
295
+
296
+ mock_handler = MagicMock()
297
+ client._reconnection_handler = mock_handler
298
+ client.config = MqttConnectionConfig(auto_reconnect=True)
299
+
300
+ client._actively_reconnecting = False # flag is clear (default)
301
+
302
+ error = AwsCrtError(
303
+ code=0,
304
+ name="AWS_ERROR_MQTT_UNEXPECTED_HANGUP",
305
+ message="hangup",
306
+ )
307
+ client._on_connection_interrupted_internal(
308
+ connection=MagicMock(), error=error
309
+ )
310
+
311
+ mock_handler.on_connection_interrupted.assert_called_once_with(error)
312
+
313
+ @pytest.mark.asyncio(loop_scope="function")
314
+ async def test_active_reconnect_sets_and_clears_flag_on_success(self):
315
+ """_active_reconnect sets the flag, does its work, then clears it."""
316
+ client = self._make_mqtt_client()
317
+ client._connected = False
318
+ client._loop = asyncio.get_running_loop()
319
+
320
+ flag_during = []
321
+
322
+ async def fake_reconnect():
323
+ flag_during.append(client._actively_reconnecting)
324
+ client._connected = True
325
+ return True
326
+
327
+ mock_conn_mgr = AsyncMock()
328
+ mock_conn_mgr.close = AsyncMock()
329
+ mock_conn_mgr.connect = AsyncMock(side_effect=fake_reconnect)
330
+ mock_conn_mgr.connection = MagicMock()
331
+ client._connection_manager = mock_conn_mgr
332
+
333
+ with patch(
334
+ "nwp500.mqtt.client.MqttConnection", return_value=mock_conn_mgr
335
+ ):
336
+ client._auth_client.ensure_valid_token = AsyncMock()
337
+ client._subscription_manager = None
338
+ await client._active_reconnect()
339
+
340
+ assert flag_during == [True], "Flag must be True while reconnecting"
341
+ assert not client._actively_reconnecting, "Flag must be cleared after"
342
+
343
+ @pytest.mark.asyncio(loop_scope="function")
344
+ async def test_active_reconnect_clears_flag_on_exception(self):
345
+ """_active_reconnect clears the flag even on exception."""
346
+ from awscrt.exceptions import AwsCrtError
347
+
348
+ client = self._make_mqtt_client()
349
+ client._connected = False
350
+ client._loop = asyncio.get_running_loop()
351
+
352
+ mock_conn_mgr = AsyncMock()
353
+ mock_conn_mgr.close = AsyncMock()
354
+ mock_conn_mgr.connect = AsyncMock(
355
+ side_effect=AwsCrtError(
356
+ code=0, name="AWS_ERROR_MQTT_UNEXPECTED_HANGUP", message="fail"
357
+ )
358
+ )
359
+ client._connection_manager = mock_conn_mgr
360
+
361
+ with patch(
362
+ "nwp500.mqtt.client.MqttConnection", return_value=mock_conn_mgr
363
+ ):
364
+ client._auth_client.ensure_valid_token = AsyncMock()
365
+ with pytest.raises(AwsCrtError):
366
+ await client._active_reconnect()
367
+
368
+ assert not client._actively_reconnecting, "must be cleared on error"
369
+ """
370
+ A second concurrent call to _active_reconnect while the first is
371
+ still running must return immediately without making changes.
372
+ """
373
+ client = self._make_mqtt_client()
374
+ client._connected = False
375
+ client._loop = asyncio.get_running_loop()
376
+ client._actively_reconnecting = True # Simulate first call in progress
377
+
378
+ # No connection manager – if we got past the guard we'd crash
379
+ client._connection_manager = None
380
+
381
+ # Should return immediately without touching the connection
382
+ await client._active_reconnect() # Must not raise
383
+
384
+ # State unchanged
385
+ assert client._actively_reconnecting is True
386
+ assert not client._connected
387
+
388
+ @pytest.mark.asyncio(loop_scope="function")
389
+ async def test_deep_reconnect_sets_and_clears_flag(self):
390
+ """_deep_reconnect also sets and clears _actively_reconnecting."""
391
+ client = self._make_mqtt_client()
392
+ client._connected = False
393
+ client._loop = asyncio.get_running_loop()
394
+
395
+ flag_during = []
396
+
397
+ async def fake_connect():
398
+ flag_during.append(client._actively_reconnecting)
399
+ client._connected = True
400
+ return True
401
+
402
+ mock_conn_mgr = AsyncMock()
403
+ mock_conn_mgr.close = AsyncMock()
404
+ mock_conn_mgr.connect = AsyncMock(side_effect=fake_connect)
405
+ mock_conn_mgr.connection = MagicMock()
406
+ client._connection_manager = mock_conn_mgr
407
+
408
+ with patch(
409
+ "nwp500.mqtt.client.MqttConnection", return_value=mock_conn_mgr
410
+ ):
411
+ client._auth_client.ensure_valid_token = AsyncMock()
412
+ client._auth_client.current_tokens.refresh_token = "ref"
413
+ client._auth_client.refresh_token = AsyncMock()
414
+ client._subscription_manager = None
415
+ await client._deep_reconnect()
416
+
417
+ assert flag_during == [True], "Flag True while deep-reconnecting"
418
+ assert not client._actively_reconnecting, "Flag must be cleared after"
419
+
420
+ @pytest.mark.asyncio(loop_scope="function")
421
+ async def test_deep_reconnect_clears_flag_on_exception(self):
422
+ """_deep_reconnect clears the flag even when an exception is raised."""
423
+ from awscrt.exceptions import AwsCrtError
424
+
425
+ client = self._make_mqtt_client()
426
+ client._connected = False
427
+ client._loop = asyncio.get_running_loop()
428
+
429
+ mock_conn_mgr = AsyncMock()
430
+ mock_conn_mgr.close = AsyncMock()
431
+ mock_conn_mgr.connect = AsyncMock(
432
+ side_effect=AwsCrtError(
433
+ code=0, name="AWS_ERROR_MQTT_UNEXPECTED_HANGUP", message="fail"
434
+ )
435
+ )
436
+ client._connection_manager = mock_conn_mgr
437
+
438
+ with patch(
439
+ "nwp500.mqtt.client.MqttConnection", return_value=mock_conn_mgr
440
+ ):
441
+ client._auth_client.ensure_valid_token = AsyncMock()
442
+ client._auth_client.current_tokens.refresh_token = "ref"
443
+ client._auth_client.refresh_token = AsyncMock()
444
+ with pytest.raises(AwsCrtError):
445
+ await client._deep_reconnect()
446
+
447
+ assert not client._actively_reconnecting, "must be cleared on error"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes