nwp500-python 7.4.7__tar.gz → 7.4.9__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 (183) hide show
  1. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/CHANGELOG.rst +96 -0
  2. {nwp500_python-7.4.7/src/nwp500_python.egg-info → nwp500_python-7.4.9}/PKG-INFO +6 -6
  3. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/scheduling.rst +123 -11
  4. nwp500_python-7.4.9/examples/advanced/firmware_payload_capture.py +211 -0
  5. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/setup.cfg +5 -5
  6. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/__init__.py +11 -0
  7. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/auth.py +35 -6
  8. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/cli/__main__.py +1 -1
  9. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/cli/handlers.py +42 -179
  10. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/events.py +2 -2
  11. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/factory.py +14 -5
  12. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/control.py +6 -3
  13. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/subscriptions.py +30 -14
  14. nwp500_python-7.4.9/src/nwp500/reservations.py +313 -0
  15. {nwp500_python-7.4.7 → nwp500_python-7.4.9/src/nwp500_python.egg-info}/PKG-INFO +6 -6
  16. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/SOURCES.txt +3 -0
  17. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/requires.txt +5 -5
  18. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_auth.py +15 -14
  19. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_mqtt_client_init.py +10 -10
  20. nwp500_python-7.4.9/tests/test_reservations.py +396 -0
  21. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/.coveragerc +0 -0
  22. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  23. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/.github/copilot-instructions.md +0 -0
  24. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/.github/workflows/ci.yml +0 -0
  25. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/.github/workflows/release.yml +0 -0
  26. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/.gitignore +0 -0
  27. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/.pre-commit-config.yaml +0 -0
  28. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/.readthedocs.yml +0 -0
  29. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/AUTHORS.rst +0 -0
  30. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/CONTRIBUTING.rst +0 -0
  31. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/LICENSE.txt +0 -0
  32. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/Makefile +0 -0
  33. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/README.rst +0 -0
  34. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/RELEASE.md +0 -0
  35. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/Makefile +0 -0
  36. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/_static/.gitignore +0 -0
  37. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/api/nwp500.rst +0 -0
  38. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/authors.rst +0 -0
  39. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/changelog.rst +0 -0
  40. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/conf.py +0 -0
  41. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/configuration.rst +0 -0
  42. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/development/contributing.rst +0 -0
  43. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/development/history.rst +0 -0
  44. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/enumerations.rst +0 -0
  45. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/advanced_features_explained.rst +0 -0
  46. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/authentication.rst +0 -0
  47. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/auto_recovery.rst +0 -0
  48. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/command_queue.rst +0 -0
  49. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/energy_monitoring.rst +0 -0
  50. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/event_system.rst +0 -0
  51. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/home_assistant_integration.rst +0 -0
  52. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/mqtt_diagnostics.rst +0 -0
  53. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/time_of_use.rst +0 -0
  54. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/guides/unit_conversion.rst +0 -0
  55. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/index.rst +0 -0
  56. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/installation.rst +0 -0
  57. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/license.rst +0 -0
  58. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/openapi.yaml +0 -0
  59. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/protocol/data_conversions.rst +0 -0
  60. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/protocol/device_features.rst +0 -0
  61. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/protocol/device_status.rst +0 -0
  62. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/protocol/error_codes.rst +0 -0
  63. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/protocol/mqtt_protocol.rst +0 -0
  64. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/protocol/quick_reference.rst +0 -0
  65. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/protocol/rest_api.rst +0 -0
  66. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/python_api/api_client.rst +0 -0
  67. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/python_api/auth_client.rst +0 -0
  68. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/python_api/cli.rst +0 -0
  69. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/python_api/device_control.rst +0 -0
  70. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/python_api/events.rst +0 -0
  71. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/python_api/exceptions.rst +0 -0
  72. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/python_api/models.rst +0 -0
  73. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/python_api/mqtt_client.rst +0 -0
  74. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/quickstart.rst +0 -0
  75. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/docs/requirements.txt +0 -0
  76. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/.ruff.toml +0 -0
  77. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/README.md +0 -0
  78. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/air_filter_reset.py +0 -0
  79. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/anti_legionella.py +0 -0
  80. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/auto_recovery.py +0 -0
  81. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/combined_callbacks.py +0 -0
  82. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/demand_response.py +0 -0
  83. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/device_capabilities.py +0 -0
  84. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/device_status_debug.py +0 -0
  85. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/energy_analytics.py +0 -0
  86. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/error_code_demo.py +0 -0
  87. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/mqtt_diagnostics.py +0 -0
  88. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/power_control.py +0 -0
  89. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/recirculation_control.py +0 -0
  90. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/reconnection_demo.py +0 -0
  91. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/reservation_schedule.py +0 -0
  92. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/simple_auto_recovery.py +0 -0
  93. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/token_restoration.py +0 -0
  94. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/tou_openei.py +0 -0
  95. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/tou_schedule.py +0 -0
  96. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/advanced/water_reservation.py +0 -0
  97. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/beginner/01_authentication.py +0 -0
  98. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/beginner/02_list_devices.py +0 -0
  99. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/beginner/03_get_status.py +0 -0
  100. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/beginner/04_set_temperature.py +0 -0
  101. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/advanced_auth_patterns.py +0 -0
  102. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/command_queue.py +0 -0
  103. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/device_status_callback.py +0 -0
  104. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/error_handling.py +0 -0
  105. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/event_driven_control.py +0 -0
  106. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/improved_auth.py +0 -0
  107. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/legacy_auth_constructor.py +0 -0
  108. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  109. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/periodic_requests.py +0 -0
  110. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/set_mode.py +0 -0
  111. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/intermediate/vacation_mode.py +0 -0
  112. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/mask.py +0 -0
  113. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/testing/periodic_device_info.py +0 -0
  114. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/testing/simple_periodic_info.py +0 -0
  115. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/testing/test_api_client.py +0 -0
  116. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/testing/test_mqtt_connection.py +0 -0
  117. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/testing/test_mqtt_messaging.py +0 -0
  118. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/examples/testing/test_periodic_minimal.py +0 -0
  119. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/pyproject.toml +0 -0
  120. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/scripts/README.md +0 -0
  121. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/scripts/bump_version.py +0 -0
  122. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/scripts/diagnose_mqtt_connection.py +0 -0
  123. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/scripts/extract_changelog.py +0 -0
  124. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/scripts/format.py +0 -0
  125. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/scripts/lint.py +0 -0
  126. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/scripts/setup-dev.py +0 -0
  127. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/scripts/validate_version.py +0 -0
  128. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/setup.py +0 -0
  129. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/api_client.py +0 -0
  130. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/cli/__init__.py +0 -0
  131. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/cli/commands.py +0 -0
  132. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/cli/monitoring.py +0 -0
  133. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/cli/output_formatters.py +0 -0
  134. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/cli/rich_output.py +0 -0
  135. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/cli/token_storage.py +0 -0
  136. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/command_decorators.py +0 -0
  137. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/config.py +0 -0
  138. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/converters.py +0 -0
  139. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/device_capabilities.py +0 -0
  140. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/device_info_cache.py +0 -0
  141. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/encoding.py +0 -0
  142. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/enums.py +0 -0
  143. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/exceptions.py +0 -0
  144. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/field_factory.py +0 -0
  145. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/models.py +0 -0
  146. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/__init__.py +0 -0
  147. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/client.py +0 -0
  148. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/command_queue.py +0 -0
  149. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/connection.py +0 -0
  150. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/diagnostics.py +0 -0
  151. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/periodic.py +0 -0
  152. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/reconnection.py +0 -0
  153. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt/utils.py +0 -0
  154. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/mqtt_events.py +0 -0
  155. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/openei.py +0 -0
  156. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/py.typed +0 -0
  157. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/temperature.py +0 -0
  158. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/topic_builder.py +0 -0
  159. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/unit_system.py +0 -0
  160. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500/utils.py +0 -0
  161. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  162. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  163. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  164. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/top_level.txt +0 -0
  165. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/conftest.py +0 -0
  166. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_api_helpers.py +0 -0
  167. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_cli_basic.py +0 -0
  168. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_cli_commands.py +0 -0
  169. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_command_decorators.py +0 -0
  170. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_command_queue.py +0 -0
  171. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_device_capabilities.py +0 -0
  172. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_device_info_cache.py +0 -0
  173. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_events.py +0 -0
  174. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_exceptions.py +0 -0
  175. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_model_converters.py +0 -0
  176. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_models.py +0 -0
  177. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_mqtt_hypothesis.py +0 -0
  178. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_openei.py +0 -0
  179. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_temperature_converters.py +0 -0
  180. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_tou_api.py +0 -0
  181. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_unit_switching.py +0 -0
  182. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tests/test_utils.py +0 -0
  183. {nwp500_python-7.4.7 → nwp500_python-7.4.9}/tox.ini +0 -0
