nwp500-python 7.4.8__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.8 → nwp500_python-7.4.9}/CHANGELOG.rst +84 -0
- {nwp500_python-7.4.8/src/nwp500_python.egg-info → nwp500_python-7.4.9}/PKG-INFO +6 -6
- nwp500_python-7.4.9/examples/advanced/firmware_payload_capture.py +211 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/setup.cfg +5 -5
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/auth.py +35 -6
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/cli/__main__.py +1 -1
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/cli/handlers.py +6 -5
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/events.py +2 -2
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/factory.py +14 -5
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/control.py +6 -3
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/subscriptions.py +30 -14
- {nwp500_python-7.4.8 → nwp500_python-7.4.9/src/nwp500_python.egg-info}/PKG-INFO +6 -6
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/requires.txt +5 -5
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_auth.py +15 -14
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_mqtt_client_init.py +10 -10
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/.coveragerc +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/.github/RESOLVING_PR_COMMENTS.md +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/.github/copilot-instructions.md +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/.github/workflows/ci.yml +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/.github/workflows/release.yml +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/.gitignore +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/.pre-commit-config.yaml +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/.readthedocs.yml +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/AUTHORS.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/CONTRIBUTING.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/LICENSE.txt +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/Makefile +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/README.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/RELEASE.md +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/Makefile +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/_static/.gitignore +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/api/nwp500.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/authors.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/changelog.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/conf.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/configuration.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/development/contributing.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/development/history.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/enumerations.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/advanced_features_explained.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/authentication.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/auto_recovery.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/command_queue.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/energy_monitoring.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/event_system.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/home_assistant_integration.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/mqtt_diagnostics.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/scheduling.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/time_of_use.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/guides/unit_conversion.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/index.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/installation.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/license.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/openapi.yaml +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/protocol/data_conversions.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/protocol/device_features.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/protocol/device_status.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/protocol/error_codes.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/protocol/quick_reference.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/protocol/rest_api.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/python_api/api_client.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/python_api/auth_client.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/python_api/cli.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/python_api/device_control.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/python_api/events.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/python_api/exceptions.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/python_api/models.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/python_api/mqtt_client.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/quickstart.rst +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/docs/requirements.txt +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/.ruff.toml +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/README.md +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/air_filter_reset.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/anti_legionella.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/auto_recovery.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/combined_callbacks.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/demand_response.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/device_capabilities.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/device_status_debug.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/energy_analytics.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/error_code_demo.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/mqtt_diagnostics.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/power_control.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/recirculation_control.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/reconnection_demo.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/reservation_schedule.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/simple_auto_recovery.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/token_restoration.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/tou_openei.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/tou_schedule.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/advanced/water_reservation.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/beginner/01_authentication.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/beginner/02_list_devices.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/beginner/03_get_status.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/beginner/04_set_temperature.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/advanced_auth_patterns.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/command_queue.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/device_status_callback.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/error_handling.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/event_driven_control.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/improved_auth.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/legacy_auth_constructor.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/periodic_requests.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/set_mode.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/intermediate/vacation_mode.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/mask.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/testing/periodic_device_info.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/testing/simple_periodic_info.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/testing/test_api_client.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/testing/test_mqtt_connection.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/testing/test_mqtt_messaging.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/examples/testing/test_periodic_minimal.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/pyproject.toml +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/scripts/README.md +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/scripts/bump_version.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/scripts/diagnose_mqtt_connection.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/scripts/extract_changelog.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/scripts/format.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/scripts/lint.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/scripts/setup-dev.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/scripts/validate_version.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/setup.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/__init__.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/api_client.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/cli/rich_output.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/command_decorators.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/config.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/converters.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/device_capabilities.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/device_info_cache.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/encoding.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/enums.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/exceptions.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/field_factory.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/models.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/__init__.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/client.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/command_queue.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/connection.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/diagnostics.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/periodic.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/reconnection.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt/utils.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/mqtt_events.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/openei.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/py.typed +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/reservations.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/temperature.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/topic_builder.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/unit_system.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500/utils.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/conftest.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_api_helpers.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_cli_basic.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_cli_commands.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_command_decorators.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_command_queue.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_device_capabilities.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_device_info_cache.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_events.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_exceptions.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_model_converters.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_models.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_mqtt_hypothesis.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_openei.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_reservations.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_temperature_converters.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_tou_api.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_unit_switching.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tests/test_utils.py +0 -0
- {nwp500_python-7.4.8 → nwp500_python-7.4.9}/tox.ini +0 -0
|
@@ -2,6 +2,90 @@
|
|
|
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
|
+
|
|
5
89
|
Version 7.4.8 (2026-02-17)
|
|
6
90
|
==========================
|
|
7
91
|
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -452,14 +452,15 @@ async def handle_set_anti_legionella_period_request(
|
|
|
452
452
|
# Get current enabled state
|
|
453
453
|
use = getattr(status, "anti_legionella_use", None)
|
|
454
454
|
|
|
455
|
-
# If enabled, keep it enabled; otherwise, enable it
|
|
456
|
-
# (period only, no disable-state for set operation)
|
|
457
455
|
if use:
|
|
458
456
|
await mqtt.control.enable_anti_legionella(device, period_days)
|
|
457
|
+
print(f"Anti-Legionella period set to {period_days} day(s)")
|
|
459
458
|
else:
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
459
|
+
print(
|
|
460
|
+
"Anti-Legionella is currently disabled. "
|
|
461
|
+
"Enable it first to set the period, or use "
|
|
462
|
+
"'anti-legionella enable' with the desired period."
|
|
463
|
+
)
|
|
463
464
|
except (RangeValidationError, ValidationError) as e:
|
|
464
465
|
_logger.error(f"Failed to set Anti-Legionella period: {e}")
|
|
465
466
|
except DeviceError as e:
|
|
@@ -252,8 +252,8 @@ class EventEmitter:
|
|
|
252
252
|
|
|
253
253
|
called_count += 1
|
|
254
254
|
|
|
255
|
-
# Check if this is a once listener
|
|
256
|
-
if
|
|
255
|
+
# Check if this is a once listener
|
|
256
|
+
if listener.once:
|
|
257
257
|
listeners_to_remove.append(listener)
|
|
258
258
|
self._once_callbacks.discard((event, listener.callback))
|
|
259
259
|
|
|
@@ -18,6 +18,8 @@ Example:
|
|
|
18
18
|
... devices = await api.list_devices()
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
|
+
import asyncio
|
|
22
|
+
|
|
21
23
|
from .api_client import NavienAPIClient
|
|
22
24
|
from .auth import NavienAuthClient
|
|
23
25
|
from .mqtt import NavienMqttClient
|
|
@@ -75,13 +77,20 @@ async def create_navien_clients(
|
|
|
75
77
|
# Authenticate and enter context manager
|
|
76
78
|
try:
|
|
77
79
|
await auth_client.__aenter__()
|
|
78
|
-
except
|
|
79
|
-
#
|
|
80
|
-
await auth_client.__aexit__(None, None, None)
|
|
80
|
+
except asyncio.CancelledError:
|
|
81
|
+
# Shield cleanup from further cancellation
|
|
82
|
+
await asyncio.shield(auth_client.__aexit__(None, None, None))
|
|
83
|
+
raise
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
await auth_client.__aexit__(type(exc), exc, exc.__traceback__)
|
|
81
86
|
raise
|
|
82
87
|
|
|
83
88
|
# Create API and MQTT clients that share the session
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
try:
|
|
90
|
+
api_client = NavienAPIClient(auth_client=auth_client)
|
|
91
|
+
mqtt_client = NavienMqttClient(auth_client=auth_client)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
await auth_client.__aexit__(type(exc), exc, exc.__traceback__)
|
|
94
|
+
raise
|
|
86
95
|
|
|
87
96
|
return auth_client, api_client, mqtt_client
|
|
@@ -648,10 +648,13 @@ class MqttDeviceController:
|
|
|
648
648
|
|
|
649
649
|
@requires_capability("holiday_use")
|
|
650
650
|
async def set_vacation_days(self, device: Device, days: int) -> int:
|
|
651
|
-
"""Set vacation/away mode duration (1-
|
|
652
|
-
self._validate_range("days", days, 1,
|
|
651
|
+
"""Set vacation/away mode duration (1-30 days)."""
|
|
652
|
+
self._validate_range("days", days, 1, 30)
|
|
653
653
|
return await self._mode_command(
|
|
654
|
-
device,
|
|
654
|
+
device,
|
|
655
|
+
CommandCode.DHW_MODE,
|
|
656
|
+
"dhw-mode",
|
|
657
|
+
[DhwOperationSetting.VACATION.value, days],
|
|
655
658
|
)
|
|
656
659
|
|
|
657
660
|
@requires_capability("program_reservation_use")
|
|
@@ -304,26 +304,42 @@ class MqttSubscriptionManager:
|
|
|
304
304
|
self._subscriptions.clear()
|
|
305
305
|
self._message_handlers.clear()
|
|
306
306
|
|
|
307
|
-
# Re-establish each subscription
|
|
307
|
+
# Re-establish each subscription — one network call per topic,
|
|
308
|
+
# regardless of how many handlers are registered for it.
|
|
308
309
|
failed_subscriptions: set[str] = set()
|
|
309
310
|
for topic, qos in subscriptions_to_restore:
|
|
310
311
|
handlers = handlers_to_restore.get(topic, [])
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
312
|
+
if not handlers:
|
|
313
|
+
continue
|
|
314
|
+
try:
|
|
315
|
+
# One network subscribe for the first handler
|
|
316
|
+
await self.subscribe(topic, handlers[0], qos)
|
|
317
|
+
except (AwsCrtError, RuntimeError) as e:
|
|
318
|
+
_logger.error(
|
|
319
|
+
f"Failed to re-subscribe to '{redact_topic(topic)}': {e}"
|
|
320
|
+
)
|
|
321
|
+
failed_subscriptions.add(topic)
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
# Register remaining handlers without extra network calls
|
|
325
|
+
for handler in handlers[1:]:
|
|
326
|
+
if handler not in self._message_handlers[topic]:
|
|
327
|
+
self._message_handlers[topic].append(handler)
|
|
323
328
|
|
|
324
329
|
if failed_subscriptions:
|
|
330
|
+
# Restore failed subscriptions to internal state so they can be
|
|
331
|
+
# retried on the next reconnection cycle.
|
|
332
|
+
qos_map = dict(subscriptions_to_restore)
|
|
333
|
+
for topic in failed_subscriptions:
|
|
334
|
+
self._subscriptions[topic] = qos_map.get(
|
|
335
|
+
topic, mqtt.QoS.AT_LEAST_ONCE
|
|
336
|
+
)
|
|
337
|
+
self._message_handlers[topic] = handlers_to_restore.get(
|
|
338
|
+
topic, []
|
|
339
|
+
)
|
|
325
340
|
_logger.warning(
|
|
326
|
-
f"Failed to restore {len(failed_subscriptions)}
|
|
341
|
+
f"Failed to restore {len(failed_subscriptions)} "
|
|
342
|
+
"subscription(s); will retry on next reconnection"
|
|
327
343
|
)
|
|
328
344
|
else:
|
|
329
345
|
_logger.info("All subscriptions re-established successfully")
|
|
@@ -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"
|
|
@@ -71,6 +71,7 @@ examples/advanced/device_capabilities.py
|
|
|
71
71
|
examples/advanced/device_status_debug.py
|
|
72
72
|
examples/advanced/energy_analytics.py
|
|
73
73
|
examples/advanced/error_code_demo.py
|
|
74
|
+
examples/advanced/firmware_payload_capture.py
|
|
74
75
|
examples/advanced/mqtt_diagnostics.py
|
|
75
76
|
examples/advanced/power_control.py
|
|
76
77
|
examples/advanced/recirculation_control.py
|