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