@@ -2,6 +2,102 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
+ Version 7.4.9 (2026-04-12)
6
+ ==========================
7
+
8
+ Added
9
+ -----
10
+ - **Firmware Payload Capture Tool**: New example script
11
+ ``examples/advanced/firmware_payload_capture.py`` for capturing raw MQTT
12
+ payloads to detect firmware-introduced protocol changes. Subscribes to all
13
+ response and event topics via wildcards, requests the full scheduling
14
+ data set (weekly reservations, TOU, device info), and saves everything to a
15
+ timestamped JSON file suitable for ``jq``/``diff`` comparison across firmware
16
+ versions.
17
+
18
+ Fixed
19
+ -----
20
+ - **Timezone-naive datetime in token expiry checks**: ``AuthTokens.is_expired``,
21
+ ``are_aws_credentials_expired``, and ``time_until_expiry`` used
22
+ ``datetime.now()`` (naive, local time). During DST transitions or timezone
23
+ changes this could cause incorrect expiry detection, leading to premature
24
+ re-authentication or use of an actually-expired token. Fixed by using
25
+ ``datetime.now(UTC)`` throughout, switching the ``issued_at`` field default
26
+ to ``datetime.now(UTC)``, and adding a field validator to normalize any
27
+ timezone-naive ``issued_at`` values loaded from old stored token files to UTC
28
+ (previously this would raise a ``TypeError`` at comparison time). The
29
+ validator was further extended to also handle ISO 8601 strings without
30
+ timezone info (e.g. ``"2026-02-17T14:47:01.686943"``), which is the actual
31
+ format written by ``to_dict()`` for tokens stored before this fix.
32
+ - **Vacation mode sent wrong MQTT command**: ``set_vacation_days()`` used
33
+ ``CommandCode.GOOUT_DAY`` (33554466), which the device silently accepted
34
+ but did not activate vacation mode — the operating mode remained unchanged.
35
+ HAR capture of the official Navien app confirms the correct command is
36
+ ``DHW_MODE`` (33554437) with ``param=[5, days]``
37
+ (``DhwOperationSetting.VACATION``). The valid range has also been corrected
38
+ from 1–365 to 1–30 to match the device's actual constraint.
39
+ - **Duplicate AWS IoT subscribe calls on reconnect**: ``resubscribe_all()``
40
+ called ``connection.subscribe()`` (a network round-trip to AWS IoT) once per
41
+ handler per topic. If a topic had N handlers, N identical subscribe requests
42
+ were sent on every reconnect. Fixed by making one network call per unique
43
+ topic and registering remaining handlers directly into ``_message_handlers``.
44
+ - **Anti-Legionella set-period State Preservation**: ``nwp-cli anti-legionella
45
+ set-period`` was calling ``enable_anti_legionella()`` in both the enabled and
46
+ disabled branches, silently re-enabling the feature when it was off. The
47
+ command now informs the user that the period can only be updated while the
48
+ feature is enabled and directs them to ``anti-legionella enable``.
49
+ - **Subscription State Lost After Failed Resubscription**: ``resubscribe_all()``
50
+ cleared ``_subscriptions`` and ``_message_handlers`` before the re-subscribe
51
+ loop. Topics that failed to resubscribe were permanently dropped from internal
52
+ state and could not be retried on the next reconnection. Failed topics are now
53
+ restored so they are retried automatically.
54
+ - **Unit System Detection Returns None on Timeout**: ``_detect_unit_system()``
55
+ declared return type ``UnitSystemType`` but returned ``None`` on
56
+ ``TimeoutError``, violating the type contract. Now returns
57
+ ``"us_customary"`` consistent with the warning message.
58
+ - **Once-Listener Becomes Permanent With Duplicate Callbacks**: ``emit()``
59
+ identified once-listeners via a ``set`` of ``(event, callback)`` tuples. If
60
+ the same callback was registered twice with ``once=True``, the set
61
+ deduplicated the tuple — after the first emit the second listener lost its
62
+ once-status and became permanent. Fixed by checking ``listener.once``
63
+ directly on the ``EventListener`` object.
64
+ - **Auth Session Leaked on Client Construction Failure**: In
65
+ ``create_navien_clients()``, if ``NavienAPIClient`` or
66
+ ``NavienMqttClient`` construction raised after a successful
67
+ ``auth_client.__aenter__()``, the auth session and its underlying
68
+ ``aiohttp`` session would leak. Client construction is now wrapped in a
69
+ ``try/except`` that calls ``auth_client.__aexit__()`` on failure.
70
+ Additionally, both ``except BaseException`` blocks have been replaced with
71
+ ``except Exception`` (passing real exception info to ``__aexit__``) plus a
72
+ separate ``except asyncio.CancelledError`` block that uses
73
+ ``asyncio.shield()`` to ensure cleanup completes even when the task is
74
+ being cancelled.
75
+ - **Hypothesis Tests Broke All Test Collection**: ``test_mqtt_hypothesis.py``
76
+ imported ``hypothesis`` at module level; when it was not installed, pytest
77
+ failed to collect every test in the suite. ``hypothesis`` is now mandated
78
+ as a ``[testing]`` extra dependency, restoring correct collection behaviour.
79
+
80
+ Changed
81
+ -------
82
+ - **Dependency updates**: Bumped minimum versions to track current releases:
83
+ ``aiohttp >= 3.13.5``, ``pydantic >= 2.12.5``, ``click >= 8.3.0``,
84
+ ``rich >= 14.3.0``.
85
+ - **Dependency: awsiotsdk >= 1.28.2**: Bumped minimum ``awsiotsdk`` version
86
+ from ``>=1.27.0`` to ``>=1.28.2`` to track the current patch release.
87
+ ``awscrt`` 0.31.3 is pulled in transitively.
88
+
89
+ Version 7.4.8 (2026-02-17)
90
+ ==========================
91
+
92
+ Added
93
+ -----
94
+ - **Reservation CRUD Helpers**: New public functions ``fetch_reservations()``,
95
+ ``add_reservation()``, ``delete_reservation()``, and ``update_reservation()``
96
+ in ``nwp500.reservations`` (and exported from ``nwp500``). These abstract the
97
+ read-modify-write pattern for single-entry schedule management so library
98
+ users no longer need to fetch the full schedule, splice it manually, and send
99
+ it back. The CLI now delegates to these library functions.
100
+
5
101
  Version 7.4.7 (2026-02-17)
