nwp500-python 7.4.7__tar.gz → 7.4.8__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 (182) hide show
  1. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/CHANGELOG.rst +12 -0
  2. {nwp500_python-7.4.7/src/nwp500_python.egg-info → nwp500_python-7.4.8}/PKG-INFO +1 -1
  3. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/scheduling.rst +123 -11
  4. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/__init__.py +11 -0
  5. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/cli/handlers.py +36 -174
  6. nwp500_python-7.4.8/src/nwp500/reservations.py +313 -0
  7. {nwp500_python-7.4.7 → nwp500_python-7.4.8/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  8. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500_python.egg-info/SOURCES.txt +2 -0
  9. nwp500_python-7.4.8/tests/test_reservations.py +396 -0
  10. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/.coveragerc +0 -0
  11. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  12. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/.github/copilot-instructions.md +0 -0
  13. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/.github/workflows/ci.yml +0 -0
  14. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/.github/workflows/release.yml +0 -0
  15. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/.gitignore +0 -0
  16. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/.pre-commit-config.yaml +0 -0
  17. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/.readthedocs.yml +0 -0
  18. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/AUTHORS.rst +0 -0
  19. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/CONTRIBUTING.rst +0 -0
  20. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/LICENSE.txt +0 -0
  21. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/Makefile +0 -0
  22. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/README.rst +0 -0
  23. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/RELEASE.md +0 -0
  24. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/Makefile +0 -0
  25. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/_static/.gitignore +0 -0
  26. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/api/nwp500.rst +0 -0
  27. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/authors.rst +0 -0
  28. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/changelog.rst +0 -0
  29. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/conf.py +0 -0
  30. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/configuration.rst +0 -0
  31. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/development/contributing.rst +0 -0
  32. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/development/history.rst +0 -0
  33. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/enumerations.rst +0 -0
  34. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/advanced_features_explained.rst +0 -0
  35. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/authentication.rst +0 -0
  36. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/auto_recovery.rst +0 -0
  37. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/command_queue.rst +0 -0
  38. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/energy_monitoring.rst +0 -0
  39. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/event_system.rst +0 -0
  40. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/home_assistant_integration.rst +0 -0
  41. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/mqtt_diagnostics.rst +0 -0
  42. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/time_of_use.rst +0 -0
  43. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/guides/unit_conversion.rst +0 -0
  44. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/index.rst +0 -0
  45. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/installation.rst +0 -0
  46. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/license.rst +0 -0
  47. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/openapi.yaml +0 -0
  48. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/protocol/data_conversions.rst +0 -0
  49. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/protocol/device_features.rst +0 -0
  50. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/protocol/device_status.rst +0 -0
  51. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/protocol/error_codes.rst +0 -0
  52. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/protocol/mqtt_protocol.rst +0 -0
  53. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/protocol/quick_reference.rst +0 -0
  54. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/protocol/rest_api.rst +0 -0
  55. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/python_api/api_client.rst +0 -0
  56. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/python_api/auth_client.rst +0 -0
  57. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/python_api/cli.rst +0 -0
  58. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/python_api/device_control.rst +0 -0
  59. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/python_api/events.rst +0 -0
  60. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/python_api/exceptions.rst +0 -0
  61. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/python_api/models.rst +0 -0
  62. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/python_api/mqtt_client.rst +0 -0
  63. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/quickstart.rst +0 -0
  64. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/docs/requirements.txt +0 -0
  65. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/.ruff.toml +0 -0
  66. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/README.md +0 -0
  67. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/air_filter_reset.py +0 -0
  68. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/anti_legionella.py +0 -0
  69. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/auto_recovery.py +0 -0
  70. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/combined_callbacks.py +0 -0
  71. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/demand_response.py +0 -0
  72. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/device_capabilities.py +0 -0
  73. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/device_status_debug.py +0 -0
  74. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/energy_analytics.py +0 -0
  75. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/error_code_demo.py +0 -0
  76. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/mqtt_diagnostics.py +0 -0
  77. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/power_control.py +0 -0
  78. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/recirculation_control.py +0 -0
  79. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/reconnection_demo.py +0 -0
  80. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/reservation_schedule.py +0 -0
  81. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/simple_auto_recovery.py +0 -0
  82. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/token_restoration.py +0 -0
  83. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/tou_openei.py +0 -0
  84. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/tou_schedule.py +0 -0
  85. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/advanced/water_reservation.py +0 -0
  86. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/beginner/01_authentication.py +0 -0
  87. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/beginner/02_list_devices.py +0 -0
  88. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/beginner/03_get_status.py +0 -0
  89. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/beginner/04_set_temperature.py +0 -0
  90. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/advanced_auth_patterns.py +0 -0
  91. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/command_queue.py +0 -0
  92. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/device_status_callback.py +0 -0
  93. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/error_handling.py +0 -0
  94. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/event_driven_control.py +0 -0
  95. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/improved_auth.py +0 -0
  96. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/legacy_auth_constructor.py +0 -0
  97. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  98. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/periodic_requests.py +0 -0
  99. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/set_mode.py +0 -0
  100. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/intermediate/vacation_mode.py +0 -0
  101. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/mask.py +0 -0
  102. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/testing/periodic_device_info.py +0 -0
  103. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/testing/simple_periodic_info.py +0 -0
  104. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/testing/test_api_client.py +0 -0
  105. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/testing/test_mqtt_connection.py +0 -0
  106. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/testing/test_mqtt_messaging.py +0 -0
  107. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/examples/testing/test_periodic_minimal.py +0 -0
  108. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/pyproject.toml +0 -0
  109. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/scripts/README.md +0 -0
  110. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/scripts/bump_version.py +0 -0
  111. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/scripts/diagnose_mqtt_connection.py +0 -0
  112. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/scripts/extract_changelog.py +0 -0
  113. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/scripts/format.py +0 -0
  114. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/scripts/lint.py +0 -0
  115. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/scripts/setup-dev.py +0 -0
  116. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/scripts/validate_version.py +0 -0
  117. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/setup.cfg +0 -0
  118. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/setup.py +0 -0
  119. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/api_client.py +0 -0
  120. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/auth.py +0 -0
  121. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/cli/__init__.py +0 -0
  122. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/cli/__main__.py +0 -0
  123. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/cli/commands.py +0 -0
  124. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/cli/monitoring.py +0 -0
  125. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/cli/output_formatters.py +0 -0
  126. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/cli/rich_output.py +0 -0
  127. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/cli/token_storage.py +0 -0
  128. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/command_decorators.py +0 -0
  129. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/config.py +0 -0
  130. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/converters.py +0 -0
  131. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/device_capabilities.py +0 -0
  132. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/device_info_cache.py +0 -0
  133. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/encoding.py +0 -0
  134. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/enums.py +0 -0
  135. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/events.py +0 -0
  136. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/exceptions.py +0 -0
  137. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/factory.py +0 -0
  138. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/field_factory.py +0 -0
  139. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/models.py +0 -0
  140. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/__init__.py +0 -0
  141. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/client.py +0 -0
  142. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/command_queue.py +0 -0
  143. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/connection.py +0 -0
  144. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/control.py +0 -0
  145. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/diagnostics.py +0 -0
  146. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/periodic.py +0 -0
  147. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/reconnection.py +0 -0
  148. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/subscriptions.py +0 -0
  149. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt/utils.py +0 -0
  150. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/mqtt_events.py +0 -0
  151. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/openei.py +0 -0
  152. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/py.typed +0 -0
  153. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/temperature.py +0 -0
  154. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/topic_builder.py +0 -0
  155. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/unit_system.py +0 -0
  156. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500/utils.py +0 -0
  157. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  158. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  159. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  160. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500_python.egg-info/requires.txt +0 -0
  161. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/src/nwp500_python.egg-info/top_level.txt +0 -0
  162. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/conftest.py +0 -0
  163. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_api_helpers.py +0 -0
  164. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_auth.py +0 -0
  165. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_cli_basic.py +0 -0
  166. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_cli_commands.py +0 -0
  167. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_command_decorators.py +0 -0
  168. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_command_queue.py +0 -0
  169. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_device_capabilities.py +0 -0
  170. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_device_info_cache.py +0 -0
  171. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_events.py +0 -0
  172. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_exceptions.py +0 -0
  173. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_model_converters.py +0 -0
  174. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_models.py +0 -0
  175. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_mqtt_client_init.py +0 -0
  176. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_mqtt_hypothesis.py +0 -0
  177. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_openei.py +0 -0
  178. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_temperature_converters.py +0 -0
  179. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_tou_api.py +0 -0
  180. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_unit_switching.py +0 -0
  181. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tests/test_utils.py +0 -0
  182. {nwp500_python-7.4.7 → nwp500_python-7.4.8}/tox.ini +0 -0
@@ -2,6 +2,18 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
+ Version 7.4.8 (2026-02-17)
6
+ ==========================
7
+
8
+ Added
9
+ -----
10
+ - **Reservation CRUD Helpers**: New public functions ``fetch_reservations()``,
11
+ ``add_reservation()``, ``delete_reservation()``, and ``update_reservation()``
12
+ in ``nwp500.reservations`` (and exported from ``nwp500``). These abstract the
13
+ read-modify-write pattern for single-entry schedule management so library
14
+ users no longer need to fetch the full schedule, splice it manually, and send
15
+ it back. The CLI now delegates to these library functions.
16
+
5
17
  Version 7.4.7 (2026-02-17)
6
18
  ==========================
7
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 7.4.7
3
+ Version: 7.4.8
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
@@ -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
  -----------------------
@@ -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",
@@ -23,7 +23,13 @@ from nwp500.exceptions import (
23
23
  )
24
24
  from nwp500.models import ReservationSchedule
25
25
  from nwp500.mqtt.utils import redact_serial
26
- from nwp500.unit_system import get_unit_system, set_unit_system
26
+ from nwp500.reservations import (
27
+ add_reservation,
28
+ delete_reservation,
29
+ fetch_reservations,
30
+ update_reservation,
31
+ )
32
+ from nwp500.unit_system import get_unit_system
27
33
 
28
34
  from .output_formatters import (
29
35
  print_device_info,
@@ -36,16 +42,6 @@ from .rich_output import get_formatter
36
42
  _logger = logging.getLogger(__name__)
37
43
  _formatter = get_formatter()
38
44
 
39
- # Raw protocol fields for ReservationEntry (used in model_dump include)
40
- _RAW_RESERVATION_FIELDS = {
41
- "enable",
42
- "week",
43
- "hour",
44
- "min",
45
- "mode",
46
- "param",
47
- }
48
-
49
45
  T = TypeVar("T")
50
46
 
51
47
 
@@ -275,45 +271,6 @@ async def handle_power_request(
275
271
  )
276
272
 
277
273
 
278
- async def _fetch_reservations(
279
- mqtt: NavienMqttClient, device: Device
280
- ) -> ReservationSchedule | None:
281
- """Fetch current reservations from device and return as a model.
282
-
283
- Returns None on timeout.
284
- """
285
- future: asyncio.Future[ReservationSchedule] = (
286
- asyncio.get_running_loop().create_future()
287
- )
288
- caller_unit_system = get_unit_system()
289
-
290
- def raw_callback(topic: str, message: dict[str, Any]) -> None:
291
- if (
292
- future.done()
293
- or "response" not in message
294
- or "/res/rsv/" not in topic
295
- ):
296
- return
297
- response = message.get("response", {})
298
- # Ensure it's actually a reservation response (not some other /res/ msg)
299
- if "reservationUse" not in response and "reservation" not in response:
300
- return
301
- if caller_unit_system:
302
- set_unit_system(caller_unit_system)
303
- schedule = ReservationSchedule(**response)
304
- future.set_result(schedule)
305
-
306
- device_type = str(device.device_info.device_type)
307
- response_pattern = f"cmd/{device_type}/+/#"
308
- await mqtt.subscribe(response_pattern, raw_callback)
309
- await mqtt.control.request_reservations(device)
310
- try:
311
- return await asyncio.wait_for(future, timeout=10)
312
- except TimeoutError:
313
- _logger.error("Timed out waiting for reservations.")
314
- return None
315
-
316
-
317
274
  def _schedule_to_display_list(
318
275
  schedule: ReservationSchedule,
319
276
  ) -> list[dict[str, Any]]:
@@ -331,7 +288,7 @@ async def handle_get_reservations_request(
331
288
  mqtt: NavienMqttClient, device: Device, output_json: bool = False
332
289
  ) -> None:
333
290
  """Request current reservation schedule."""
334
- schedule = await _fetch_reservations(mqtt, device)
291
+ schedule = await fetch_reservations(mqtt, device)
335
292
  if schedule is None:
336
293
  return
337
294
 
@@ -388,53 +345,21 @@ async def handle_add_reservation_request(
388
345
  temperature: float,
389
346
  ) -> None:
390
347
  """Add a single reservation to the existing schedule."""
391
- from nwp500.encoding import build_reservation_entry
392
-
393
- # Validate inputs
394
- if not 0 <= hour <= 23:
395
- _logger.error("Hour must be between 0 and 23")
396
- return
397
- if not 0 <= minute <= 59:
398
- _logger.error("Minute must be between 0 and 59")
399
- return
400
- if not 1 <= mode <= 6:
401
- _logger.error("Mode must be between 1 and 6")
402
- return
403
-
404
- # Parse day string (comma-separated: "MO,WE,FR" or full day names)
405
348
  day_list = [d.strip() for d in days.split(",")]
406
-
407
349
  try:
408
- # Build the reservation entry
409
- reservation_entry = build_reservation_entry(
350
+ await add_reservation(
351
+ mqtt,
352
+ device,
410
353
  enabled=enabled,
411
354
  days=day_list,
412
355
  hour=hour,
413
356
  minute=minute,
414
- mode_id=mode,
357
+ mode=mode,
415
358
  temperature=temperature,
416
359
  )
417
-
418
- # Fetch current reservations using shared helper
419
- schedule = await _fetch_reservations(mqtt, device)
420
- if schedule is None:
421
- _logger.error("Timed out fetching current reservations")
422
- return
423
-
424
- # Build raw entry list and append new one
425
- current_reservations = [
426
- e.model_dump(include=_RAW_RESERVATION_FIELDS)
427
- for e in schedule.reservation
428
- ]
429
- current_reservations.append(reservation_entry)
430
-
431
- # Update the full schedule
432
- await mqtt.control.update_reservations(
433
- device, current_reservations, enabled=True
434
- )
435
-
436
360
  print("✓ Reservation added successfully")
437
-
361
+ except (ValueError, TimeoutError) as e:
362
+ _logger.error(str(e))
438
363
  except (RangeValidationError, ValidationError) as e:
439
364
  _logger.error(f"Failed to add reservation: {e}")
440
365
 
@@ -445,34 +370,11 @@ async def handle_delete_reservation_request(
445
370
  index: int,
446
371
  ) -> None:
447
372
  """Delete a single reservation by 1-based index."""
448
- schedule = await _fetch_reservations(mqtt, device)
449
- if schedule is None:
450
- _logger.error("Timed out fetching current reservations")
451
- return
452
-
453
- count = len(schedule.reservation)
454
- if index < 1 or index > count:
455
- _logger.error(
456
- f"Invalid reservation index {index}. "
457
- f"Valid range: 1-{count} ({count} reservation(s) exist)"
458
- )
459
- return
460
-
461
- # Build raw entry list and remove the target
462
- current_reservations = [
463
- e.model_dump(include=_RAW_RESERVATION_FIELDS)
464
- for e in schedule.reservation
465
- ]
466
- removed = current_reservations.pop(index - 1)
467
- _logger.info(f"Removing reservation {index}: {removed}")
468
-
469
- # Determine if reservations should stay enabled
470
- still_enabled = schedule.enabled and len(current_reservations) > 0
471
-
472
- await mqtt.control.update_reservations(
473
- device, current_reservations, enabled=still_enabled
474
- )
475
- print(f"✓ Reservation {index} deleted successfully")
373
+ try:
374
+ await delete_reservation(mqtt, device, index)
375
+ print(f" Reservation {index} deleted successfully")
376
+ except (ValueError, TimeoutError) as e:
377
+ _logger.error(str(e))
476
378
 
477
379
 
478
380
  async def handle_update_reservation_request(
@@ -491,66 +393,26 @@ async def handle_update_reservation_request(
491
393
 
492
394
  Only the provided fields are modified; others are preserved.
493
395
  """
494
- from nwp500.encoding import build_reservation_entry
495
-
496
- schedule = await _fetch_reservations(mqtt, device)
497
- if schedule is None:
498
- _logger.error("Timed out fetching current reservations")
499
- return
500
-
501
- count = len(schedule.reservation)
502
- if index < 1 or index > count:
503
- _logger.error(
504
- f"Invalid reservation index {index}. "
505
- f"Valid range: 1-{count} ({count} reservation(s) exist)"
506
- )
507
- return
508
-
509
- existing = schedule.reservation[index - 1]
510
-
511
- # Merge: use provided values or fall back to existing
512
- new_enabled = enabled if enabled is not None else existing.enabled
513
- new_days: list[str] = (
514
- [d.strip() for d in days.split(",")] if days else existing.days
396
+ day_list: list[str] | None = (
397
+ [d.strip() for d in days.split(",")] if days is not None else None
515
398
  )
516
- new_hour = hour if hour is not None else existing.hour
517
- new_minute = minute if minute is not None else existing.min
518
- new_mode = mode if mode is not None else existing.mode
519
-
520
- # Temperature requires special handling: if user provides a value
521
- # it's in their preferred unit, otherwise keep the raw param.
522
- if temperature is not None:
523
- new_entry = build_reservation_entry(
524
- enabled=new_enabled,
525
- days=new_days,
526
- hour=new_hour,
527
- minute=new_minute,
528
- mode_id=new_mode,
399
+ try:
400
+ await update_reservation(
401
+ mqtt,
402
+ device,
403
+ index,
404
+ enabled=enabled,
405
+ days=day_list,
406
+ hour=hour,
407
+ minute=minute,
408
+ mode=mode,
529
409
  temperature=temperature,
530
410
  )
531
- else:
532
- from nwp500.encoding import encode_week_bitfield
533
-
534
- new_entry = {
535
- "enable": 2 if new_enabled else 1,
536
- "week": encode_week_bitfield(new_days),
537
- "hour": new_hour,
538
- "min": new_minute,
539
- "mode": new_mode,
540
- "param": existing.param,
541
- }
542
-
543
- # Build full list with the replacement
544
- current_reservations = [
545
- e.model_dump(include=_RAW_RESERVATION_FIELDS)
546
- for e in schedule.reservation
547
- ]
548
- current_reservations[index - 1] = new_entry
549
-
550
- await mqtt.control.update_reservations(
551
- device, current_reservations, enabled=schedule.enabled
552
- )
553
- print(f"✓ Reservation {index} updated successfully")
411
+ print(f"✓ Reservation {index} updated successfully")
412
+ except (ValueError, TimeoutError) as e:
413
+ _logger.error(str(e))
414
+ except (RangeValidationError, ValidationError) as e:
415
+ _logger.error(f"Failed to update reservation: {e}")
554
416
 
555
417
 
556
418
  async def handle_enable_anti_legionella_request(