nwp500-python 9.3.0__tar.gz → 9.3.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 (225) hide show
  1. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/CHANGELOG.rst +99 -0
  2. {nwp500_python-9.3.0/src/nwp500_python.egg-info → nwp500_python-9.3.1}/PKG-INFO +1 -1
  3. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/optimize-tou.rst +72 -57
  4. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/openapi.yaml +31 -1
  5. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/python_api/models.rst +52 -0
  6. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/python_api/mqtt_client.rst +4 -11
  7. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/firmware_payload_capture.py +18 -14
  8. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/tou_schedule.py +4 -4
  9. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/__init__.py +4 -0
  10. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/cli/__main__.py +74 -15
  11. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/cli/handlers.py +9 -5
  12. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/models/__init__.py +4 -0
  13. nwp500_python-9.3.1/src/nwp500/models/device.py +101 -0
  14. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/models/feature.py +6 -1
  15. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/models/tou.py +6 -5
  16. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/_control_commands.py +0 -8
  17. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/_device_subscriptions.py +6 -5
  18. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/control.py +0 -32
  19. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/subscriptions.py +7 -6
  20. {nwp500_python-9.3.0 → nwp500_python-9.3.1/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  21. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
  22. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500_python.egg-info/scm_file_list.json +175 -174
  23. nwp500_python-9.3.1/src/nwp500_python.egg-info/scm_version.json +8 -0
  24. nwp500_python-9.3.1/tests/test_cli_commands.py +441 -0
  25. nwp500_python-9.3.1/tests/test_device_rest_models.py +109 -0
  26. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_models.py +49 -0
  27. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_public_api.py +13 -0
  28. nwp500_python-9.3.0/src/nwp500/models/device.py +0 -57
  29. nwp500_python-9.3.0/src/nwp500_python.egg-info/scm_version.json +0 -8
  30. nwp500_python-9.3.0/tests/test_cli_commands.py +0 -220
  31. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/.coveragerc +0 -0
  32. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  33. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/.github/copilot-instructions.md +0 -0
  34. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/.github/workflows/ci.yml +0 -0
  35. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/.github/workflows/release.yml +0 -0
  36. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/.gitignore +0 -0
  37. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/.pre-commit-config.yaml +0 -0
  38. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/.readthedocs.yml +0 -0
  39. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/AUTHORS.rst +0 -0
  40. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/CONTRIBUTING.rst +0 -0
  41. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/LICENSE.txt +0 -0
  42. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/Makefile +0 -0
  43. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/README.rst +0 -0
  44. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/RELEASE.md +0 -0
  45. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/Makefile +0 -0
  46. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/_static/.gitignore +0 -0
  47. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/conf.py +0 -0
  48. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/explanation/advanced-features.rst +0 -0
  49. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/explanation/architecture.rst +0 -0
  50. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/explanation/index.rst +0 -0
  51. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/explanation/tank-energy.rst +0 -0
  52. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/explanation/unknown-values.rst +0 -0
  53. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/authenticate.rst +0 -0
  54. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/auto-recovery.rst +0 -0
  55. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/diagnose-mqtt.rst +0 -0
  56. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/home-assistant.rst +0 -0
  57. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/index.rst +0 -0
  58. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/maintenance.rst +0 -0
  59. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/manage-units.rst +0 -0
  60. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/monitor-status.rst +0 -0
  61. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/queue-commands.rst +0 -0
  62. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/schedule-operation.rst +0 -0
  63. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/how-to/track-energy.rst +0 -0
  64. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/index.rst +0 -0
  65. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/project/authors.rst +0 -0
  66. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/project/changelog.rst +0 -0
  67. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/project/contributing.rst +0 -0
  68. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/project/history.rst +0 -0
  69. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/project/license.rst +0 -0
  70. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/configuration.rst +0 -0
  71. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/enumerations.rst +0 -0
  72. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/index.rst +0 -0
  73. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/installation.rst +0 -0
  74. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/protocol/data_conversions.rst +0 -0
  75. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/protocol/device_features.rst +0 -0
  76. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/protocol/device_status.rst +0 -0
  77. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/protocol/error_codes.rst +0 -0
  78. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  79. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/protocol/quick_reference.rst +0 -0
  80. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/protocol/rest_api.rst +0 -0
  81. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/python_api/api_client.rst +0 -0
  82. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/python_api/auth_client.rst +0 -0
  83. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/python_api/cli.rst +0 -0
  84. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/python_api/events.rst +0 -0
  85. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/reference/python_api/exceptions.rst +0 -0
  86. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/requirements.txt +0 -0
  87. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/docs/tutorials/getting-started.rst +0 -0
  88. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/.ruff.toml +0 -0
  89. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/README.md +0 -0
  90. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/air_filter_reset.py +0 -0
  91. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/anti_legionella.py +0 -0
  92. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/auto_recovery.py +0 -0
  93. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/combined_callbacks.py +0 -0
  94. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/demand_response.py +0 -0
  95. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/device_capabilities.py +0 -0
  96. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/device_status_debug.py +0 -0
  97. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/energy_analytics.py +0 -0
  98. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/error_code_demo.py +0 -0
  99. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/mqtt_diagnostics.py +0 -0
  100. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/power_control.py +0 -0
  101. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/recirculation_control.py +0 -0
  102. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/reconnection_demo.py +0 -0
  103. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/reservation_schedule.py +0 -0
  104. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/simple_auto_recovery.py +0 -0
  105. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/token_restoration.py +0 -0
  106. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/tou_openei.py +0 -0
  107. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/advanced/water_reservation.py +0 -0
  108. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/beginner/01_authentication.py +0 -0
  109. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/beginner/02_list_devices.py +0 -0
  110. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/beginner/03_get_status.py +0 -0
  111. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/beginner/04_set_temperature.py +0 -0
  112. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/advanced_auth_patterns.py +0 -0
  113. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/command_queue.py +0 -0
  114. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/device_status_callback.py +0 -0
  115. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/error_handling.py +0 -0
  116. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/event_driven_control.py +0 -0
  117. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/improved_auth.py +0 -0
  118. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/legacy_auth_constructor.py +0 -0
  119. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  120. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/periodic_requests.py +0 -0
  121. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/set_mode.py +0 -0
  122. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/intermediate/vacation_mode.py +0 -0
  123. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/mask.py +0 -0
  124. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/testing/periodic_device_info.py +0 -0
  125. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/testing/simple_periodic_info.py +0 -0
  126. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/testing/test_api_client.py +0 -0
  127. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/testing/test_mqtt_connection.py +0 -0
  128. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/testing/test_mqtt_messaging.py +0 -0
  129. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/examples/testing/test_periodic_minimal.py +0 -0
  130. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/pyproject.toml +0 -0
  131. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/scripts/README.md +0 -0
  132. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/scripts/bump_version.py +0 -0
  133. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/scripts/diagnose_mqtt_connection.py +0 -0
  134. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/scripts/extract_changelog.py +0 -0
  135. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/scripts/format.py +0 -0
  136. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/scripts/lint.py +0 -0
  137. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/scripts/setup-dev.py +0 -0
  138. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/scripts/validate_version.py +0 -0
  139. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/setup.cfg +0 -0
  140. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/setup.py +0 -0
  141. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/_base.py +0 -0
  142. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/api_client.py +0 -0
  143. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/auth.py +0 -0
  144. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/cli/__init__.py +0 -0
  145. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/cli/monitoring.py +0 -0
  146. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/cli/output_formatters.py +0 -0
  147. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/cli/presentation.py +0 -0
  148. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/cli/rich_output.py +0 -0
  149. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/cli/token_storage.py +0 -0
  150. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/command_decorators.py +0 -0
  151. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/config.py +0 -0
  152. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/converters.py +0 -0
  153. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/device_capabilities.py +0 -0
  154. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/device_info_cache.py +0 -0
  155. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/encoding.py +0 -0
  156. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/enums.py +0 -0
  157. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/events.py +0 -0
  158. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/exceptions.py +0 -0
  159. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/factory.py +0 -0
  160. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/field_factory.py +0 -0
  161. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/models/_converters.py +0 -0
  162. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/models/energy.py +0 -0
  163. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/models/mqtt_models.py +0 -0
  164. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/models/schedule.py +0 -0
  165. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/models/status.py +0 -0
  166. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/__init__.py +0 -0
  167. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/client.py +0 -0
  168. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/command_queue.py +0 -0
  169. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/connection.py +0 -0
  170. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/diagnostics.py +0 -0
  171. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/periodic.py +0 -0
  172. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/reconnection.py +0 -0
  173. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/state_tracker.py +0 -0
  174. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/types.py +0 -0
  175. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt/utils.py +0 -0
  176. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/mqtt_events.py +0 -0
  177. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/openei.py +0 -0
  178. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/py.typed +0 -0
  179. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/reservations.py +0 -0
  180. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/temperature.py +0 -0
  181. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/topic_builder.py +0 -0
  182. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/tou_schedule.py +0 -0
  183. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/unit_system.py +0 -0
  184. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500/utils.py +0 -0
  185. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  186. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  187. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  188. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500_python.egg-info/requires.txt +0 -0
  189. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/src/nwp500_python.egg-info/top_level.txt +0 -0
  190. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/conftest.py +0 -0
  191. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_api_helpers.py +0 -0
  192. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_auth.py +0 -0
  193. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_auth_session_lifecycle.py +0 -0
  194. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_bug_fixes.py +0 -0
  195. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_canonical_schedule.py +0 -0
  196. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_cli_basic.py +0 -0
  197. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_cli_tristate_presentation.py +0 -0
  198. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_command_decorators.py +0 -0
  199. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_command_queue.py +0 -0
  200. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_device_capabilities.py +0 -0
  201. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_device_info_cache.py +0 -0
  202. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_events.py +0 -0
  203. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_exceptions.py +0 -0
  204. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_model_converters.py +0 -0
  205. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_mqtt_clean_session_resume.py +0 -0
  206. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_mqtt_client_init.py +0 -0
  207. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_mqtt_events.py +0 -0
  208. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_mqtt_hypothesis.py +0 -0
  209. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_mqtt_reconnection.py +0 -0
  210. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_mqtt_reconnection_storm.py +0 -0
  211. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_mqtt_reliability.py +0 -0
  212. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_multi_device.py +0 -0
  213. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_openei.py +0 -0
  214. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_protocol_correctness.py +0 -0
  215. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_reservations.py +0 -0
  216. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_temperature_converters.py +0 -0
  217. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_threading_model.py +0 -0
  218. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_token_storage.py +0 -0
  219. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_tou_api.py +0 -0
  220. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_tou_schedule.py +0 -0
  221. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_unit_switching.py +0 -0
  222. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_unit_system_process_wide.py +0 -0
  223. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_utility_modules.py +0 -0
  224. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tests/test_utils.py +0 -0
  225. {nwp500_python-9.3.0 → nwp500_python-9.3.1}/tox.ini +0 -0
@@ -5,6 +5,105 @@ Changelog
5
5
  Unreleased
6
6
  ==========
7
7
 
8
+ Version 9.3.1 (2026-08-29)
9
+ ==========================
10
+
11
+ ``NavienMqttClient.request_tou_settings()`` is removed. Not a breaking
12
+ change in practice: the device never answered it, so no caller can have
13
+ been getting a schedule out of it. Code that still names the method will
14
+ now fail with ``AttributeError`` rather than time out. See Removed below.
15
+
16
+ Added
17
+ -----
18
+ - **REST fields the cloud added since the models were written.**
19
+ ``/device/list`` now returns an ``error`` block (``errorCode``,
20
+ ``errorOccuredTime``) and the ``descaling`` block previously seen only
21
+ on ``/device/info``, and ``deviceInfo`` gained ``modelTypeCode`` and
22
+ ``installerId``. None of these were modelled, and ``NavienBaseModel``
23
+ ignores unknown keys, so all of them were silently discarded.
24
+ :class:`~nwp500.models.Device` gains optional ``error``
25
+ (:class:`~nwp500.models.DeviceErrorSummary`) and ``descaling``
26
+ (:class:`~nwp500.models.DescalingInfo`) sections, and
27
+ :class:`~nwp500.models.DeviceInfo` gains ``model_type_code`` and
28
+ ``installer_id``. ``error`` makes the device's last recorded fault
29
+ readable without an MQTT connection, including while the device is
30
+ offline. Every new field is optional, so responses that omit them - such
31
+ as ``/device/info``, which carries no ``error`` block - parse unchanged.
32
+ ``error_code`` is typed ``ErrorCode | int`` (validated left to right, so a
33
+ known code becomes an ``ErrorCode`` member and only an unknown one stays a
34
+ plain int) so an unrecognised code cannot make a whole listing unparseable. ``docs/openapi.yaml`` is
35
+ updated to match.
36
+
37
+ Removed
38
+ -------
39
+ - **``request_tou_settings()`` removed - the device has no MQTT read for its
40
+ TOU schedule.** The method published a ``CommandCode.TOU_RESERVATION``
41
+ message to ``ctrl/tou/rd`` carrying only ``controllerSerialNumber``, then
42
+ waited for a reply on ``res/tou/rd``. No reply ever comes: a live device
43
+ with TOU provisioned (``program_reservation_use`` true, a valid controller
44
+ serial, and a plan the REST API returns in full) stayed silent for 45
45
+ seconds. ``ctrl/tou/rd`` with that command code is the *write* - it is what
46
+ :meth:`~nwp500.NavienMqttClient.configure_tou_schedule` publishes and what
47
+ the vendor app publishes from its TOU editor
48
+ (``TouScheduleViewmodel.setPublishMgppControlTou``) - and the device answers
49
+ on ``res/tou/rd`` only to confirm such a write. The vendor app reads TOU
50
+ over REST, which is the only TOU read the protocol has.
51
+
52
+ Beyond returning nothing, the call published a write-shaped command with no
53
+ schedule attached. This device ignored it - ``touStatus`` was unchanged
54
+ across repeated calls - but a firmware that took it at face value could read
55
+ it as "store an empty TOU schedule".
56
+
57
+ **Migration**: use :meth:`~nwp500.NavienAPIClient.get_tou_info`, which
58
+ returns the stored plan - rate name, utility, ZIP code and the seasonal
59
+ pricing intervals. The read itself is pure REST; it is keyed by the
60
+ controller serial number, which only the MQTT device-info response
61
+ publishes, so fetch that once and cache it.
62
+ :meth:`~nwp500.NavienMqttClient.subscribe_tou_response` is unaffected and
63
+ still delivers write confirmations. Enabling and disabling TOU
64
+ (:meth:`~nwp500.NavienMqttClient.set_tou_enabled`, command codes
65
+ ``TOU_ON``/``TOU_OFF``) is a separate path and is unaffected.
66
+
67
+ Fixed
68
+ -----
69
+ - **CLI ``energy --months`` no longer duplicates ``--month`` output.**
70
+ ``handle_get_energy_request`` picked the view from the length of the
71
+ month list, so ``--months 5`` took the single-month branch and printed
72
+ the same daily breakdown as ``--month 5`` instead of the monthly
73
+ summary. The handler now takes an explicit ``daily`` flag set by the
74
+ option the user actually passed. ``energy`` also rejects ``--month``
75
+ and ``--months`` together rather than silently preferring ``--month``,
76
+ and validates the ``--months`` range and format the way ``--month``
77
+ already did. All of that validation now happens while Click parses the
78
+ arguments, so a bad invocation is a usage error (exit code 2) before any
79
+ authentication or MQTT connection is attempted - previously the command
80
+ body raised ``ClickException`` after connecting, where ``async_command``'s
81
+ catch-all reported it as an "Unexpected Error" with a traceback, and
82
+ anyone without working credentials saw an authentication failure instead
83
+ of the usage error. That catch-all now re-raises ``ClickException`` so any
84
+ command can report a usage error as itself.
85
+ - **``DeviceFeature.mixing_valve_use`` now reads the device's actual key.**
86
+ The field was aliased to ``mixingValveUse``, but the device sends
87
+ ``mixingValueUse`` - "Value", not "Valve" - and the base model ignores
88
+ unknown keys, so the field silently reported its ``False`` default on
89
+ every device regardless of whether a thermostatic mixing valve was
90
+ supported. The CLI's "Mixing Valve" capability row was wrong for the
91
+ same reason. The misspelling is Navien's own: it appears in the vendor
92
+ app's ``KDResponseMgppDid$Feature`` class and in
93
+ ``docs/reference/protocol/device_features.rst``, which documented the
94
+ wire spelling correctly all along.
95
+
96
+ - **``firmware_payload_capture.py`` captured almost nothing.** Its wildcards
97
+ covered ``cmd/{type}/{client_id}/res/#`` and the event topic, but the device
98
+ acknowledges control and query commands on
99
+ ``cmd/{type}/navilink-{mac}/{client_id}/res`` - which neither pattern
100
+ matches - so the device status and device info responses were missed, along
101
+ with every request published on the device path. A capture run recorded 2
102
+ payloads where it should have recorded 11. It now also subscribes to
103
+ ``cmd/{type}/navilink-{mac}/#``, which additionally picks up traffic from
104
+ other clients on the same device. Query results the device routes back to a
105
+ client-keyed topic, such as reservations and energy usage, were already
106
+ covered by the existing response wildcard.
8
107
  Version 9.3.0 (2026-08-03)
9
108
  ==========================
10
109
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.3.0
3
+ Version: 9.3.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
@@ -339,28 +339,38 @@ Enables or disables TOU operation without changing the schedule.
339
339
  * ``device``: Device object
340
340
  * ``enabled``: ``True`` to enable TOU, ``False`` to disable
341
341
 
342
- MQTT: Request TOU Settings
343
- ~~~~~~~~~~~~~~~~~~~~~~~~~~
342
+ REST: Read the Current TOU Schedule
343
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
344
344
 
345
345
  .. code-block:: python
346
346
 
347
- async def request_tou_settings(
348
- device: Device,
349
- controller_serial_number: str
350
- ) -> None
347
+ async def get_tou_info(
348
+ mac_address: str,
349
+ additional_value: str,
350
+ controller_id: str,
351
+ user_type: str = "O",
352
+ ) -> TOUInfo
351
353
 
352
- Requests the current TOU configuration from the device.
354
+ Returns the stored TOU plan: rate name, utility, ZIP code, and the seasonal
355
+ schedule with its pricing intervals.
353
356
 
354
357
  **Parameters:**
355
358
 
356
- * ``device``: Device object
357
- * ``controller_serial_number``: Controller serial number
359
+ * ``mac_address``: Device MAC address
360
+ * ``additional_value``: Additional device identifier
361
+ * ``controller_id``: Controller serial number. Only the MQTT device-info
362
+ response publishes it (``DeviceFeature.controller_serial_number``); it is a
363
+ hardware property, so fetch it once and cache it. See Example 3.
364
+ * ``user_type``: User type (default ``"O"``)
358
365
 
359
- The device will respond on the topic:
366
+ .. note::
360
367
 
361
- .. code-block:: text
362
-
363
- cmd/{deviceType}/{deviceId}/res/tou/rd
368
+ There is no MQTT read for the TOU schedule. ``ctrl/tou/rd`` with
369
+ ``CommandCode.TOU_RESERVATION`` is the *write* - it is what
370
+ :meth:`~nwp500.NavienMqttClient.configure_tou_schedule` publishes, and what
371
+ the vendor app publishes from its TOU editor. The device replies on
372
+ ``cmd/{deviceType}/{clientId}/res/tou/rd`` to confirm such a write; it does
373
+ not answer a request that carries no schedule. Read the plan over REST.
364
374
 
365
375
  Building TOU Periods
366
376
  --------------------
@@ -527,7 +537,7 @@ Configure two rate periods - off-peak and peak pricing:
527
537
  await mqtt_client.subscribe_device_feature(device, capture_feature)
528
538
  await mqtt_client.request_device_info(device)
529
539
  feature = await asyncio.wait_for(feature_future, timeout=15)
530
- controller_serial = feature.controllerSerialNumber
540
+ controller_serial = feature.controller_serial_number
531
541
 
532
542
  # Define off-peak period (midnight to 2 PM, weekdays)
533
543
  off_peak = build_tou_period(
@@ -651,58 +661,63 @@ Configure different rates for summer and winter:
651
661
  Example 3: Retrieve Current TOU Settings
652
662
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
653
663
 
654
- Query the device for its current TOU configuration:
664
+ The stored TOU plan is read over the REST API. The read itself needs no MQTT
665
+ connection, but it is keyed by the controller serial number, and the only
666
+ place that is published is the MQTT device-info response. The serial is a
667
+ hardware property that never changes, so fetch it once, keep it, and every
668
+ later read is pure REST:
655
669
 
656
670
  .. code-block:: python
657
671
 
658
- from nwp500.encoding import decode_week_bitfield, decode_price
672
+ import asyncio
673
+ from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient
674
+ from nwp500.encoding import decode_price, decode_week_bitfield
675
+
676
+ async def get_controller_serial(auth_client, device) -> str:
677
+ """One-time lookup: the controller serial is only published over MQTT."""
678
+ mqtt_client = NavienMqttClient(auth_client)
679
+ await mqtt_client.connect()
680
+ try:
681
+ feature_future = asyncio.Future()
682
+
683
+ def capture_feature(feature):
684
+ if not feature_future.done():
685
+ feature_future.set_result(feature)
686
+
687
+ await mqtt_client.subscribe_device_feature(device, capture_feature)
688
+ await mqtt_client.request_device_info(device)
689
+ feature = await asyncio.wait_for(feature_future, timeout=15)
690
+ return feature.controller_serial_number
691
+ finally:
692
+ await mqtt_client.disconnect()
659
693
 
660
- async def check_tou_settings():
694
+ async def check_tou_settings(controller_serial: str | None = None):
661
695
  async with NavienAuthClient("user@example.com", "password") as auth_client:
662
696
  api_client = NavienAPIClient(auth_client=auth_client)
663
697
  device = await api_client.get_first_device()
664
-
665
- mqtt_client = NavienMqttClient(auth_client)
666
- await mqtt_client.connect()
667
-
668
- # ... get controller_serial (same as Example 1) ...
669
-
670
- # Set up response handler
671
- response_topic = f"cmd/{device.device_info.device_type}/{mqtt_client.config.client_id}/res/tou/rd"
672
-
673
- def on_tou_response(topic: str, message: dict):
674
- response = message.get("response", {})
675
- enabled = response.get("reservationUse")
676
- periods = response.get("reservation", [])
677
-
678
- print(f"TOU Enabled: {enabled}")
679
- print(f"Number of periods: {len(periods)}")
680
-
681
- for i, period in enumerate(periods, 1):
682
- days = decode_week_bitfield(period.get("week", 0))
683
- price_min = decode_price(
684
- period.get("priceMin", 0),
685
- period.get("decimalPoint", 0)
686
- )
687
- price_max = decode_price(
688
- period.get("priceMax", 0),
689
- period.get("decimalPoint", 0)
690
- )
691
-
698
+
699
+ if controller_serial is None:
700
+ controller_serial = await get_controller_serial(auth_client, device)
701
+
702
+ tou_info = await api_client.get_tou_info(
703
+ mac_address=device.device_info.mac_address,
704
+ additional_value=device.device_info.additional_value,
705
+ controller_id=controller_serial,
706
+ )
707
+
708
+ print(f"Plan: {tou_info.name} ({tou_info.utility})")
709
+
710
+ # TOUSchedule.intervals holds the raw interval dicts
711
+ for season in tou_info.schedule:
712
+ for i, interval in enumerate(season.intervals, 1):
713
+ days = decode_week_bitfield(interval["week"])
714
+ dp = interval["decimalPoint"]
692
715
  print(f"\nPeriod {i}:")
693
716
  print(f" Days: {', '.join(days)}")
694
- print(f" Time: {period['startHour']:02d}:{period['startMinute']:02d} "
695
- f"- {period['endHour']:02d}:{period['endMinute']:02d}")
696
- print(f" Price: ${price_min:.5f} - ${price_max:.5f}/kWh")
697
-
698
- await mqtt_client.subscribe(response_topic, on_tou_response)
699
-
700
- # Request current settings
701
- await mqtt_client.request_tou_settings(device, controller_serial)
702
-
703
- # Wait for response
704
- await asyncio.sleep(5)
705
- await mqtt_client.disconnect()
717
+ print(f" Time: {interval['startHour']:02d}:{interval['startMinute']:02d} "
718
+ f"- {interval['endHour']:02d}:{interval['endMinute']:02d}")
719
+ print(f" Price: ${decode_price(interval['priceMin'], dp):.5f} "
720
+ f"- ${decode_price(interval['priceMax'], dp):.5f}/kWh")
706
721
 
707
722
  asyncio.run(check_tou_settings())
708
723
 
@@ -195,6 +195,14 @@ paths:
195
195
  connected:
196
196
  type: integer
197
197
  example: 2
198
+ modelTypeCode:
199
+ type: integer
200
+ nullable: true
201
+ example: null
202
+ installerId:
203
+ type: string
204
+ nullable: true
205
+ example: null
198
206
  location:
199
207
  type: object
200
208
  properties:
@@ -206,7 +214,29 @@ paths:
206
214
  example: "Anytown"
207
215
  address:
208
216
  type: string
209
- example: "123 Main Street"
217
+ example: "123 Main Street"
218
+ error:
219
+ type: object
220
+ description: Last recorded device fault.
221
+ properties:
222
+ errorCode:
223
+ type: integer
224
+ example: 0
225
+ errorOccuredTime:
226
+ type: string
227
+ nullable: true
228
+ example: "2025-12-07T11:58:02"
229
+ descaling:
230
+ type: object
231
+ properties:
232
+ descalingStartTime:
233
+ type: string
234
+ nullable: true
235
+ example: null
236
+ descalingEndTime:
237
+ type: string
238
+ nullable: true
239
+ example: null
210
240
  /device/info:
211
241
  post:
212
242
  summary: Device Info
@@ -60,6 +60,10 @@ Complete device representation with info and location.
60
60
 
61
61
  * ``device_info`` (DeviceInfo) - Device identification and status
62
62
  * ``location`` (Location) - Physical location information
63
+ * ``error`` (DeviceErrorSummary, optional) - Last recorded fault. Returned by
64
+ ``/device/list`` only; ``None`` on a device fetched through ``/device/info``.
65
+ * ``descaling`` (DescalingInfo, optional) - Descaling window, if one is
66
+ scheduled or recorded
63
67
 
64
68
  **Example:**
65
69
 
@@ -80,6 +84,52 @@ Complete device representation with info and location.
80
84
  print(f"Location: {loc.city}, {loc.state}")
81
85
  print(f"Coords: {loc.latitude}, {loc.longitude}")
82
86
 
87
+ DeviceErrorSummary
88
+ ------------------
89
+
90
+ The device's last recorded fault, as reported by the REST API. Unlike
91
+ ``DeviceStatus.error_code`` this is readable without an MQTT connection, and
92
+ remains readable while the device is offline.
93
+
94
+ .. py:class:: DeviceErrorSummary
95
+
96
+ **Fields:**
97
+
98
+ * ``error_code`` (ErrorCode | int) - ``ErrorCode.NO_ERROR`` when there is no
99
+ recorded fault. A code the enum does not know is kept as a plain int rather
100
+ than failing the whole response.
101
+ * ``error_occurred_time`` (str, optional) - When the fault was recorded, as an
102
+ ISO-8601 string. Sent by the API under the misspelled key ``errorOccuredTime``.
103
+
104
+ **Example:**
105
+
106
+ .. code-block:: python
107
+
108
+ devices = await api.list_devices()
109
+
110
+ for device in devices:
111
+ if device.error and device.error.error_code != ErrorCode.NO_ERROR:
112
+ code = device.error.error_code
113
+ # A code the enum knows arrives as an ErrorCode; anything else
114
+ # falls back to a plain int, which has no .name.
115
+ label = code.name if isinstance(code, ErrorCode) else f"code {code}"
116
+ print(f"{device.device_info.device_name}: {label}"
117
+ f" at {device.error.error_occurred_time}")
118
+
119
+ DescalingInfo
120
+ -------------
121
+
122
+ Descaling window reported by the REST API.
123
+
124
+ .. py:class:: DescalingInfo
125
+
126
+ **Fields:**
127
+
128
+ * ``descaling_start_time`` (str, optional) - Start of the descaling window
129
+ * ``descaling_end_time`` (str, optional) - End of the descaling window
130
+
131
+ Both are ``None`` on a device with no descaling scheduled or recorded.
132
+
83
133
  DeviceInfo
84
134
  ----------
85
135
 
@@ -99,6 +149,8 @@ Device identification and connection information.
99
149
  * ``device_name`` (str) - User-assigned device name
100
150
  * ``connected`` (int) - Connection status (2 = online, 0 = offline)
101
151
  * ``install_type`` (str, optional) - Installation type
152
+ * ``model_type_code`` (int, optional) - Model type code
153
+ * ``installer_id`` (str, optional) - Installer identifier
102
154
 
103
155
  **Example:**
104
156
 
@@ -582,24 +582,17 @@ configure_tou_schedule()
582
582
 
583
583
  **Capability Required:** ``program_reservation_use``
584
584
 
585
- request_tou_settings()
586
- ^^^^^^^^^^^^^^^^^^^^^^
587
-
588
- .. py:method:: request_tou_settings(device, controller_serial_number)
589
-
590
- Request the current TOU schedule.
591
-
592
585
  subscribe_tou_response()
593
586
  ^^^^^^^^^^^^^^^^^^^^^^^^
594
587
 
595
588
  .. py:method:: subscribe_tou_response(device, callback)
596
589
 
597
- Subscribe to parsed TOU schedule responses.
590
+ Subscribe to parsed TOU schedule write confirmations.
598
591
 
599
592
  The callback is invoked with a :class:`~nwp500.models.TOUReservationSchedule`
600
- whenever the device responds to a :meth:`request_tou_settings` read or a
601
- :meth:`configure_tou_schedule` write (both use the ``tou/rd`` response
602
- topic).
593
+ when the device confirms a :meth:`configure_tou_schedule` write on the
594
+ ``tou/rd`` response topic. The device has no MQTT read for its TOU schedule;
595
+ to read the stored plan, use :meth:`~nwp500.NavienAPIClient.get_tou_info`.
603
596
 
604
597
  :param callback: Called with the parsed TOU schedule on each response.
605
598
  :type callback: Callable[[TOUReservationSchedule], None]
@@ -8,7 +8,6 @@ updates by diffing captures taken before and after an update.
8
8
 
9
9
  Specifically captures:
10
10
  - Weekly reservations (rsv/rd)
11
- - Time-of-Use schedule (tou/rd)
12
11
  - Device info (firmware versions, capabilities)
13
12
  - Device status (current operating state)
14
13
  - All other response/event topics (via wildcards)
@@ -37,7 +36,6 @@ from pathlib import Path
37
36
  from typing import Any
38
37
 
39
38
  from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient
40
- from nwp500.exceptions import Nwp500Error
41
39
  from nwp500.models import DeviceFeature
42
40
  from nwp500.mqtt.utils import redact, redact_topic
43
41
  from nwp500.topic_builder import MqttTopicBuilder
@@ -113,17 +111,30 @@ async def main() -> None:
113
111
 
114
112
  # --- Wildcard subscriptions to catch everything ---
115
113
 
116
- # All response messages back to this client
114
+ # Everything under the device's own command path. The device
115
+ # acknowledges control and query commands on
116
+ # cmd/{type}/navilink-{mac}/{client_id}/res, which is where the status
117
+ # and device-info responses arrive, so a capture without this
118
+ # subscription misses them entirely. It also picks up the published
119
+ # requests themselves and the traffic of other clients on the same
120
+ # device (a Home Assistant integration, the vendor app), which is
121
+ # exactly what a capture wants. Query results that the device routes
122
+ # back to a client-keyed topic - reservations, energy usage - arrive
123
+ # under res_wildcard below instead.
124
+ cmd_wildcard = MqttTopicBuilder.command_topic(device_type, mac, "#")
125
+ # Query results routed back to this client specifically
117
126
  res_wildcard = MqttTopicBuilder.response_topic(device_type, client_id, "#")
118
127
  # All event messages pushed by the device
119
128
  evt_wildcard = MqttTopicBuilder.event_topic(device_type, mac, "#")
120
129
 
121
130
  print(
122
- f"\nSubscribing to:\n {redact_topic(res_wildcard)}\n"
131
+ f"\nSubscribing to:\n {redact_topic(cmd_wildcard)}\n"
132
+ f" {redact_topic(res_wildcard)}\n"
123
133
  f" {redact_topic(evt_wildcard)}\n"
124
134
  )
125
135
  print("Captured topics:")
126
136
 
137
+ await mqtt_client.subscribe(cmd_wildcard, capture.record)
127
138
  await mqtt_client.subscribe(res_wildcard, capture.record)
128
139
  await mqtt_client.subscribe(evt_wildcard, capture.record)
129
140
 
@@ -156,16 +167,9 @@ async def main() -> None:
156
167
  await mqtt_client.request_reservations(device)
157
168
  await asyncio.sleep(5)
158
169
 
159
- # --- Step 4: request TOU schedule (requires controller serial number) ---
160
- if device_feature and device_feature.program_reservation_use:
161
- serial = device_feature.controller_serial_number
162
- if serial:
163
- print("Requesting TOU schedule...")
164
- try:
165
- await mqtt_client.request_tou_settings(device, serial)
166
- await asyncio.sleep(5)
167
- except Nwp500Error as exc:
168
- print(f" TOU request failed: {exc}")
170
+ # No TOU step: the device has no MQTT read for its TOU schedule.
171
+ # ctrl/tou/rd carries the *write*, which a capture tool must not send.
172
+ # Read the schedule over REST instead (api_client.get_tou_info).
169
173
 
170
174
  # --- Step 5: wait a bit more to catch any late-arriving messages ---
171
175
  print("\nWaiting for any remaining messages...")
@@ -119,10 +119,10 @@ async def main() -> None:
119
119
  enabled=True,
120
120
  )
121
121
 
122
- print("Requesting current TOU settings for confirmation...")
123
- await mqtt_client.request_tou_settings(device, controller_serial)
124
-
125
- print("Waiting up to 15 seconds for TOU responses...")
122
+ # The device has no MQTT read for its TOU schedule; the write above
123
+ # is confirmed on the same tou/rd topic. To read the stored schedule
124
+ # back, use the REST API: api_client.get_tou_info(...).
125
+ print("Waiting up to 15 seconds for the TOU write confirmation...")
126
126
  await asyncio.sleep(15)
127
127
 
128
128
  print("Toggling TOU off for quick test...")
@@ -75,7 +75,9 @@ from nwp500.factory import (
75
75
  )
76
76
  from nwp500.models import (
77
77
  ConvertedTOUPlan,
78
+ DescalingInfo,
78
79
  Device,
80
+ DeviceErrorSummary,
79
81
  DeviceFeature,
80
82
  DeviceInfo,
81
83
  DeviceStatus,
@@ -147,6 +149,8 @@ __all__ = [
147
149
  "DeviceInfo",
148
150
  "Location",
149
151
  "Device",
152
+ "DeviceErrorSummary",
153
+ "DescalingInfo",
150
154
  "FirmwareInfo",
151
155
  "ReservationEntry",
152
156
  "ReservationSchedule",
@@ -143,6 +143,11 @@ def async_command(f: Any) -> Any:
143
143
  await mqtt.disconnect()
144
144
  return 0
145
145
 
146
+ except click.ClickException:
147
+ # A usage/config error a command raised deliberately. Let
148
+ # Click report it as itself; the catch-all below would
149
+ # otherwise log it as an "Unexpected Error" with a traceback.
150
+ raise
146
151
  except (
147
152
  InvalidCredentialsError,
148
153
  AuthenticationError,
@@ -639,14 +644,69 @@ async def tou_apply(
639
644
  _logger.error("API client not available")
640
645
 
641
646
 
642
- @cli.command() # type: ignore[attr-defined]
647
+ def _parse_months(
648
+ ctx: click.Context, param: click.Parameter, value: str | None
649
+ ) -> list[int] | None:
650
+ """Parse ``--months`` at parse time, so bad input is a usage error."""
651
+ if value is None:
652
+ return None
653
+ months = []
654
+ for raw in value.split(","):
655
+ try:
656
+ month = int(raw.strip())
657
+ except ValueError:
658
+ raise click.BadParameter(
659
+ f"{raw.strip()!r} is not a month number; expected a "
660
+ "comma-separated list like 1,2,3"
661
+ ) from None
662
+ if not 1 <= month <= 12:
663
+ raise click.BadParameter(f"{month} is not in the range 1-12")
664
+ months.append(month)
665
+ if not months:
666
+ raise click.BadParameter("at least one month is required")
667
+ return months
668
+
669
+
670
+ class _EnergySelection(click.Command):
671
+ """Rejects an unusable --month/--months combination during parsing.
672
+
673
+ Cross-option checks have no natural home in a per-option callback, and
674
+ the command body is the wrong place: it runs inside ``async_command``,
675
+ after authentication and the MQTT connect, so a usage error there would
676
+ surface only to users whose credentials already work - and then as an
677
+ "Unexpected Error" traceback, because the wrapper's catch-all swallows
678
+ ``ClickException``.
679
+ """
680
+
681
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
682
+ rest = super().parse_args(ctx, args)
683
+ month, months = ctx.params.get("month"), ctx.params.get("months")
684
+ if month is not None and months is not None:
685
+ raise click.UsageError(
686
+ "Use either --month (daily breakdown) or --months "
687
+ "(monthly summary), not both",
688
+ ctx=ctx,
689
+ )
690
+ if month is None and months is None:
691
+ raise click.UsageError(
692
+ "Either --months (for monthly summary) or --month "
693
+ "(for daily breakdown) is required",
694
+ ctx=ctx,
695
+ )
696
+ return rest
697
+
698
+
699
+ @cli.command(cls=_EnergySelection) # type: ignore[attr-defined]
643
700
  @click.option("--year", type=int, required=True, help="Year to query")
644
701
  @click.option(
645
- "--months", required=False, help="Comma-separated months (e.g. 1,2,3)"
702
+ "--months",
703
+ required=False,
704
+ callback=_parse_months,
705
+ help="Comma-separated months for a monthly summary (e.g. 1,2,3)",
646
706
  )
647
707
  @click.option(
648
708
  "--month",
649
- type=int,
709
+ type=click.IntRange(1, 12),
650
710
  required=False,
651
711
  help="Show daily breakdown for a specific month (1-12)",
652
712
  )
@@ -655,26 +715,25 @@ async def energy(
655
715
  mqtt: NavienMqttClient,
656
716
  device: Any,
657
717
  year: int,
658
- months: str | None,
718
+ months: list[int] | None,
659
719
  month: int | None,
660
720
  ) -> None:
661
721
  """Query historical energy usage.
662
722
 
663
723
  Use either --months for monthly summary or --month for daily breakdown.
724
+ Which option was passed decides the view, not how many months it names:
725
+ ``--months 5`` is a one-month summary, ``--month 5`` is a daily
726
+ breakdown of that month.
664
727
  """
728
+ # _EnergySelection rejected every combination but these two during
729
+ # parsing, so exactly one of the options is set here.
665
730
  if month is not None:
666
- # Daily breakdown for a single month
667
- if month < 1 or month > 12:
668
- raise click.ClickException("Month must be between 1 and 12")
669
- await handlers.handle_get_energy_request(mqtt, device, year, [month])
731
+ await handlers.handle_get_energy_request(
732
+ mqtt, device, year, [month], daily=True
733
+ )
670
734
  elif months is not None:
671
- # Monthly summary
672
- month_list = [int(m.strip()) for m in months.split(",")]
673
- await handlers.handle_get_energy_request(mqtt, device, year, month_list)
674
- else:
675
- raise click.ClickException(
676
- "Either --months (for monthly summary) or --month "
677
- "(for daily breakdown) required"
735
+ await handlers.handle_get_energy_request(
736
+ mqtt, device, year, months, daily=False
678
737
  )
679
738
 
680
739