nwp500-python 9.10.0__tar.gz → 9.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/CHANGELOG.rst +34 -0
  2. {nwp500_python-9.10.0/src/nwp500_python.egg-info → nwp500_python-9.11.0}/PKG-INFO +1 -1
  3. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/simulate-heater.rst +16 -4
  4. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/sim.rst +4 -2
  5. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/params.py +50 -6
  6. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim_home_assistant.py +325 -17
  7. {nwp500_python-9.10.0 → nwp500_python-9.11.0/src/nwp500_python.egg-info}/PKG-INFO +1 -1
  8. nwp500_python-9.11.0/src/nwp500_python.egg-info/scm_version.json +8 -0
  9. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_home_assistant.py +427 -0
  10. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_hot_water.py +47 -0
  11. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_overlay.py +15 -0
  12. nwp500_python-9.10.0/src/nwp500_python.egg-info/scm_version.json +0 -8
  13. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.coveragerc +0 -0
  14. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.github/RESOLVING_PR_COMMENTS.md +0 -0
  15. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.github/copilot-instructions.md +0 -0
  16. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.github/workflows/ci.yml +0 -0
  17. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.github/workflows/release.yml +0 -0
  18. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.gitignore +0 -0
  19. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.pre-commit-config.yaml +0 -0
  20. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/.readthedocs.yml +0 -0
  21. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/AUTHORS.rst +0 -0
  22. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/CONTRIBUTING.rst +0 -0
  23. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/LICENSE.txt +0 -0
  24. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/Makefile +0 -0
  25. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/README.rst +0 -0
  26. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/RELEASE.md +0 -0
  27. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/Makefile +0 -0
  28. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/_static/.gitignore +0 -0
  29. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/conf.py +0 -0
  30. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/architecture.rst +0 -0
  31. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/draws.rst +0 -0
  32. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/heating-elements.rst +0 -0
  33. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/index.rst +0 -0
  34. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/recovery-speed.rst +0 -0
  35. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/simulator.rst +0 -0
  36. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/standby-loss.rst +0 -0
  37. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/tank-energy.rst +0 -0
  38. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/tou-recovery-cap.rst +0 -0
  39. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/unknown-values.rst +0 -0
  40. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/explanation/what-starts-a-recovery.rst +0 -0
  41. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/authenticate.rst +0 -0
  42. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/auto-recovery.rst +0 -0
  43. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/bluetooth.rst +0 -0
  44. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/calibrate-simulator.rst +0 -0
  45. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/diagnose-mqtt.rst +0 -0
  46. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/home-assistant.rst +0 -0
  47. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/index.rst +0 -0
  48. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/maintenance.rst +0 -0
  49. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/manage-units.rst +0 -0
  50. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/monitor-status.rst +0 -0
  51. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/optimize-tou.rst +0 -0
  52. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/queue-commands.rst +0 -0
  53. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/schedule-operation.rst +0 -0
  54. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/how-to/track-energy.rst +0 -0
  55. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/index.rst +0 -0
  56. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/openapi.yaml +0 -0
  57. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/authors.rst +0 -0
  58. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/changelog.rst +0 -0
  59. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/contributing.rst +0 -0
  60. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/history.rst +0 -0
  61. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/project/license.rst +0 -0
  62. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/configuration.rst +0 -0
  63. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/enumerations.rst +0 -0
  64. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/index.rst +0 -0
  65. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/installation.rst +0 -0
  66. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/data_conversions.rst +0 -0
  67. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/device_features.rst +0 -0
  68. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/device_status.rst +0 -0
  69. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/error_codes.rst +0 -0
  70. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/mqtt_protocol.rst +0 -0
  71. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/quick_reference.rst +0 -0
  72. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/protocol/rest_api.rst +0 -0
  73. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/api_client.rst +0 -0
  74. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/auth_client.rst +0 -0
  75. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/cli.rst +0 -0
  76. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/events.rst +0 -0
  77. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/exceptions.rst +0 -0
  78. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/models.rst +0 -0
  79. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/python_api/mqtt_client.rst +0 -0
  80. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/reference/simulator-inputs.rst +0 -0
  81. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/requirements.txt +0 -0
  82. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/docs/tutorials/getting-started.rst +0 -0
  83. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/.ruff.toml +0 -0
  84. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/README.md +0 -0
  85. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/air_filter_reset.py +0 -0
  86. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/anti_legionella.py +0 -0
  87. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/auto_recovery.py +0 -0
  88. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/combined_callbacks.py +0 -0
  89. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/demand_response.py +0 -0
  90. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/device_capabilities.py +0 -0
  91. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/device_status_debug.py +0 -0
  92. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/energy_analytics.py +0 -0
  93. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/error_code_demo.py +0 -0
  94. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/firmware_payload_capture.py +0 -0
  95. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/mqtt_diagnostics.py +0 -0
  96. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/power_control.py +0 -0
  97. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/recirculation_control.py +0 -0
  98. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/reconnection_demo.py +0 -0
  99. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/reservation_schedule.py +0 -0
  100. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/simple_auto_recovery.py +0 -0
  101. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/token_restoration.py +0 -0
  102. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/tou_openei.py +0 -0
  103. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/tou_schedule.py +0 -0
  104. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/advanced/water_reservation.py +0 -0
  105. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/beginner/01_authentication.py +0 -0
  106. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/beginner/02_list_devices.py +0 -0
  107. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/beginner/03_get_status.py +0 -0
  108. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/beginner/04_set_temperature.py +0 -0
  109. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/advanced_auth_patterns.py +0 -0
  110. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/command_queue.py +0 -0
  111. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/device_status_callback.py +0 -0
  112. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/error_handling.py +0 -0
  113. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/event_driven_control.py +0 -0
  114. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/improved_auth.py +0 -0
  115. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/legacy_auth_constructor.py +0 -0
  116. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
  117. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/periodic_requests.py +0 -0
  118. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/set_mode.py +0 -0
  119. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/intermediate/vacation_mode.py +0 -0
  120. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/mask.py +0 -0
  121. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/simulator/lower-setpoint.json +0 -0
  122. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/simulator/plan.json +0 -0
  123. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/simulator/scenario.json +0 -0
  124. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/periodic_device_info.py +0 -0
  125. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/simple_periodic_info.py +0 -0
  126. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/test_api_client.py +0 -0
  127. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/test_mqtt_connection.py +0 -0
  128. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/test_mqtt_messaging.py +0 -0
  129. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/examples/testing/test_periodic_minimal.py +0 -0
  130. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/pyproject.toml +0 -0
  131. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/README.md +0 -0
  132. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/bump_version.py +0 -0
  133. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/diagnose_mqtt_connection.py +0 -0
  134. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/extract_changelog.py +0 -0
  135. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/format.py +0 -0
  136. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/lint.py +0 -0
  137. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/setup-dev.py +0 -0
  138. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/scripts/validate_version.py +0 -0
  139. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/setup.cfg +0 -0
  140. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/setup.py +0 -0
  141. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/__init__.py +0 -0
  142. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/_base.py +0 -0
  143. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/api_client.py +0 -0
  144. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/auth.py +0 -0
  145. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/__init__.py +0 -0
  146. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/__main__.py +0 -0
  147. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/client.py +0 -0
  148. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/mqtt_adapter.py +0 -0
  149. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/ble/protocol.py +0 -0
  150. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/__init__.py +0 -0
  151. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/__main__.py +0 -0
  152. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/ble_backend.py +0 -0
  153. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/calibration.py +0 -0
  154. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/handlers.py +0 -0
  155. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/monitoring.py +0 -0
  156. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/output_formatters.py +0 -0
  157. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/presentation.py +0 -0
  158. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/recording.py +0 -0
  159. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/rich_output.py +0 -0
  160. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/simulate.py +0 -0
  161. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/cli/token_storage.py +0 -0
  162. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/command_decorators.py +0 -0
  163. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/config.py +0 -0
  164. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/converters.py +0 -0
  165. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/device_capabilities.py +0 -0
  166. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/device_info_cache.py +0 -0
  167. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/encoding.py +0 -0
  168. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/enums.py +0 -0
  169. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/events.py +0 -0
  170. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/exceptions.py +0 -0
  171. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/factory.py +0 -0
  172. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/field_factory.py +0 -0
  173. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/__init__.py +0 -0
  174. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/_converters.py +0 -0
  175. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/account.py +0 -0
  176. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/device.py +0 -0
  177. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/diagnostics.py +0 -0
  178. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/energy.py +0 -0
  179. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/feature.py +0 -0
  180. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/firmware.py +0 -0
  181. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/maintenance.py +0 -0
  182. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/mqtt_models.py +0 -0
  183. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/schedule.py +0 -0
  184. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/status.py +0 -0
  185. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/models/tou.py +0 -0
  186. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/__init__.py +0 -0
  187. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/_control_commands.py +0 -0
  188. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/_device_subscriptions.py +0 -0
  189. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/client.py +0 -0
  190. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/command_queue.py +0 -0
  191. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/connection.py +0 -0
  192. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/control.py +0 -0
  193. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/diagnostics.py +0 -0
  194. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/periodic.py +0 -0
  195. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/reconnection.py +0 -0
  196. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/state_tracker.py +0 -0
  197. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/subscriptions.py +0 -0
  198. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/types.py +0 -0
  199. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt/utils.py +0 -0
  200. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/mqtt_events.py +0 -0
  201. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/openei.py +0 -0
  202. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/py.typed +0 -0
  203. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/reservations.py +0 -0
  204. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/__init__.py +0 -0
  205. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/api.py +0 -0
  206. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/attribution.py +0 -0
  207. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/book.py +0 -0
  208. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/calibration.py +0 -0
  209. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/circulation.py +0 -0
  210. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/codec.py +0 -0
  211. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/cold_supply.py +0 -0
  212. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/controller.py +0 -0
  213. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/decay.py +0 -0
  214. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/defaults.py +0 -0
  215. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/delivery.py +0 -0
  216. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/draw_forecast.py +0 -0
  217. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/engine.py +0 -0
  218. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/extensions.py +0 -0
  219. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/heat_pump_recovery.py +0 -0
  220. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/heating_trajectory.py +0 -0
  221. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/inputs.py +0 -0
  222. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/location_air.py +0 -0
  223. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/observed.py +0 -0
  224. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/__init__.py +0 -0
  225. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/compressor.py +0 -0
  226. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/draw.py +0 -0
  227. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/element.py +0 -0
  228. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/refrigerant.py +0 -0
  229. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/physics/tank.py +0 -0
  230. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/profile.py +0 -0
  231. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/ready.py +0 -0
  232. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/record.py +0 -0
  233. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/result.py +0 -0
  234. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/schedules.py +0 -0
  235. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/tank_physics.py +0 -0
  236. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/tou_triggers.py +0 -0
  237. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim/units.py +0 -0
  238. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim_adapters.py +0 -0
  239. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim_calibrate.py +0 -0
  240. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/sim_live.py +0 -0
  241. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/status_history.py +0 -0
  242. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/temperature.py +0 -0
  243. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/topic_builder.py +0 -0
  244. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/tou_schedule.py +0 -0
  245. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/unit_system.py +0 -0
  246. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500/utils.py +0 -0
  247. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/SOURCES.txt +0 -0
  248. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
  249. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/entry_points.txt +0 -0
  250. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/not-zip-safe +0 -0
  251. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/requires.txt +0 -0
  252. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/scm_file_list.json +0 -0
  253. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/src/nwp500_python.egg-info/top_level.txt +0 -0
  254. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/_sim_site.py +0 -0
  255. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/conftest.py +0 -0
  256. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/fixtures/sim/engine_golden.json +0 -0
  257. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/fixtures/sim/heater_params.json +0 -0
  258. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/fixtures/sim/heater_params_fitted.json +0 -0
  259. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_api_endpoints_201.py +0 -0
  260. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_api_helpers.py +0 -0
  261. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_app_queries.py +0 -0
  262. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_auth.py +0 -0
  263. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_auth_session_lifecycle.py +0 -0
  264. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_auth_session_refresh.py +0 -0
  265. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_ble_mqtt_adapter.py +0 -0
  266. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_ble_protocol.py +0 -0
  267. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_bug_fixes.py +0 -0
  268. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_canonical_schedule.py +0 -0
  269. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_basic.py +0 -0
  270. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_calibrate.py +0 -0
  271. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_commands.py +0 -0
  272. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_simulate.py +0 -0
  273. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_cli_tristate_presentation.py +0 -0
  274. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_command_decorators.py +0 -0
  275. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_command_queue.py +0 -0
  276. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_device_capabilities.py +0 -0
  277. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_device_info_cache.py +0 -0
  278. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_device_rest_models.py +0 -0
  279. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_events.py +0 -0
  280. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_exceptions.py +0 -0
  281. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_model_converters.py +0 -0
  282. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_models.py +0 -0
  283. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_clean_session_resume.py +0 -0
  284. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_client_init.py +0 -0
  285. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_control_fail.py +0 -0
  286. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_events.py +0 -0
  287. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_hypothesis.py +0 -0
  288. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_last_will.py +0 -0
  289. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_reconnection.py +0 -0
  290. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_reconnection_storm.py +0 -0
  291. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_mqtt_reliability.py +0 -0
  292. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_multi_device.py +0 -0
  293. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_navilink_201_enums.py +0 -0
  294. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_openei.py +0 -0
  295. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_protocol_correctness.py +0 -0
  296. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_public_api.py +0 -0
  297. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_reservation_validation.py +0 -0
  298. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_reservations.py +0 -0
  299. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_adapters.py +0 -0
  300. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_api.py +0 -0
  301. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_book.py +0 -0
  302. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_book_history.py +0 -0
  303. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_bottom_loss.py +0 -0
  304. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_calibrate.py +0 -0
  305. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_calibration.py +0 -0
  306. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_change_history.py +0 -0
  307. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_circulation.py +0 -0
  308. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_codec.py +0 -0
  309. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_cold_supply.py +0 -0
  310. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_controller.py +0 -0
  311. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_defaults.py +0 -0
  312. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_documents.py +0 -0
  313. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_draw_forecast.py +0 -0
  314. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_draw_loss.py +0 -0
  315. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_element.py +0 -0
  316. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_element_fit.py +0 -0
  317. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_fit_book.py +0 -0
  318. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_forecast.py +0 -0
  319. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_golden.py +0 -0
  320. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_import_contract.py +0 -0
  321. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_live.py +0 -0
  322. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_location_air.py +0 -0
  323. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_no_clock_no_state.py +0 -0
  324. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_observed.py +0 -0
  325. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_profile.py +0 -0
  326. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_readiness.py +0 -0
  327. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_readiness_fit.py +0 -0
  328. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_ready.py +0 -0
  329. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_ready_api.py +0 -0
  330. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_replay.py +0 -0
  331. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_schedules.py +0 -0
  332. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_sim_tank_physics.py +0 -0
  333. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_status_history.py +0 -0
  334. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_temperature_converters.py +0 -0
  335. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_threading_model.py +0 -0
  336. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_token_storage.py +0 -0
  337. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_tou_api.py +0 -0
  338. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_tou_schedule.py +0 -0
  339. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_unit_switching.py +0 -0
  340. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_unit_system_process_wide.py +0 -0
  341. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_utility_modules.py +0 -0
  342. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tests/test_utils.py +0 -0
  343. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/tox.ini +0 -0
  344. {nwp500_python-9.10.0 → nwp500_python-9.11.0}/uv.lock +0 -0
