nwp500-python 8.0.0__tar.gz → 8.1.0__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 (199) hide show
  1. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/CHANGELOG.rst +64 -2
  2. {nwp500_python-8.0.0/src/nwp500_python.egg-info → nwp500_python-8.1.0}/PKG-INFO +1 -1
  3. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/bump_version.py +77 -0
  4. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/device_info_cache.py +9 -6
  5. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/encoding.py +30 -8
  6. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/events.py +1 -1
  7. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/client.py +23 -15
  8. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/connection.py +38 -0
  9. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/diagnostics.py +6 -1
  10. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/periodic.py +6 -0
  11. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/utils.py +3 -1
  12. {nwp500_python-8.0.0 → nwp500_python-8.1.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  13. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/SOURCES.txt +2 -0
  14. nwp500_python-8.1.0/tests/test_bug_fixes.py +198 -0
  15. nwp500_python-8.1.0/tests/test_mqtt_reconnection.py +130 -0
  16. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.coveragerc +0 -0
  17. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  18. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.github/copilot-instructions.md +0 -0
  19. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.github/workflows/ci.yml +0 -0
  20. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.github/workflows/release.yml +0 -0
  21. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.gitignore +0 -0
  22. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.pre-commit-config.yaml +0 -0
  23. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/.readthedocs.yml +0 -0
  24. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/AUTHORS.rst +0 -0
  25. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/CONTRIBUTING.rst +0 -0
  26. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/LICENSE.txt +0 -0
  27. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/Makefile +0 -0
  28. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/README.rst +0 -0
  29. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/RELEASE.md +0 -0
  30. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/Makefile +0 -0
  31. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/_static/.gitignore +0 -0
  32. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/conf.py +0 -0
  33. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/explanation/advanced-features.rst +0 -0
  34. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/explanation/architecture.rst +0 -0
  35. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/explanation/index.rst +0 -0
  36. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/authenticate.rst +0 -0
  37. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/auto-recovery.rst +0 -0
  38. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/diagnose-mqtt.rst +0 -0
  39. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/home-assistant.rst +0 -0
  40. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/index.rst +0 -0
  41. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/maintenance.rst +0 -0
  42. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/manage-units.rst +0 -0
  43. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/monitor-status.rst +0 -0
  44. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/optimize-tou.rst +0 -0
  45. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/queue-commands.rst +0 -0
  46. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/schedule-operation.rst +0 -0
  47. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/how-to/track-energy.rst +0 -0
  48. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/index.rst +0 -0
  49. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/openapi.yaml +0 -0
  50. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/authors.rst +0 -0
  51. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/changelog.rst +0 -0
  52. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/contributing.rst +0 -0
  53. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/history.rst +0 -0
  54. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/project/license.rst +0 -0
  55. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/configuration.rst +0 -0
  56. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/enumerations.rst +0 -0
  57. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/index.rst +0 -0
  58. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/installation.rst +0 -0
  59. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/data_conversions.rst +0 -0
  60. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/device_features.rst +0 -0
  61. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/device_status.rst +0 -0
  62. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/error_codes.rst +0 -0
  63. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  64. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/quick_reference.rst +0 -0
  65. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/protocol/rest_api.rst +0 -0
  66. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/api_client.rst +0 -0
  67. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/auth_client.rst +0 -0
  68. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/cli.rst +0 -0
  69. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/events.rst +0 -0
  70. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/exceptions.rst +0 -0
  71. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/models.rst +0 -0
  72. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/reference/python_api/mqtt_client.rst +0 -0
  73. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/requirements.txt +0 -0
  74. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/docs/tutorials/getting-started.rst +0 -0
  75. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/.ruff.toml +0 -0
  76. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/README.md +0 -0
  77. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/air_filter_reset.py +0 -0
  78. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/anti_legionella.py +0 -0
  79. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/auto_recovery.py +0 -0
  80. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/combined_callbacks.py +0 -0
  81. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/demand_response.py +0 -0
  82. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/device_capabilities.py +0 -0
  83. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/device_status_debug.py +0 -0
  84. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/energy_analytics.py +0 -0
  85. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/error_code_demo.py +0 -0
  86. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/firmware_payload_capture.py +0 -0
  87. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/mqtt_diagnostics.py +0 -0
  88. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/power_control.py +0 -0
  89. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/recirculation_control.py +0 -0
  90. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/reconnection_demo.py +0 -0
  91. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/reservation_schedule.py +0 -0
  92. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/simple_auto_recovery.py +0 -0
  93. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/token_restoration.py +0 -0
  94. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/tou_openei.py +0 -0
  95. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/tou_schedule.py +0 -0
  96. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/advanced/water_reservation.py +0 -0
  97. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/beginner/01_authentication.py +0 -0
  98. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/beginner/02_list_devices.py +0 -0
  99. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/beginner/03_get_status.py +0 -0
  100. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/beginner/04_set_temperature.py +0 -0
  101. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
  102. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/command_queue.py +0 -0
  103. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/device_status_callback.py +0 -0
  104. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/error_handling.py +0 -0
  105. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/event_driven_control.py +0 -0
  106. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/improved_auth.py +0 -0
  107. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
  108. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  109. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/periodic_requests.py +0 -0
  110. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/set_mode.py +0 -0
  111. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/intermediate/vacation_mode.py +0 -0
  112. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/mask.py +0 -0
  113. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/periodic_device_info.py +0 -0
  114. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/simple_periodic_info.py +0 -0
  115. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/test_api_client.py +0 -0
  116. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/test_mqtt_connection.py +0 -0
  117. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/test_mqtt_messaging.py +0 -0
  118. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/examples/testing/test_periodic_minimal.py +0 -0
  119. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/pyproject.toml +0 -0
  120. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/README.md +0 -0
  121. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/diagnose_mqtt_connection.py +0 -0
  122. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/extract_changelog.py +0 -0
  123. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/format.py +0 -0
  124. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/lint.py +0 -0
  125. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/setup-dev.py +0 -0
  126. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/scripts/validate_version.py +0 -0
  127. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/setup.cfg +0 -0
  128. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/setup.py +0 -0
  129. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/__init__.py +0 -0
  130. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/_base.py +0 -0
  131. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/api_client.py +0 -0
  132. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/auth.py +0 -0
  133. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/__init__.py +0 -0
  134. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/__main__.py +0 -0
  135. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/commands.py +0 -0
  136. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/handlers.py +0 -0
  137. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/monitoring.py +0 -0
  138. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/output_formatters.py +0 -0
  139. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/rich_output.py +0 -0
  140. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/cli/token_storage.py +0 -0
  141. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/command_decorators.py +0 -0
  142. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/config.py +0 -0
  143. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/converters.py +0 -0
  144. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/device_capabilities.py +0 -0
  145. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/enums.py +0 -0
  146. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/exceptions.py +0 -0
  147. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/factory.py +0 -0
  148. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/field_factory.py +0 -0
  149. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/__init__.py +0 -0
  150. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/_converters.py +0 -0
  151. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/device.py +0 -0
  152. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/energy.py +0 -0
  153. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/feature.py +0 -0
  154. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/mqtt_models.py +0 -0
  155. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/schedule.py +0 -0
  156. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/status.py +0 -0
  157. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/models/tou.py +0 -0
  158. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/__init__.py +0 -0
  159. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/command_queue.py +0 -0
  160. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/control.py +0 -0
  161. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/reconnection.py +0 -0
  162. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/state_tracker.py +0 -0
  163. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt/subscriptions.py +0 -0
  164. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/mqtt_events.py +0 -0
  165. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/openei.py +0 -0
  166. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/py.typed +0 -0
  167. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/reservations.py +0 -0
  168. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/temperature.py +0 -0
  169. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/topic_builder.py +0 -0
  170. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/unit_system.py +0 -0
  171. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500/utils.py +0 -0
  172. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  173. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  174. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  175. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/requires.txt +0 -0
  176. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
  177. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/conftest.py +0 -0
  178. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_api_helpers.py +0 -0
  179. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_auth.py +0 -0
  180. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_cli_basic.py +0 -0
  181. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_cli_commands.py +0 -0
  182. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_command_decorators.py +0 -0
  183. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_command_queue.py +0 -0
  184. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_device_capabilities.py +0 -0
  185. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_device_info_cache.py +0 -0
  186. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_events.py +0 -0
  187. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_exceptions.py +0 -0
  188. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_model_converters.py +0 -0
  189. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_models.py +0 -0
  190. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_mqtt_client_init.py +0 -0
  191. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_mqtt_hypothesis.py +0 -0
  192. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_multi_device.py +0 -0
  193. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_openei.py +0 -0
  194. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_reservations.py +0 -0
  195. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_temperature_converters.py +0 -0
  196. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_tou_api.py +0 -0
  197. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_unit_switching.py +0 -0
  198. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tests/test_utils.py +0 -0
  199. {nwp500_python-8.0.0 → nwp500_python-8.1.0}/tox.ini +0 -0
@@ -2,8 +2,70 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
- Unreleased (8.x)
6
- ================
5
+ Unreleased
6
+ ==========
7
+
8
+ Version 8.1.0 (2026-05-16)
9
+ ==========================
10
+
11
+ Bug Fixes
12
+ ---------
13
+ - **Fix MQTT connection flapping after reconnect**: When ``_active_reconnect()``
14
+ created a new ``MqttConnection``, the old connection was never closed. The old
15
+ SDK connection's built-in auto-reconnect would eventually succeed, creating two
16
+ active connections sharing the same client ID. Because AWS IoT allows only one
17
+ connection per client ID, the broker would kick one off, triggering
18
+ ``on_connection_interrupted`` and starting yet another reconnection — an
19
+ infinite connect/disconnect loop. Fixed by adding ``MqttConnection.close()``
20
+ (unconditional teardown regardless of ``_connected`` state) and calling it
21
+ before creating the replacement connection in both ``_active_reconnect()`` and
22
+ ``_deep_reconnect()``.
23
+
24
+ - **Thread-safety race in ``ensure_device_info_cached``**: The ``future.done()``
25
+ check and ``future.set_result()`` were performed in the AWS SDK callback thread
26
+ without synchronisation, creating a race against the asyncio event loop thread.
27
+ Moved both operations inside a ``call_soon_threadsafe`` callback so they execute
28
+ atomically on the event loop thread.
29
+
30
+ - **ZeroDivisionError when ``deep_reconnect_threshold`` is 0**: Config validation
31
+ now clamps ``deep_reconnect_threshold`` to a minimum of 1, preventing a
32
+ ``ZeroDivisionError`` in the exponential-backoff reconnection logic.
33
+
34
+ - **Reconnect counter never incremented**: ``total_reconnect_attempts`` in
35
+ diagnostics was not incremented on connection drops, so it always reported 0
36
+ despite active reconnections. Counter is now incremented on each
37
+ ``on_connection_interrupted`` event.
38
+
39
+ - **``shortest_session_seconds`` not JSON-serialisable**: The diagnostics
40
+ ``to_dict()`` method used ``float('inf')`` as the initial value for
41
+ ``shortest_session_seconds``, which is not valid JSON. Changed to ``None``
42
+ so serialisation succeeds when no session has completed yet.
43
+
44
+ - **``wait_for()`` future not bound to running loop**: ``wait_for()`` created a
45
+ bare ``asyncio.Future()`` rather than
46
+ ``asyncio.get_running_loop().create_future()``, which could bind the future to
47
+ a different loop in multi-loop test setups.
48
+
49
+ - **Reservation temperature validation was US-only**: ``build_reservation_entry``
50
+ validated set-point temperatures against hardcoded Fahrenheit bounds (95–150 °F)
51
+ regardless of the active unit system. Validation now uses the current unit system
52
+ context: 35–65 °C in metric mode, 95–150 °F in US mode. Celsius users previously
53
+ received spurious ``ValueError`` rejections for valid temperatures.
54
+
55
+ - **Malformed reservation data silently dropped**: ``build_reservation_entry`` now
56
+ logs a warning when reservation hex data contains unexpected trailing bytes
57
+ instead of silently dropping partial entries.
58
+
59
+ - **Unknown ``PeriodicRequestType`` silently ignored**: The periodic-request handler
60
+ now logs an error and breaks when it encounters an unknown request type instead of
61
+ doing nothing.
62
+
63
+ - **Memory leak in device info cache**: ``get_all_cached()`` only filtered expired
64
+ entries from its return value but left them in the cache dictionary. Expired
65
+ entries are now evicted during ``get_all_cached()`` to prevent unbounded growth.
66
+
67
+ Version 8.0.0 (2026-05-13)
68
+ ===========================
7
69
 
8
70
  Bug Fixes
9
71
  ---------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 8.0.0
3
+ Version: 8.1.0
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
@@ -22,6 +22,8 @@ field is for the PyScaffold tool version, not the package version!
22
22
  import re
23
23
  import subprocess
24
24
  import sys
25
+ from datetime import date
26
+ from pathlib import Path
25
27
 
26
28
 
27
29
  def run_git_command(args: list) -> str:
@@ -140,6 +142,75 @@ def check_working_directory_clean() -> None:
140
142
  sys.exit(1)
141
143
 
142
144
 
145
+ def update_changelog(version: str) -> None:
146
+ """Insert a version heading into CHANGELOG.rst below the Unreleased section.
147
+
148
+ Transforms:
149
+
150
+ Unreleased
151
+ ==========
152
+
153
+ <content...>
154
+
155
+ into:
156
+
157
+ Unreleased
158
+ ==========
159
+
160
+ Version X.Y.Z (YYYY-MM-DD)
161
+ ===========================
162
+
163
+ <content...>
164
+ """
165
+ changelog_path = Path("CHANGELOG.rst")
166
+ if not changelog_path.exists():
167
+ print("Warning: CHANGELOG.rst not found, skipping changelog update.")
168
+ return
169
+
170
+ content = changelog_path.read_text(encoding="utf-8")
171
+
172
+ heading = f"Version {version} ({date.today().isoformat()})"
173
+ underline = "=" * len(heading)
174
+ version_block = f"{heading}\n{underline}\n"
175
+
176
+ # Match "Unreleased\n==========\n" (any number of = signs) followed by
177
+ # one or more blank lines, then insert the version block after them.
178
+ pattern = re.compile(
179
+ r"(Unreleased\n=+\n)" # group 1: Unreleased heading
180
+ r"(\n+)", # group 2: blank line(s) separator
181
+ re.MULTILINE,
182
+ )
183
+
184
+ match = pattern.search(content)
185
+ if not match:
186
+ print(
187
+ "Warning: Could not find 'Unreleased' section in CHANGELOG.rst. "
188
+ "Skipping changelog update.",
189
+ file=sys.stderr,
190
+ )
191
+ return
192
+
193
+ # Insert the version block after the blank lines that follow "Unreleased"
194
+ new_content = (
195
+ content[: match.end()]
196
+ + version_block
197
+ + "\n"
198
+ + content[match.end() :]
199
+ )
200
+
201
+ changelog_path.write_text(new_content, encoding="utf-8")
202
+ print(f"[OK] Updated CHANGELOG.rst with {heading}")
203
+
204
+
205
+ def commit_changelog(version: str) -> None:
206
+ """Stage and commit the CHANGELOG.rst update."""
207
+ run_git_command(["add", "CHANGELOG.rst"])
208
+ run_git_command(
209
+ ["commit", "-m", f"Update changelog for v{version}"]
210
+ )
211
+ print("[OK] Committed changelog update")
212
+
213
+
143
214
  def create_tag(version: str, message: str = None) -> None:
144
215
  """Create a git tag for the version."""
145
216
  tag_name = f"v{version}"
@@ -223,6 +294,11 @@ def main() -> None:
223
294
  # Validate version progression
224
295
  validate_version_progression(current_version, new_version)
225
296
 
297
+ # Update CHANGELOG.rst and commit, then create the tag
298
+ print("\nUpdating CHANGELOG.rst...")
299
+ update_changelog(new_version)
300
+ commit_changelog(new_version)
301
+
226
302
  # Create the tag
227
303
  print(f"\nCreating tag v{new_version}...")
228
304
  create_tag(new_version)
@@ -230,6 +306,7 @@ def main() -> None:
230
306
  print("\n[OK] Version bump complete!")
231
307
  print("\nNext steps:")
232
308
  print(f" 1. Push the tag: git push origin v{new_version}")
309
+ print(" (also push the changelog commit: git push origin HEAD)")
233
310
  print(" 2. Build release: make build")
234
311
  print(" 3. Test on TestPyPI: make publish-test")
235
312
  print(" 4. Publish to PyPI: make publish")
@@ -137,12 +137,15 @@ class MqttDeviceInfoCache:
137
137
  Dictionary mapping MAC addresses to DeviceFeature objects
138
138
  """
139
139
  async with self._lock:
140
- # Filter out expired entries
141
- return {
142
- mac: features
143
- for mac, (features, timestamp) in self._cache.items()
144
- if not self.is_expired(timestamp)
145
- }
140
+ # Filter out expired entries and purge them from cache
141
+ expired_keys = [
142
+ mac
143
+ for mac, (_, timestamp) in self._cache.items()
144
+ if self.is_expired(timestamp)
145
+ ]
146
+ for mac in expired_keys:
147
+ del self._cache[mac]
148
+ return {mac: features for mac, (features, _) in self._cache.items()}
146
149
 
147
150
  async def get_cache_info(
148
151
  self,
@@ -8,11 +8,14 @@ These utilities are used by both the API client and MQTT client.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
11
12
  from collections.abc import Iterable
12
13
  from numbers import Real
13
14
 
14
15
  from .exceptions import ParameterValidationError, RangeValidationError
15
16
 
17
+ _logger = logging.getLogger(__name__)
18
+
16
19
  # MGPP Week Bitfield Encoding (from NaviLink APK KDEnum.MgppReservationWeek).
17
20
  # Uses a single byte where bits 1-7 represent days; bit 0 is unused.
18
21
  #
@@ -342,14 +345,18 @@ def decode_reservation_hex(hex_string: str) -> list[dict[str, int]]:
342
345
  data = bytes.fromhex(hex_string)
343
346
  reservations = []
344
347
 
348
+ if len(data) % 6 != 0:
349
+ _logger.warning(
350
+ "Reservation hex data length %d is not a multiple of 6; "
351
+ "trailing %d bytes will be ignored",
352
+ len(data),
353
+ len(data) % 6,
354
+ )
355
+
345
356
  # Process 6 bytes at a time
346
- for i in range(0, len(data), 6):
357
+ for i in range(0, len(data) - (len(data) % 6), 6):
347
358
  chunk = data[i : i + 6]
348
359
 
349
- # Ensure we have a full 6-byte entry
350
- if len(chunk) != 6:
351
- break
352
-
353
360
  # Skip empty entries (all zeros)
354
361
  if all(b == 0 for b in chunk):
355
362
  continue
@@ -425,11 +432,26 @@ def build_reservation_entry(
425
432
  """
426
433
  # Import here to avoid circular import
427
434
  from .models import preferred_to_half_celsius
435
+ from .unit_system import get_unit_system
436
+
437
+ # Read unit system once to keep min/max bounds consistent
438
+ unit_system = get_unit_system()
428
439
 
429
440
  # Use device-provided limits if available, otherwise use defaults
430
- # Defaults are conservative: 95°F / 35°C minimum, 150°F / 65°C maximum
431
- min_temp = temperature_min if temperature_min is not None else 95
432
- max_temp = temperature_max if temperature_max is not None else 150
441
+ # in the user's preferred unit system.
442
+ if temperature_min is not None:
443
+ min_temp = temperature_min
444
+ elif unit_system == "metric":
445
+ min_temp = 35.0 # ~35°C
446
+ else:
447
+ min_temp = 95.0 # 95°F
448
+
449
+ if temperature_max is not None:
450
+ max_temp = temperature_max
451
+ elif unit_system == "metric":
452
+ max_temp = 65.0 # ~65°C
453
+ else:
454
+ max_temp = 150.0 # 150°F
433
455
 
434
456
  if not 0 <= hour <= 23:
435
457
  raise RangeValidationError(
@@ -396,7 +396,7 @@ class EventEmitter:
396
396
  current_temp = temperature_event.new_temperature
397
397
  """
398
398
  future: asyncio.Future[tuple[tuple[Any, ...], dict[str, Any]]] = (
399
- asyncio.Future()
399
+ asyncio.get_running_loop().create_future()
400
400
  )
401
401
 
402
402
  def handler(*args: Any, **kwargs: Any) -> None:
@@ -371,7 +371,10 @@ class NavienMqttClient(EventEmitter):
371
371
  reconnect instead of passively waiting for AWS IoT SDK.
372
372
 
373
373
  Note: This creates a new connection while preserving subscriptions
374
- and configuration.
374
+ and configuration. The old connection is closed first to prevent
375
+ its SDK auto-reconnect from creating a competing connection with
376
+ the same client ID (which causes the broker to kick one off,
377
+ leading to an infinite connect/disconnect loop).
375
378
  """
376
379
  if self._connected:
377
380
  _logger.debug("Already connected, skipping reconnection")
@@ -385,12 +388,15 @@ class NavienMqttClient(EventEmitter):
385
388
 
386
389
  # If we have a connection manager, try to reconnect using it
387
390
  if self._connection_manager:
388
- # The connection might be in a bad state, so we need to
389
- # recreate the underlying connection
391
+ # Close old connection to stop SDK auto-reconnect and
392
+ # prevent two connections with the same client ID.
390
393
  _logger.debug("Recreating MQTT connection...")
394
+ try:
395
+ await self._connection_manager.close()
396
+ except (AwsCrtError, RuntimeError) as e:
397
+ _logger.debug(f"Old connection cleanup (benign): {e}")
391
398
 
392
399
  # Create a new connection manager with same config
393
- old_connection_manager = self._connection_manager
394
400
  self._connection_manager = MqttConnection(
395
401
  config=self.config,
396
402
  auth_client=self._auth_client,
@@ -415,9 +421,6 @@ class NavienMqttClient(EventEmitter):
415
421
 
416
422
  _logger.info("Active reconnection successful")
417
423
  else:
418
- # Restore old connection manager and connection reference
419
- self._connection_manager = old_connection_manager
420
- self._connection = old_connection_manager.connection
421
424
  _logger.warning("Active reconnection failed")
422
425
  else:
423
426
  _logger.warning(
@@ -458,8 +461,7 @@ class NavienMqttClient(EventEmitter):
458
461
  if self._connection_manager:
459
462
  _logger.debug("Cleaning up old connection...")
460
463
  try:
461
- if self._connection_manager.is_connected:
462
- await self._connection_manager.disconnect()
464
+ await self._connection_manager.close()
463
465
  except (AwsCrtError, RuntimeError) as e:
464
466
  # Expected: connection already dead or in bad state
465
467
  _logger.debug(f"Error during cleanup: {e} (expected)")
@@ -1294,14 +1296,20 @@ class NavienMqttClient(EventEmitter):
1294
1296
  return True
1295
1297
 
1296
1298
  # Not cached, request and wait
1297
- future: asyncio.Future[DeviceFeature] = (
1298
- asyncio.get_running_loop().create_future()
1299
- )
1299
+ loop = asyncio.get_running_loop()
1300
+ future: asyncio.Future[DeviceFeature] = loop.create_future()
1300
1301
 
1301
1302
  def on_feature(feature: DeviceFeature) -> None:
1302
- if not future.done():
1303
- _logger.info(f"Device feature received for {redacted_mac}")
1304
- future.set_result(feature)
1303
+ # Called from AWS SDK thread — schedule onto the event loop
1304
+ # thread-safely. The done() check is inside the scheduled
1305
+ # callback so it runs on the event loop thread, eliminating
1306
+ # the race between the check and set_result.
1307
+ def _set_result() -> None:
1308
+ if not future.done():
1309
+ _logger.info(f"Device feature received for {redacted_mac}")
1310
+ future.set_result(feature)
1311
+
1312
+ loop.call_soon_threadsafe(_set_result)
1305
1313
 
1306
1314
  _logger.info(f"Ensuring device info cached for {redacted_mac}")
1307
1315
  await self.subscribe_device_feature(device, on_feature)
@@ -245,6 +245,44 @@ class MqttConnection:
245
245
  _logger.error(f"Error during disconnect: {e}")
246
246
  raise
247
247
 
248
+ async def close(self) -> None:
249
+ """Unconditionally close the underlying SDK connection.
250
+
251
+ Unlike :meth:`disconnect`, this method closes the connection
252
+ regardless of the ``_connected`` flag. After a connection
253
+ interruption, ``_connected`` is ``False`` but the SDK connection
254
+ object is still alive and its built-in auto-reconnect can still
255
+ fire. Calling ``close()`` ensures the SDK connection is fully
256
+ torn down so its callbacks and auto-reconnect cannot interfere
257
+ with a replacement connection.
258
+
259
+ This method is safe to call multiple times or on already-closed
260
+ connections.
261
+ """
262
+ connection = self._connection
263
+ self._connection = None
264
+ self._connected = False
265
+
266
+ if connection is None:
267
+ return
268
+
269
+ _logger.debug("Closing underlying SDK connection...")
270
+ try:
271
+ disconnect_future = cast(
272
+ asyncio.Future[Any], connection.disconnect()
273
+ )
274
+ await asyncio.shield(asyncio.wrap_future(disconnect_future))
275
+ _logger.debug("SDK connection closed")
276
+ except (AwsCrtError, RuntimeError) as e:
277
+ # Expected when connection is already dead or in bad state
278
+ _logger.debug(f"SDK connection close (benign): {e}")
279
+ except asyncio.CancelledError:
280
+ _logger.debug(
281
+ "Close operation cancelled but SDK disconnect "
282
+ "will complete in background"
283
+ )
284
+ raise
285
+
248
286
  async def subscribe(
249
287
  self,
250
288
  topic: str,
@@ -95,7 +95,11 @@ class MqttMetrics:
95
95
 
96
96
  def to_dict(self) -> dict[str, Any]:
97
97
  """Convert to dictionary for JSON serialization."""
98
- return asdict(self)
98
+ d = asdict(self)
99
+ # Replace inf with None for JSON compatibility
100
+ if d.get("shortest_session_seconds") == float("inf"):
101
+ d["shortest_session_seconds"] = None
102
+ return d
99
103
 
100
104
 
101
105
  class MqttDiagnosticsCollector:
@@ -213,6 +217,7 @@ class MqttDiagnosticsCollector:
213
217
 
214
218
  # Update metrics
215
219
  self._metrics.total_connection_drops += 1
220
+ self._metrics.total_reconnect_attempts += 1
216
221
  if error_name:
217
222
  self._metrics.connection_drops_by_error[error_name] = (
218
223
  self._metrics.connection_drops_by_error.get(error_name, 0) + 1
@@ -173,6 +173,12 @@ 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
176
182
 
177
183
  _logger.debug(
178
184
  "Sent periodic %s request for %s",
@@ -233,11 +233,13 @@ class MqttConnectionConfig:
233
233
  max_queued_commands: int = 100
234
234
 
235
235
  def __post_init__(self) -> None:
236
- """Generate client ID if not provided."""
236
+ """Generate client ID if not provided and validate settings."""
237
237
  if not self.client_id:
238
238
  object.__setattr__(
239
239
  self, "client_id", f"navien-client-{uuid.uuid4().hex[:8]}"
240
240
  )
241
+ if self.deep_reconnect_threshold < 1:
242
+ object.__setattr__(self, "deep_reconnect_threshold", 1)
241
243
 
242
244
 
243
245
  @dataclass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 8.0.0
3
+ Version: 8.1.0
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
@@ -175,6 +175,7 @@ src/nwp500_python.egg-info/top_level.txt
175
175
  tests/conftest.py
176
176
  tests/test_api_helpers.py
177
177
  tests/test_auth.py
178
+ tests/test_bug_fixes.py
178
179
  tests/test_cli_basic.py
179
180
  tests/test_cli_commands.py
180
181
  tests/test_command_decorators.py
@@ -187,6 +188,7 @@ tests/test_model_converters.py
187
188
  tests/test_models.py
188
189
  tests/test_mqtt_client_init.py
189
190
  tests/test_mqtt_hypothesis.py
191
+ tests/test_mqtt_reconnection.py
190
192
  tests/test_multi_device.py
191
193
  tests/test_openei.py
192
194
  tests/test_reservations.py