6
102
  ==========================
7
103
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 7.4.7
3
+ Version: 7.4.9
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
@@ -18,12 +18,12 @@ Classifier: Programming Language :: Python :: 3 :: Only
18
18
  Requires-Python: >=3.13
19
19
  Description-Content-Type: text/x-rst; charset=UTF-8
20
20
  License-File: LICENSE.txt
21
- Requires-Dist: aiohttp>=3.8.0
22
- Requires-Dist: awsiotsdk>=1.27.0
23
- Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: aiohttp>=3.13.5
22
+ Requires-Dist: awsiotsdk>=1.28.2
23
+ Requires-Dist: pydantic>=2.12.5
24
24
  Provides-Extra: cli
25
- Requires-Dist: click>=8.0.0; extra == "cli"
26
- Requires-Dist: rich>=13.0.0; extra == "cli"
25
+ Requires-Dist: click>=8.3.0; extra == "cli"
26
+ Requires-Dist: rich>=14.3.0; extra == "cli"
27
27
  Provides-Extra: testing
28
28
  Requires-Dist: setuptools; extra == "testing"
29
29
  Requires-Dist: pytest; extra == "testing"
@@ -325,13 +325,19 @@ Managing Reservations
325
325
  **Important:** The device protocol requires sending the **full list**
326
326
  of reservations for every update. Individual add/delete/update