@@ -2,6 +2,40 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
+ Version 9.11.0 (2026-10-22)
6
+ ===========================
7
+
8
+ Added
9
+ -----
10
+ - **The air from a weather entity without a model** (#214). With a
11
+ weather entity and no ``location_air`` model,
12
+ ``sim_home_assistant.HomeAssistantEnvironment`` fits one
13
+ (``sim.location_air.fit``) on the last 3 days
14
+ (``LOCATION_AIR_WINDOW``) of the air entity's readings, or with
15
+ ``heater=`` the heater's own air reading while its compressor runs,
16
+ against the weather entity's outdoor temperature, each reading from
17
+ the unit it was recorded in (``HomeAssistantClient.outdoor_history``,
18
+ ``parse_outdoor_history``). It projects the forecast through the fit
19
+ when the readings the outdoor history covers span a day and follow
20
+ the outdoor air at r 0.5 or more, and keeps it as ``fitted_air``.
21
+ ``nwp-cli simulate --sources`` fits one the same way when the sources
22
+ file has no ``[location_air]``.
23
+
24
+ Fixed
25
+ -----
26
+ - ``sim.params.params_from_document`` raised ``TypeError`` for a list
27
+ field given as something else (``unfitted``, the draw domain ranges,
28
+ readiness ``evidence``), past the ``ValueError`` it documents and
29
+ that ``sim_live.overlay`` and ``nwp-cli simulate`` handle. It read a
30
+ string, null or bool in a number field without complaint. Each is
31
+ now a ``ValueError`` naming the field (#212).
32
+ - ``HomeAssistantEnvironment`` found no inlet plateau for a probe that
33
+ held one value through a long draw's tail. Home Assistant's history
34
+ writes a row only when a value changes, so the tail had one reading
35
+ or none, under the plateau's 3. The probe's history is now read as
36
+ the value in force every 10 seconds (``held``, ``PROBE_STEP``)
37
+ (#216).
38
+
5
39
  Version 9.10.0 (2026-10-22)
6
40
  ===========================
7
41
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.10.0
3
+ Version: 9.11.0
4
4
  Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
5
5
  Home-page: https://github.com/eman/nwp500-python
6
6
  Author: Emmanuel Levijarvi
@@ -159,10 +159,13 @@ give:
159
159
 
160
160
  - **the air:** the hourly forecast projected through the
161
161
  ``[location_air]`` model, shifted to pass through a reading beside the
162
- heater that is at most an hour old. Without a model, that reading is
163
- held over the span; without a fresh one, the projection is
164
- unshifted. Fit a model for your own heater with
165
- ``sim.location_air.fit``;
162
+ heater that is at most an hour old; without a fresh one, the
163
+ projection is unshifted. Without a ``[location_air]`` model, one is
164
+ fitted on the last 3 days of the ``air`` entity's readings against
165
+ the ``weather`` entity's outdoor temperature. It is used when the
166
+ readings span a day and follow the outdoor air at r 0.5 or more; a
167
+ note gives the fit. Otherwise, and without a ``weather`` entity, the
168
+ reading beside the heater is held over the span;
166
169
  - **the humidity:** a reading at most an hour old;
167
170
  - **the inlet:** the plateau of the long draws (11 minutes or more,
168
171
  circulation off) in the last 72 hours, from the inlet probe and the
@@ -182,6 +185,15 @@ in rpm or Hz. A part that can't be read, or is stale, keeps its
182
185
  default, and a warning on stderr says why. ``--ambient``, ``--inlet``
183
186
  and ``--environment`` override what the sources give.
184
187
 
188
+ From Python, ``sim_home_assistant.HomeAssistantEnvironment`` reads the
189
+ same parts. Given ``heater=``, a ``HeaterEntities`` with ``ambient`` and
190
+ ``compressor``, it fits the model without an ``air`` entity: on the
191
+ heater's own air reading every 10 minutes while the compressor runs,
192
+ where the reading was taken 2 minutes or more into the run (earlier, or
193
+ idle, it reads the cabinet), and not while the sensor was unavailable.
194
+ The latest such reading, if under an hour old, anchors the projection.
195
+ ``fitted_air`` holds the fit once ``read`` has run.
196
+
185
197
 
186
198
  Fetch what the heater reports
187
199
  =============================
@@ -313,9 +313,11 @@ Home Assistant sources
313
313
 
314
314
  .. automodule:: nwp500.sim_home_assistant
315
315
  :members: HomeAssistantEnvironment, HomeAssistantClient, record_inputs,
316
- RecordInputs, INLET_LOOKBACK, Sources, Entities, sources_from, State, parse_state,
316
+ RecordInputs, INLET_LOOKBACK, PROBE_STEP, held, LOCATION_AIR_WINDOW,
317
+ LOCATION_AIR_MIN_SPAN, LOCATION_AIR_MIN_R, RUN_SETTLE, Sources,
318
+ Entities, sources_from, State, parse_state,
317
319
  parse_history, parse_outages, parse_states, History, history_for,
318
- parse_forecast, read_heater_history, HeaterEntities, HeaterHistory,
320
+ parse_forecast, parse_outdoor_history, read_heater_history, HeaterEntities, HeaterHistory,
319
321
  HISTORY_WINDOW, to_w, to_wh,
320
322
  pump_runs, demand_spans, busy, covers_start, percent,
321
323
  fresh, to_f, to_gpm, to_gal, to_rpm, HomeAssistantError
@@ -45,8 +45,8 @@ from collections.abc import Mapping
45
45
  from dataclasses import MISSING, dataclass, field, fields, replace
46
46
  from datetime import timedelta
47
47
  from enum import StrEnum
48
- from types import MappingProxyType
49
- from typing import Any
48
+ from types import MappingProxyType, NoneType, UnionType
49
+ from typing import Any, cast, get_args, get_origin, get_type_hints
50
50
 
51
51
  from nwp500.sim.units import WATER_BTU_PER_GAL_F
52
52
 
@@ -1187,10 +1187,49 @@ def unknown_fields(doc: Mapping[str, Any]) -> list[str]:
1187
1187
  out = [k for k in doc if k not in ("schema", "unfitted") and k not in PARTS]
1188
1188
  for name, cls in PARTS.items():
1189
1189
  known = {f.name for f in fields(cls)}
1190
- out.extend(f"{name}.{k}" for k in doc.get(name, {}) if k not in known)
1190
+ given = doc.get(name, {})
1191
+ if isinstance(given, Mapping):
1192
+ keys = cast(Mapping[str, Any], given)
1193
+ out.extend(f"{name}.{k}" for k in keys if k not in known)
1191
1194
  return out
1192
1195
 
1193
1196
 
1197
+ def _check(where: str, hint: Any, value: Any) -> None:
1198
+ """A ``ValueError`` naming ``where`` when ``value`` is not what a
1199
+ document holds for a field of type ``hint``: a number for a float,
1200
+ a list for a tuple, null for None."""
1201
+ args = get_args(hint)
1202
+ if get_origin(hint) is UnionType:
1203
+ if value is None and NoneType in args:
1204
+ return
1205
+ (hint,) = (a for a in args if a is not NoneType)
1206
+ args = get_args(hint)
1207
+ if get_origin(hint) is tuple:
1208
+ if not isinstance(value, list | tuple):
1209
+ raise ValueError(f"{where} is {value!r}, not a list")
1210
+ items: list[Any] | tuple[Any, ...] = value
1211
+ if len(args) == 2 and args[1] is Ellipsis:
1212
+ args = (args[0],) * len(items)
1213
+ elif len(items) != len(args):
1214
+ raise ValueError(
1215
+ f"{where} has {len(items)} values, not {len(args)}"
1216
+ )
1217
+ for i, (h, v) in enumerate(zip(args, items, strict=True)):
1218
+ _check(f"{where}[{i}]", h, v)
1219
+ return
1220
+ if hint is float:
1221
+ ok = isinstance(value, int | float) and not isinstance(value, bool)
1222
+ kind = "a number"
1223
+ elif hint is bool:
1224
+ ok, kind = isinstance(value, bool), "true or false"
1225
+ elif hint is str:
1226
+ ok, kind = isinstance(value, str), "a string"
1227
+ else:
1228
+ return
1229
+ if not ok:
1230
+ raise ValueError(f"{where} is {value!r}, not {kind}")
1231
+
1232
+
1194
1233
  def params_from_document(doc: Mapping[str, Any]) -> HeaterParams:
1195
1234
  """The ``HeaterParams`` ``doc`` holds.
1196
1235
 
@@ -1199,8 +1238,9 @@ def params_from_document(doc: Mapping[str, Any]) -> HeaterParams:
1199
1238
  a ``ValueError``. Parts and fields this code does not know are left
1200
1239
  out (``unknown_fields`` names them): the code reading the document
1201
1240
  runs no rule that could use them. A schema other than
1202
- ``PARAMS_SCHEMA`` is a ``ValueError``, and so is a ``doc`` that is
1203
- not an object.
1241
+ ``PARAMS_SCHEMA`` is a ``ValueError``, and so are a ``doc`` that is
1242
+ not an object and a value of the wrong type (a number where a list
1243
+ goes, a string where a number goes).
1204
1244
  """
1205
1245
  if not isinstance(doc, Mapping): # pyright: ignore[reportUnnecessaryIsInstance]
1206
1246
  raise ValueError("a parameters document is an object")
@@ -1216,10 +1256,14 @@ def params_from_document(doc: Mapping[str, Any]) -> HeaterParams:
1216
1256
  if not isinstance(given, Mapping):
1217
1257
  raise ValueError(f"part {name!r} is not an object")
1218
1258
  values = {}
1259
+ hints = get_type_hints(cls)
1219
1260
  for f in fields(cls):
1220
1261
  if f.name in given:
1262
+ _check(f"{name}.{f.name}", hints[f.name], given[f.name])
1221
1263
  values[f.name] = given[f.name]
1222
1264
  elif f.default is MISSING and f.default_factory is MISSING:
1223
1265
  raise ValueError(f"{name}.{f.name} is missing")
1224
1266
  parts[name] = cls(**values)
1225
- return HeaterParams(**parts, unfitted=tuple(doc.get("unfitted", ())))
1267
+ unfitted = doc.get("unfitted", ())
1268
+ _check("unfitted", tuple[str, ...], unfitted)
1269
+ return HeaterParams(**parts, unfitted=tuple(unfitted))
@@ -14,9 +14,9 @@ this module, a ``sim_live`` source:
14
14
  ``sim_live.compose()``:
15
15
 
16
16
  - **the air:** the outdoor forecast projected through a
17
- ``location_air`` model, anchored to a fresh reading of the air
18
- beside the heater; without a model, that reading held over the
19
- span;
17
+ ``location_air`` model, given or fitted on the recent history,
18
+ anchored to a fresh reading of the air beside the heater; without
19
+ a weather entity or a model, that reading held over the span;
20
20
  - **the humidity:** a fresh reading;
21
21
  - **the inlet:** the plateau of the long draws in the last 72 h, from
22
22
  the inlet probe, the household's draws (a demand entity) and the
@@ -37,6 +37,7 @@ from the caller's configuration (``sources_from``).
37
37
  import enum
38
38
  import logging
39
39
  import math
40
+ from bisect import bisect_right
40
41
  from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
41
42
  from dataclasses import dataclass, field
42
43
  from datetime import UTC, datetime, timedelta
@@ -45,7 +46,12 @@ from typing import Any, cast
45
46
  from nwp500 import sim_adapters as sa
46
47
  from nwp500.exceptions import Nwp500Error
47
48
  from nwp500.sim import Environment
48
- from nwp500.sim.location_air import LocationAirModel
49
+ from nwp500.sim.location_air import (
50
+ FIT_LAGS_H,
51
+ FittedLocationAir,
52
+ LocationAirModel,
53
+ )
54
+ from nwp500.sim.location_air import fit as fit_air
49
55
  from nwp500.sim.location_air import project as project_air
50
56
 
51
57
  __author__ = "Emmanuel Levijarvi"
@@ -67,6 +73,20 @@ PUMP_ON_RPM = 10.0
67
73
  PUMP_STALE = timedelta(seconds=300)
68
74
  #: How far back the inlet's plateau looks.
69
75
  INLET_LOOKBACK = timedelta(hours=72)
76
+ #: The grid the inlet probe's history is read on (``held``): the
77
+ #: plateau takes readings as samples, and a history keeps only changes.
78
+ PROBE_STEP = timedelta(seconds=10)
79
+ #: How far back the air beside the heater and the outdoor air are read
80
+ #: to fit a ``location_air`` model where none is given.
81
+ LOCATION_AIR_WINDOW = timedelta(days=3)
82
+ #: The least span of readings such a fit is made on: a day, so it sees
83
+ #: the outdoor air's daily swing.
84
+ LOCATION_AIR_MIN_SPAN = timedelta(days=1)
85
+ #: The least r (``FittedLocationAir.r``) such a fit is used at.
86
+ LOCATION_AIR_MIN_R = 0.5
87
+ #: The heater's own air reading is the air it stands in from this far
88
+ #: into a compressor run (about 2.7 °F warm before), and not while idle.
89
+ RUN_SETTLE = timedelta(minutes=2)
70
90
  #: How long a request waits, seconds.
71
91
  DEFAULT_TIMEOUT = 10.0
72
92
 
@@ -98,7 +118,8 @@ class Entities:
98
118
  demand: str | None = None
99
119
  #: The circulation pump's speed.
100
120
  circulation_speed: str | None = None
101
- #: A weather entity with an hourly forecast.
121
+ #: A weather entity with an hourly forecast, its ``temperature``
122
+ #: attribute the outdoor air.
102
123
  weather: str | None = None
103
124
 
104
125
 
@@ -106,7 +127,8 @@ class Entities:
106
127
  class Sources:
107
128
  """Where the inputs are: Home Assistant's address and token, the
108
129
  entities, and the model that projects the air from the outdoor
109
- forecast (None: hold a fresh reading)."""
130
+ forecast (None: one fitted from the recent history, as
131
+ ``HomeAssistantEnvironment`` fits it)."""
110
132
 
111
133
  url: str
112
134
  token: str = field(repr=False)
@@ -454,6 +476,45 @@ def parse_forecast(doc: Any, entity: str) -> tuple[list[Reading], str | None]:
454
476
  )
455
477
 
456
478
 
479
+ def parse_outdoor_history(
480
+ doc: Any, entity: str
481
+ ) -> list[tuple[datetime, float, str | None]]:
482
+ """A weather entity's outdoor air from ``/api/history/period``'s
483
+ answer: each row's instant, ``temperature`` attribute and the unit
484
+ it was recorded in (``temperature_unit``, None where the row names
485
+ none), in time order. Rows without a number are left out."""
486
+ if not isinstance(doc, list):
487
+ raise HomeAssistantError("not a history")
488
+ out: list[tuple[datetime, float, str | None]] = []
489
+ for series in doc:
490
+ if not isinstance(series, list):
491
+ continue
492
+ for r in series:
493
+ if not isinstance(r, Mapping):
494
+ continue
495
+ row = cast(Mapping[str, Any], r)
496
+ if row.get("entity_id", entity) != entity:
497
+ break
498
+ stamp = row.get("last_updated") or row.get("last_changed")
499
+ attributes = row.get("attributes")
500
+ if stamp is None or not isinstance(attributes, Mapping):
501
+ continue
502
+ a = cast(Mapping[str, Any], attributes)
503
+ value = _number(a.get("temperature"))
504
+ if value is None:
505
+ continue
506
+ unit = a.get("temperature_unit")
507
+ out.append(
508
+ (
509
+ _instant(stamp),
510
+ value,
511
+ unit if isinstance(unit, str) else None,
512
+ )
513
+ )
514
+ out.sort(key=lambda x: x[0])
515
+ return out
516
+
517
+
457
518
  def to_f(value: float, unit: str | None) -> float:
458
519
  """A temperature in ``unit`` (°F, °C or K), in °F."""
459
520
  u = (unit or "").strip().replace("º", "°")
@@ -490,6 +551,35 @@ def to_rpm(value: float, unit: str | None) -> float:
490
551
  raise ValueError(f"{unit!r} is not a rotational speed unit")
491
552
 
492
553
 
554
+ def held(
555
+ readings: Sequence[Reading],
556
+ start: datetime,
557
+ end: datetime,
558
+ step: timedelta = PROBE_STEP,
559
+ ) -> list[Reading]:
560
+ """``readings``, a history of changes, as a probe sampled every
561
+ ``step`` from ``start`` to ``end`` would read them: the value in
562
+ force at each instant, none before the first reading. A history
563
+ writes no row for a report of the value it already holds, so a
564
+ probe steady over a span has one reading there, or none. The grid
565
+ is in UTC, every ``step`` of elapsed time across a clock change."""
566
+ if step <= timedelta(0):
567
+ raise ValueError("step must be positive")
568
+ # In UTC: two local times in one zone compare, and step, by the wall
569
+ # clock, which skips or repeats an hour at a daylight-saving change.
570
+ rows = sorted((t.astimezone(UTC), v) for t, v in readings)
571
+ out: list[Reading] = []
572
+ i = -1
573
+ t, end = start.astimezone(UTC), end.astimezone(UTC)
574
+ while t <= end:
575
+ while i + 1 < len(rows) and rows[i + 1][0] <= t:
576
+ i += 1
577
+ if i >= 0:
578
+ out.append((t, rows[i][1]))
579
+ t += step
580
+ return out
581
+
582
+
493
583
  def busy(*spans: Sequence[Span]) -> list[Span]:
494
584
  """``spans`` together, overlapping ones joined, in time order, in
495
585
  UTC."""
@@ -756,6 +846,36 @@ class HomeAssistantClient:
756
846
  unit = (await self._attributes(entity)).get("temperature_unit")
757
847
  return hours, unit if isinstance(unit, str) else None
758
848
 
849
+ async def outdoor_history(
850
+ self, entity: str, start: datetime, end: datetime
851
+ ) -> list[Reading]:
852
+ """A weather entity's outdoor air from ``start`` to ``end``, °F,
853
+ each reading from the unit it was recorded in
854
+ (``parse_outdoor_history``), the entity's own unit where a row
855
+ names none. A reading in no unit ``to_f`` reads is left out."""
856
+ doc = await self._json(
857
+ "GET",
858
+ f"/api/history/period/{start.astimezone(UTC).isoformat()}",
859
+ params={
860
+ "filter_entity_id": entity,
861
+ "end_time": end.astimezone(UTC).isoformat(),
862
+ # Every row, with its attributes: the temperature is one.
863
+ "significant_changes_only": "0",
864
+ },
865
+ )
866
+ rows = parse_outdoor_history(doc, entity)
867
+ today: str | None = None
868
+ if any(u is None for _, _, u in rows):
869
+ u = (await self._attributes(entity)).get("temperature_unit")
870
+ today = u if isinstance(u, str) else None
871
+ out: list[Reading] = []
872
+ for t, v, u in rows:
873
+ try:
874
+ out.append((t, to_f(v, u or today)))
875
+ except ValueError:
876
+ continue
877
+ return out
878
+
759
879
  async def _attributes(self, entity: str) -> Mapping[str, Any]:
760
880
  doc = await self._json("GET", f"/api/states/{entity}")
761
881
  attributes = doc.get("attributes") if isinstance(doc, dict) else None
@@ -769,11 +889,61 @@ class HomeAssistantClient:
769
889
  # ── the environment source ──────────────────────────────────────────
770
890
 
771
891
 
892
+ def _while_running(
893
+ air: Sequence[Reading],
894
+ compressor: Sequence[tuple[datetime, str]],
895
+ end: datetime,
896
+ unknown: Sequence[Span] = (),
897
+ step: timedelta = timedelta(minutes=10),
898
+ ) -> tuple[list[Reading], Reading | None]:
899
+ """``air``'s value in force every ``step`` through each of
900
+ ``compressor``'s runs (its ``on`` states) to its end, or ``end`` for
901
+ one still running, where that value was read ``RUN_SETTLE`` or more
902
+ into the run: one read earlier, or while idle, is not the air the
903
+ heater stands in. None inside ``unknown``, when the sensor said
904
+ nothing that was read. The second item is the newest reading those
905
+ samples rest on, at its own instant."""
906
+ # A row that repeats the state (an attribute's update) is not a new
907
+ # run; a change to anything else, ``unavailable`` included, ends one.
908
+ changes: list[tuple[datetime, str]] = []
909
+ for at, state in compressor:
910
+ text = state.strip().lower()
911
+ if not changes or changes[-1][1] != text:
912
+ changes.append((at, text))
913
+ times = [t for t, _ in air]
914
+ out: list[Reading] = []
915
+ newest: Reading | None = None
916
+ for i, (on_at, state) in enumerate(changes):
917
+ if state != "on":
918
+ continue
919
+ stop = changes[i + 1][0] if i + 1 < len(changes) else end
920
+ settled = on_at + RUN_SETTLE
921
+ t = settled
922
+ while t < stop:
923
+ k = bisect_right(times, t)
924
+ if (
925
+ k
926
+ and times[k - 1] >= settled
927
+ and not any(a <= t < b for a, b in unknown)
928
+ ):
929
+ out.append((t, air[k - 1][1]))
930
+ if newest is None or air[k - 1][0] > newest[0]:
931
+ newest = air[k - 1]
932
+ t += step
933
+ return out, newest
934
+
935
+
772
936
  class HomeAssistantEnvironment:
773
937
  """An ``EnvironmentSource``: the air, its humidity and the inlet from
774
938
  Home Assistant, over ``start`` to ``until``, as of ``now`` (the
775
939
  driver's clock). ``notes`` say what each part rests on, or why it
776
- was left to the default, once ``read`` has run."""
940
+ was left to the default, once ``read`` has run.
941
+
942
+ With a weather entity and no ``location_air`` model, ``read`` fits
943
+ one (``fitted_air``) on the last ``LOCATION_AIR_WINDOW``: the air
944
+ entity's readings, or without one the heater's own air reading
945
+ while its compressor runs (``heater``'s ``ambient`` and
946
+ ``compressor``), against the weather entity's outdoor air."""
777
947
 
778
948
  def __init__(
779
949
  self,
@@ -784,6 +954,7 @@ class HomeAssistantEnvironment:
784
954
  until: datetime,
785
955
  now: datetime | None = None,
786
956
  location_air: LocationAirModel | None = None,
957
+ heater: HeaterEntities | None = None,
787
958
  ) -> None:
788
959
  self.client = client
789
960
  self.entities = entities
@@ -791,7 +962,13 @@ class HomeAssistantEnvironment:
791
962
  self.until = until
792
963
  self.now = start if now is None else now
793
964
  self.location_air = location_air
965
+ self.heater = heater
794
966
  self.notes: list[str] = []
967
+ #: The model fitted where none was given, once ``read`` has run;
968
+ #: None when it was given, or none could be fitted.
969
+ self.fitted_air: FittedLocationAir | None = None
970
+ self._fit_notes: list[str] | None = None
971
+ self._heater_anchor: Reading | None = None
795
972
 
796
973
  async def read(self) -> Environment:
797
974
  """The environment; a part that cannot be read is left to the
@@ -839,15 +1016,23 @@ class HomeAssistantEnvironment:
839
1016
  async def _air(self) -> float | tuple[Reading, ...]:
840
1017
  e = self.entities
841
1018
  weather = e.weather
842
- anchor: Reading | None = None
1019
+ held: Reading | None = None
843
1020
  if e.air is not None:
844
1021
  state = await self._fresh_state("air", e.air)
845
1022
  if state is not None and state.value is not None:
846
1023
  try:
847
- anchor = (state.at, to_f(state.value, state.unit))
1024
+ held = (state.at, to_f(state.value, state.unit))
848
1025
  except ValueError as err:
849
1026
  self.notes.append(f"air: {err}")
850
- if weather is not None and self.location_air is not None:
1027
+ model = self.location_air
1028
+ anchor = held
1029
+ if weather is not None and model is None:
1030
+ fitted = await self._fit_air(weather)
1031
+ if fitted is not None:
1032
+ model = fitted.model
1033
+ if anchor is None:
1034
+ anchor = self._heater_anchor
1035
+ if weather is not None and model is not None:
851
1036
  hours = await self._guard(
852
1037
  "outdoor forecast",
853
1038
  lambda: self.client.forecast(weather),
@@ -861,7 +1046,7 @@ class HomeAssistantEnvironment:
861
1046
  outdoor = []
862
1047
  self.notes.append(f"outdoor forecast: {err}")
863
1048
  air = project_air(
864
- self.location_air,
1049
+ model,
865
1050
  outdoor,
866
1051
  self.start,
867
1052
  self.until,
@@ -871,17 +1056,140 @@ class HomeAssistantEnvironment:
871
1056
  self.notes.append(
872
1057
  "air: projected from the outdoor forecast, "
873
1058
  + (
874
- "anchored to the reading beside the heater"
875
- if anchor
876
- else "unanchored"
1059
+ "unanchored"
1060
+ if anchor is None
1061
+ else "anchored to the reading beside the heater"
1062
+ if anchor is held
1063
+ else "anchored to the heater's own reading"
877
1064
  )
878
1065
  )
879
1066
  return air
880
- if anchor is not None:
1067
+ if held is not None:
881
1068
  self.notes.append("air: the reading beside the heater, held")
882
- return anchor[1]
1069
+ return held[1]
883
1070
  return math.nan
884
1071
 
1072
+ async def _fit_air(self, weather: str) -> FittedLocationAir | None:
1073
+ """The ``location_air`` model fitted on the last
1074
+ ``LOCATION_AIR_WINDOW``, or None (and a note): too little
1075
+ history, or a poor fit. Fitted once; a later ``read`` reuses it."""
1076
+ if self._fit_notes is None:
1077
+ before = len(self.notes)
1078
+ fitted = await self._guard(
1079
+ "air", lambda: self._fit_air_now(weather), None
1080
+ )
1081
+ self.fitted_air = cast("FittedLocationAir | None", fitted)
1082
+ self._fit_notes = self.notes[before:]
1083
+ else:
1084
+ self.notes.extend(self._fit_notes)
1085
+ return self.fitted_air
1086
+
1087
+ async def _fit_air_now(self, weather: str) -> FittedLocationAir | None:
1088
+ now = self.now.astimezone(UTC)
1089
+ since = now - LOCATION_AIR_WINDOW
1090
+ air = await self._air_history(since, now)
1091
+ if air is None:
1092
+ return None
1093
+ readings, what = air
1094
+ if not readings:
1095
+ self.notes.append(f"air: no readings of {what} to fit to")
1096
+ return None
1097
+ if not self._spans_a_day(readings, what, ""):
1098
+ return None
1099
+ lag = timedelta(hours=max(FIT_LAGS_H))
1100
+ # From before the first reading by the longest lag a fit tries.
1101
+ outdoor = await self.client.outdoor_history(weather, since - lag, now)
1102
+ if not outdoor:
1103
+ self.notes.append(f"air: no outdoor air recorded for {weather}")
1104
+ return None
1105
+ # Only readings the outdoor history covers at every lag tried:
1106
+ # past its ends, the model holds the outdoor air flat.
1107
+ first, last = outdoor[0][0] + lag, outdoor[-1][0]
1108
+ readings = [(t, v) for t, v in readings if first <= t <= last]
1109
+ if not readings or not self._spans_a_day(
1110
+ readings, what, " that the outdoor history covers"
1111
+ ):
1112
+ if not readings:
1113
+ self.notes.append(
1114
+ f"air: no readings of {what} that the outdoor history "
1115
+ "covers"
1116
+ )
1117
+ return None
1118
+ if len({v for _, v in readings}) < 2:
1119
+ self.notes.append(
1120
+ f"air: {what} held one value, so nothing to fit to"
1121
+ )
1122
+ return None
1123
+ fitted = fit_air(readings, outdoor)
1124
+ days = LOCATION_AIR_WINDOW.total_seconds() / 86400
1125
+ # A steady air has no r (NaN): no fit either.
1126
+ if not fitted.r >= LOCATION_AIR_MIN_R:
1127
+ self.notes.append(
1128
+ f"air: {what} follows the outdoor air too loosely to "
1129
+ f"project it (r {fitted.r:.2f}, under {LOCATION_AIR_MIN_R})"
1130
+ )
1131
+ return None
1132
+ self.notes.append(
1133
+ f"air: the outdoor air fitted to {what} over the last {days:g} "
1134
+ f"days (r {fitted.r:.2f}, "
1135
+ f"{fitted.model.lag.total_seconds() / 3600:g} h late)"
1136
+ )
1137
+ return fitted
1138
+
1139
+ def _spans_a_day(
1140
+ self, readings: Sequence[Reading], what: str, covered: str
1141
+ ) -> bool:
1142
+ """Whether ``readings`` span ``LOCATION_AIR_MIN_SPAN``; a note
1143
+ when they don't."""
1144
+ span = readings[-1][0] - readings[0][0]
1145
+ if span >= LOCATION_AIR_MIN_SPAN:
1146
+ return True
1147
+ self.notes.append(
1148
+ f"air: {span.total_seconds() / 3600:.0f} h of {what}{covered}, "
1149
+ f"under the {LOCATION_AIR_MIN_SPAN.total_seconds() / 3600:g} h "
1150
+ "a fit needs"
1151
+ )
1152
+ return False
1153
+
1154
+ async def _air_history(
1155
+ self, since: datetime, now: datetime
1156
+ ) -> tuple[list[Reading], str] | None:
1157
+ """The air where the heater stands since ``since``, °F, and what
1158
+ it was read from: the air entity, or the heater's own reading
1159
+ every 10 minutes while its compressor runs, from ``RUN_SETTLE``
1160
+ into each run. None (and a note) when neither is recorded."""
1161
+ e, h = self.entities, self.heater
1162
+ if e.air is not None:
1163
+ got = await self.client.read_history([e.air], since, now)
1164
+ unit = (await self.client.state(e.air)).unit
1165
+ readings, _ = got.converted(e.air, unit, to_f, now)
1166
+ return readings, "the air beside the heater"
1167
+ if h is None or h.ambient is None or h.compressor is None:
1168
+ self.notes.append(
1169
+ "air: no air reading to fit the outdoor air to (an air "
1170
+ "entity, or the heater's own air and compressor)"
1171
+ )
1172
+ return None
1173
+ got = await self.client.read_history(
1174
+ [h.ambient, h.compressor], since, now
1175
+ )
1176
+ if got.states is None:
1177
+ self.notes.append(
1178
+ f"air: {h.compressor} not read (no states in the history)"
1179
+ )
1180
+ return None
1181
+ unit = (await self.client.state(h.ambient)).unit
1182
+ ambient, unread = got.converted(h.ambient, unit, to_f, now)
1183
+ samples, newest = _while_running(
1184
+ ambient,
1185
+ got.states.get(h.compressor, []),
1186
+ now,
1187
+ busy(unread, got.outages.get(h.ambient, [])),
1188
+ )
1189
+ if newest is not None and now - newest[0] <= FRESH:
1190
+ self._heater_anchor = newest
1191
+ return samples, "the heater's own air reading"
1192
+
885
1193
  async def _humidity(self) -> float | None:
886
1194
  e = self.entities
887
1195
  if e.humidity is None:
@@ -942,7 +1250,7 @@ class HomeAssistantEnvironment:
942
1250
  )
943
1251
  # In °F, as read: no unit preference is read here.
944
1252
  cold = sa.cold_supply_f(
945
- probe,
1253
+ held(probe, since, now),
946
1254
  at=now,
947
1255
  draws=draws,
948
1256
  circulation=busy_spans,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nwp500-python
3
- Version: 9.10.0
3
+ Version: 9.11.0
4
4
  Summary: A library for controlling Navien NWP500 Water Heaters via NaviLink
5
5
  Home-page: https://github.com/eman/nwp500-python
6
6
  Author: Emmanuel Levijarvi
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "9.11.0",
3
+ "distance": 0,
4
+ "node": "gd8e04f71c9b36d7150efab7eeb2251178c8a8d56",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-23"
8
+ }