327
327
  operations work by fetching the current schedule, modifying it, and
328
- sending the full list back. The CLI and Python helpers handle this
329
- automatically.
328
+ sending the full list back.
330
329
 
331
- **Update the full schedule:**
330
+ Low-Level Method (``NavienMqttClient``)
331
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
332
+
333
+ Use ``update_reservations()`` when you need full control or are managing
334
+ multiple entries at once:
332
335
 
333
336
  .. code-block:: python
334
337
 
338
+ from nwp500.mqtt import NavienMqttClient
339
+ from nwp500.encoding import build_reservation_entry
340
+
335
341
  reservations = [
336
342
  build_reservation_entry(
337
343
  enabled=True,
@@ -358,22 +364,128 @@ automatically.
358
364
  device, [], enabled=False
359
365
  )
360
366
 
367
+ **Request current schedule:**
368
+
369
+ .. code-block:: python
370
+
371
+ await mqtt.control.request_reservations(device)
372
+
361
373
  **Read the current schedule using models:**
362
374
 
363
375
  .. code-block:: python
364
376
 
365
377
  from nwp500 import ReservationSchedule
366
378
 
367
- # Subscribe and request
379
+ # Subscribe to responses
380
+ def on_reservations(schedule: ReservationSchedule) -> None:
381
+ print(f"Enabled: {schedule.enabled}")
382
+ for entry in schedule.reservation:
383
+ print(f" {entry.time} - {', '.join(entry.days)}"
384
+ f" - {entry.temperature}{entry.unit}"
385
+ f" - {entry.mode_name}")
386
+
387
+ await mqtt.subscribe_device_feature(device, on_reservations)
368
388
  await mqtt.control.request_reservations(device)
369
389
 
370
- # In the callback, parse with the model:
371
- schedule = ReservationSchedule(**response)
372
- print(f"Enabled: {schedule.enabled}")
373
- for entry in schedule.reservation:
374
- print(f" {entry.time} - {', '.join(entry.days)}"
375
- f" - {entry.temperature}{entry.unit}"
376
- f" - {entry.mode_name}")
390
+ CLI Helpers
391
+ ^^^^^^^^^^^
392
+
393
+ The CLI provides convenience commands:
394
+
395
+ **List current reservations:**
396
+
397
+ .. code-block:: bash
398
+
399
+ nwp-cli reservations get # Formatted table
400
+ nwp-cli reservations get --json # JSON output
401
+
402
+ **Add a single reservation:**
403
+
404
+ .. code-block:: bash
405
+
406
+ nwp-cli reservations add --days MO,TU,WE,TH,FR \
407
+ --hour 6 --minute 30 --mode 4 --temperature 60
408
+
409
+ **Update an existing reservation:**
410
+
411
+ .. code-block:: bash
412
+
413
+ nwp-cli reservations update --mode 3 --temperature 58 1
414
+
415
+ **Delete a reservation:**
416
+
417
+ .. code-block:: bash
418
+
419
+ nwp-cli reservations delete 1
420
+
421
+ Library Helpers
422
+ ^^^^^^^^^^^^^^^^
423
+
424
+ The library provides convenience functions that abstract the
425
+ read-modify-write pattern for individual reservation entries.
426
+
427
+ **fetch_reservations()** — Retrieve the current schedule:
428
+
429
+ .. code-block:: python
430
+
431
+ from nwp500 import fetch_reservations
432
+
433
+ schedule = await fetch_reservations(mqtt, device)
434
+ if schedule is not None:
435
+ print(f"Schedule enabled: {schedule.enabled}")
436
+ for entry in schedule.reservation:
437
+ print(f" {entry.time} {', '.join(entry.days)}"
438
+ f" — {entry.temperature}{entry.unit}"
439
+ f" — {entry.mode_name}")
440
+
441
+ **add_reservation()** — Append a new entry to the schedule:
442
+
443
+ .. code-block:: python
444
+
445
+ from nwp500 import add_reservation
446
+
447
+ await add_reservation(
448
+ mqtt, device,
449
+ enabled=True,
450
+ days=["MO", "TU", "WE", "TH", "FR"],
451
+ hour=6,
452
+ minute=30,
453
+ mode=4, # High Demand
454
+ temperature=60.0, # In user's preferred unit
455
+ )
456
+
457
+ **delete_reservation()** — Remove an entry by 1-based index:
458
+
459
+ .. code-block:: python
460
+
461
+ from nwp500 import delete_reservation
462
+
463
+ await delete_reservation(mqtt, device, index=2)
464
+
465
+ **update_reservation()** — Modify specific fields of an existing entry.
466
+ Only the keyword arguments you supply are changed; all others are kept:
467
+
468
+ .. code-block:: python
469
+
470
+ from nwp500 import update_reservation
471
+
472
+ # Change temperature only
473
+ await update_reservation(mqtt, device, 1, temperature=55.0)
474
+
475
+ # Change days and time
476
+ await update_reservation(mqtt, device, 1, days=["SA", "SU"], hour=8, minute=0)
477
+
478
+ # Disable without deleting
479
+ await update_reservation(mqtt, device, 1, enabled=False)
480
+
481
+ These helpers raise :class:`ValueError` for out-of-range arguments,
482
+ :class:`~nwp500.exceptions.RangeValidationError` or
483
+ :class:`~nwp500.exceptions.ValidationError` for device-protocol
484
+ violations. :func:`fetch_reservations` returns ``None`` on timeout and
485
+ logs the failure, while the mutating helpers (:func:`add_reservation`,
486
+ :func:`update_reservation`, :func:`delete_reservation`) raise
487
+ :class:`TimeoutError` if the device does not respond.
488
+
377
489
 
378
490
  Mode Selection Strategy
379
491
  -----------------------
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Firmware Payload Capture Tool.
4
+
5
+ Captures raw MQTT payloads for all scheduling-related topics and dumps them
6
+ to a timestamped JSON file. Use this to detect changes introduced by firmware
7
+ updates by diffing captures taken before and after an update.
8
+
9
+ Specifically captures:
10
+ - Weekly reservations (rsv/rd)
11
+ - Time-of-Use schedule (tou/rd)
12
+ - Device info (firmware versions, capabilities)
13
+ - Device status (current operating state)
14
+ - All other response/event topics (via wildcards)
15
+
16
+ Usage:
17
+ NAVIEN_EMAIL=your@email.com NAVIEN_PASSWORD=password python3 firmware_payload_capture.py
18
+
19
+ Output:
20
+ payload_capture_YYYYMMDD_HHMMSS.json — all captured payloads with topics
21
+ and timestamps. Sensitive fields
22
+ (MAC address, session IDs, client
23
+ IDs) are redacted in the output.
24
+
25
+ Comparing two captures to find firmware changes:
26
+ diff <(jq '.payloads[] | select(.topic | contains("rsv"))' before.json) \\
27
+ <(jq '.payloads[] | select(.topic | contains("rsv"))' after.json)
28
+ """
29
+
30
+ import asyncio
31
+ import json
32
+ import logging
33
+ import os
34
+ import sys
35
+ from datetime import UTC, datetime
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient
40
+ from nwp500.models import DeviceFeature
41
+ from nwp500.mqtt.utils import redact, redact_topic
42
+ from nwp500.topic_builder import MqttTopicBuilder
43
+
44
+ logging.basicConfig(
45
+ level=logging.WARNING,
46
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
47
+ )
48
+ _logger = logging.getLogger(__name__)
49
+
50
+
51
+ class PayloadCapture:
52
+ """Captures and records raw MQTT payloads."""
53
+
54
+ def __init__(self) -> None:
55
+ self.payloads: list[dict[str, Any]] = []
56
+
57
+ def record(self, topic: str, message: dict[str, Any]) -> None:
58
+ entry = {
59
+ "timestamp": datetime.now(UTC).isoformat(),
60
+ "topic": topic,
61
+ "payload": message,
62
+ }
63
+ self.payloads.append(entry)
64
+ print(f" ← {redact_topic(topic)}")
65
+
66
+ def save(self, path: Path) -> None:
67
+ # Redact sensitive fields (MAC, session IDs, client IDs) before saving
68
+ # so the output file is safe to share. Protocol structure and payload
69
+ # field values used for firmware analysis are preserved.
70
+ redacted_payloads = [
71
+ {
72
+ "timestamp": e["timestamp"],
73
+ "topic": redact_topic(e["topic"]),
74
+ "payload": redact(e["payload"]),
75
+ }
76
+ for e in self.payloads
77
+ ]
78
+ data = {
79
+ "captured_at": datetime.now(UTC).isoformat(),
80
+ "total_payloads": len(self.payloads),
81
+ "payloads": redacted_payloads,
82
+ }
83
+ path.write_text(json.dumps(data, indent=2, default=str))
84
+ print(f"\nSaved {len(self.payloads)} payloads → {path}")
85
+
86
+
87
+ async def main() -> None:
88
+ email = os.getenv("NAVIEN_EMAIL")
89
+ password = os.getenv("NAVIEN_PASSWORD")
90
+
91
+ if not email or not password:
92
+ print("Error: set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables")
93
+ sys.exit(1)
94
+
95
+ capture = PayloadCapture()
96
+
97
+ async with NavienAuthClient(email, password) as auth_client:
98
+ api_client = NavienAPIClient(auth_client=auth_client)
99
+ device = await api_client.get_first_device()
100
+ if not device:
101
+ print("No devices found for this account")
102
+ return
103
+
104
+ device_type = str(device.device_info.device_type)
105
+ mac = device.device_info.mac_address
106
+ print(f"Device: {device.device_info.device_name} [{device_type}]")
107
+
108
+ mqtt_client = NavienMqttClient(auth_client)
109
+ await mqtt_client.connect()
110
+
111
+ client_id = mqtt_client.client_id
112
+
113
+ # --- Wildcard subscriptions to catch everything ---
114
+
115
+ # All response messages back to this client
116
+ res_wildcard = MqttTopicBuilder.response_topic(device_type, client_id, "#")
117
+ # All event messages pushed by the device
118
+ evt_wildcard = MqttTopicBuilder.event_topic(device_type, mac, "#")
119
+
120
+ print(
121
+ f"\nSubscribing to:\n {redact_topic(res_wildcard)}\n"
122
+ f" {redact_topic(evt_wildcard)}\n"
123
+ )
124
+ print("Captured topics:")
125
+
126
+ await mqtt_client.subscribe(res_wildcard, capture.record)
127
+ await mqtt_client.subscribe(evt_wildcard, capture.record)
128
+
129
+ # --- Step 1: fetch device info (needed for firmware version + serial) ---
130
+ device_info_event: asyncio.Event = asyncio.Event()
131
+ device_feature: DeviceFeature | None = None
132
+
133
+ def on_feature(feature: DeviceFeature) -> None:
134
+ nonlocal device_feature
135
+ device_feature = feature
136
+ device_info_event.set()
137
+
138
+ await mqtt_client.subscribe_device_feature(device, on_feature)
139
+ await mqtt_client.control.request_device_info(device)
140
+ await asyncio.wait_for(device_info_event.wait(), timeout=30.0)
141
+
142
+ if device_feature:
143
+ print(
144
+ f"\nFirmware: controller={device_feature.controller_sw_version} "
145
+ f"panel={device_feature.panel_sw_version} "
146
+ f"wifi={device_feature.wifi_sw_version}"
147
+ )
148
+
149
+ # --- Step 2: request device status ---
150
+ await mqtt_client.control.request_device_status(device)
151
+ await asyncio.sleep(3)
152
+
153
+ # --- Step 3: request reservation (weekly) schedule ---
154
+ print("\nRequesting weekly reservation schedule...")
155
+ await mqtt_client.control.request_reservations(device)
156
+ await asyncio.sleep(5)
157
+
158
+ # --- Step 4: request TOU schedule (requires controller serial number) ---
159
+ if device_feature and device_feature.program_reservation_use:
160
+ serial = device_feature.controller_serial_number
161
+ if serial:
162
+ print("Requesting TOU schedule...")
163
+ try:
164
+ await mqtt_client.control.request_tou_settings(device, serial)
165
+ await asyncio.sleep(5)
166
+ except Exception as exc:
167
+ print(f" TOU request failed: {exc}")
168
+
169
+ # --- Step 5: wait a bit more to catch any late-arriving messages ---
170
+ print("\nWaiting for any remaining messages...")
171
+ await asyncio.sleep(5)
172
+
173
+ await mqtt_client.disconnect()
174
+
175
+ # --- Save results ---
176
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
177
+ output_path = Path(f"payload_capture_{timestamp}.json")
178
+ capture.save(output_path)
179
+
180
+ # Print a summary grouped by topic
181
+ print("\n--- Summary by topic ---")
182
+ by_topic: dict[str, int] = {}
183
+ for entry in capture.payloads:
184
+ by_topic[entry["topic"]] = by_topic.get(entry["topic"], 0) + 1
185
+ for topic, count in sorted(by_topic.items()):
186
+ print(f" {count:2d}x {redact_topic(topic)}")
187
+
188
+ if device_feature:
189
+ print(
190
+ f"\nFirmware captured: controller_sw_version="
191
+ f"{device_feature.controller_sw_version}"
192
+ )
193
+ print(
194
+ "Compare this file against a capture from a different firmware version "
195
+ "to detect scheduling changes.\n"
196
+ "Useful diff command:\n"
197
+ " diff <(jq '.payloads[] | select(.topic | contains(\"rsv\"))' "
198
+ f"before.json) \\\n"
199
+ " <(jq '.payloads[] | select(.topic | contains(\"rsv\"))' "
200
+ f"{output_path})"
201
+ )
202
+
203
+
204
+ if __name__ == "__main__":
205
+ try:
206
+ asyncio.run(main())
207
+ except KeyboardInterrupt:
208
+ print("\nCancelled by user")
209
+ except TimeoutError:
210
+ print("\nError: timed out waiting for device response. Is the device online?")
211
+ sys.exit(1)
@@ -28,9 +28,9 @@ package_dir =
28
28
  =src
29
29
  python_requires = >=3.13
30
30
  install_requires =
31
- aiohttp>=3.8.0
32
- awsiotsdk>=1.27.0
33
- pydantic>=2.0.0
31
+ aiohttp>=3.13.5
32
+ awsiotsdk>=1.28.2
33
+ pydantic>=2.12.5
34
34
 
35
35
  [options.packages.find]
36
36
  where = src
@@ -39,8 +39,8 @@ exclude =
39
39
 
40
40
  [options.extras_require]
41
41
  cli =
42
- click>=8.0.0
43
- rich>=13.0.0
42
+ click>=8.3.0
43
+ rich>=14.3.0
44
44
  testing =
45
45
  setuptools
46
46
  pytest
@@ -134,6 +134,12 @@ from nwp500.mqtt_events import (
134
134
  from nwp500.openei import (
135
135
  OpenEIClient,
136
136
  )
137
+ from nwp500.reservations import (
138
+ add_reservation,
139
+ delete_reservation,
140
+ fetch_reservations,
141
+ update_reservation,
142
+ )
137
143
  from nwp500.unit_system import (
138
144
  get_unit_system,
139
145
  reset_unit_system,
@@ -223,6 +229,11 @@ __all__ = [
223
229
  "NavienAPIClient",
224
230
  # OpenEI Client
225
231
  "OpenEIClient",
232
+ # Reservation helpers
233
+ "fetch_reservations",
234
+ "add_reservation",
235
+ "delete_reservation",
236
+ "update_reservation",
226
237
  # MQTT Client
227
238
  "NavienMqttClient",
228
239
  "MqttConnectionConfig",
@@ -15,11 +15,18 @@ from __future__ import annotations
15
15
 
16
16
  import json
17
17
  import logging
18
- from datetime import datetime, timedelta
18
+ from datetime import UTC, datetime, timedelta
19
19
  from typing import Any, Literal, Self, cast
20
20
 
21
21
  import aiohttp
22
- from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
22
+ from pydantic import (
23
+ BaseModel,
24
+ ConfigDict,
25
+ Field,
26
+ PrivateAttr,
27
+ field_validator,
28
+ model_validator,
29
+ )
23
30
  from pydantic.alias_generators import to_camel
24
31
 
25
32
  from . import __version__
@@ -79,11 +86,31 @@ class AuthTokens(NavienBaseModel):
79
86
  authorization_expires_in: int | None = None
80
87
 
81
88
  # Calculated fields
82
- issued_at: datetime = Field(default_factory=datetime.now)
89
+ issued_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
83
90
 
84
91
  _expires_at: datetime = PrivateAttr()
85
92
  _aws_expires_at: datetime | None = PrivateAttr(default=None)
86
93
 
94
+ @field_validator("issued_at", mode="before")
95
+ @classmethod
96
+ def _normalize_issued_at_tz(cls, v: Any) -> Any:
97
+ """Assume UTC for timezone-naive datetimes.
98
+
99
+ Handles old stored tokens that may not have timezone info,
100
+ whether provided as a datetime object or an ISO 8601 string.
101
+ """
102
+ if isinstance(v, str) and not v.endswith("Z"):
103
+ # Check for a timezone offset (+HH:MM or -HH:MM) in the time
104
+ # portion only (after the 'T' separator), so that date-part hyphens
105
+ # like "2026-02-17" are not mistaken for a negative offset.
106
+ t_pos = v.find("T")
107
+ time_part = v[t_pos + 1 :] if t_pos >= 0 else v
108
+ if "+" not in time_part and "-" not in time_part:
109
+ return v + "+00:00"
110
+ if isinstance(v, datetime) and v.tzinfo is None:
111
+ return v.replace(tzinfo=UTC)
112
+ return v
113
+
87
114
  @model_validator(mode="before")
88
115
  @classmethod
89
116
  def handle_empty_aliases(cls, data: Any) -> Any:
@@ -159,7 +186,7 @@ class AuthTokens(NavienBaseModel):
159
186
  def is_expired(self) -> bool:
160
187
  """Check if the access token has expired (cached calculation)."""
161
188
  # Consider expired if within 5 minutes of expiration
162
- return datetime.now() >= (self._expires_at - timedelta(minutes=5))
189
+ return datetime.now(UTC) >= (self._expires_at - timedelta(minutes=5))
163
190
 
164
191
  @property
165
192
  def are_aws_credentials_expired(self) -> bool:
@@ -178,7 +205,9 @@ class AuthTokens(NavienBaseModel):
178
205
  # This handles cases where authorization_expires_in wasn't provided
179
206
  return False
180
207
  # Consider expired if within 5 minutes of expiration
181
- return datetime.now() >= (self._aws_expires_at - timedelta(minutes=5))
208
+ return datetime.now(UTC) >= (
209
+ self._aws_expires_at - timedelta(minutes=5)
210
+ )
182
211
 
183
212
  @property
184
213
  def time_until_expiry(self) -> timedelta:
@@ -186,7 +215,7 @@ class AuthTokens(NavienBaseModel):
186
215
 
187
216
  Uses cached expiration time for efficiency.
188
217
  """
189
- return self._expires_at - datetime.now()
218
+ return self._expires_at - datetime.now(UTC)
190
219
 
191
220
  @property
192
221
  def bearer_token(self) -> str:
@@ -66,7 +66,7 @@ async def _detect_unit_system(
66
66
  _logger.warning(
67
67
  "Timed out detecting unit system, defaulting to us_customary"
68
68
  )
69
- return None
69
+ return "us_customary"
70
70
 
71
71
 
72
72
  def async_command(f: Any) -> Any: