nwp500-python 8.1.2__tar.gz → 8.1.3__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-8.1.2 → nwp500_python-8.1.3}/CHANGELOG.rst +18 -1
- {nwp500_python-8.1.2/src/nwp500_python.egg-info → nwp500_python-8.1.3}/PKG-INFO +1 -3
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/README.rst +0 -2
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/reconnection.py +37 -4
- {nwp500_python-8.1.2 → nwp500_python-8.1.3/src/nwp500_python.egg-info}/PKG-INFO +1 -3
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_mqtt_reconnection_storm.py +155 -9
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/.coveragerc +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/.github/RESOLVING_PR_COMMENTS.md +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/.github/copilot-instructions.md +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/.github/workflows/ci.yml +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/.github/workflows/release.yml +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/.gitignore +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/.pre-commit-config.yaml +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/.readthedocs.yml +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/AUTHORS.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/CONTRIBUTING.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/LICENSE.txt +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/Makefile +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/RELEASE.md +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/Makefile +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/_static/.gitignore +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/conf.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/explanation/advanced-features.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/explanation/architecture.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/explanation/index.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/authenticate.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/auto-recovery.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/diagnose-mqtt.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/home-assistant.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/index.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/maintenance.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/manage-units.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/monitor-status.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/optimize-tou.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/queue-commands.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/schedule-operation.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/how-to/track-energy.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/index.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/openapi.yaml +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/project/authors.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/project/changelog.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/project/contributing.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/project/history.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/project/license.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/configuration.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/enumerations.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/index.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/installation.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/protocol/data_conversions.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/protocol/device_features.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/protocol/device_status.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/protocol/error_codes.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/protocol/quick_reference.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/protocol/rest_api.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/python_api/api_client.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/python_api/auth_client.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/python_api/cli.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/python_api/events.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/python_api/exceptions.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/python_api/models.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/reference/python_api/mqtt_client.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/requirements.txt +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/docs/tutorials/getting-started.rst +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/.ruff.toml +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/README.md +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/air_filter_reset.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/anti_legionella.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/auto_recovery.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/combined_callbacks.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/demand_response.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/device_capabilities.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/device_status_debug.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/energy_analytics.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/error_code_demo.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/firmware_payload_capture.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/mqtt_diagnostics.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/power_control.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/recirculation_control.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/reconnection_demo.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/reservation_schedule.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/simple_auto_recovery.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/token_restoration.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/tou_openei.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/tou_schedule.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/advanced/water_reservation.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/beginner/01_authentication.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/beginner/02_list_devices.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/beginner/03_get_status.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/beginner/04_set_temperature.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/advanced_auth_patterns.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/command_queue.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/device_status_callback.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/error_handling.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/event_driven_control.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/improved_auth.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/legacy_auth_constructor.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/periodic_requests.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/set_mode.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/vacation_mode.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/mask.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/testing/periodic_device_info.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/testing/simple_periodic_info.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/testing/test_api_client.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/testing/test_mqtt_connection.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/testing/test_mqtt_messaging.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/testing/test_periodic_minimal.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/pyproject.toml +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/scripts/README.md +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/scripts/bump_version.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/scripts/diagnose_mqtt_connection.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/scripts/extract_changelog.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/scripts/format.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/scripts/lint.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/scripts/setup-dev.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/scripts/validate_version.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/setup.cfg +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/setup.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/__init__.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/_base.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/api_client.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/auth.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/cli/handlers.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/cli/rich_output.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/command_decorators.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/config.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/converters.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/device_capabilities.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/device_info_cache.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/encoding.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/enums.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/events.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/exceptions.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/factory.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/field_factory.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/__init__.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/_converters.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/device.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/energy.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/feature.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/mqtt_models.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/schedule.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/status.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/models/tou.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/__init__.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/client.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/command_queue.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/connection.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/control.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/diagnostics.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/periodic.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/state_tracker.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/subscriptions.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt/utils.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/mqtt_events.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/openei.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/py.typed +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/reservations.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/temperature.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/topic_builder.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/unit_system.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500/utils.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/SOURCES.txt +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/conftest.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_api_helpers.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_auth.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_bug_fixes.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_cli_basic.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_cli_commands.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_command_decorators.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_command_queue.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_device_capabilities.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_device_info_cache.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_events.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_exceptions.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_model_converters.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_models.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_mqtt_clean_session_resume.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_mqtt_client_init.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_mqtt_hypothesis.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_mqtt_reconnection.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_multi_device.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_openei.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_reservations.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_temperature_converters.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_tou_api.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_unit_switching.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tests/test_utils.py +0 -0
- {nwp500_python-8.1.2 → nwp500_python-8.1.3}/tox.ini +0 -0
|
@@ -5,9 +5,26 @@ Changelog
|
|
|
5
5
|
Unreleased
|
|
6
6
|
==========
|
|
7
7
|
|
|
8
|
-
Version 8.1.
|
|
8
|
+
Version 8.1.3 (2026-06-15)
|
|
9
9
|
==========================
|
|
10
10
|
|
|
11
|
+
Bug Fixes
|
|
12
|
+
---------
|
|
13
|
+
- **Fix MQTT reconnection storm caused by non-thread-safe Task.cancel()**: The
|
|
14
|
+
``on_connection_resumed`` callback is invoked from an AWS IoT SDK background
|
|
15
|
+
thread. It was calling ``asyncio.Task.cancel()`` directly on that thread, which
|
|
16
|
+
is not thread-safe. When the event loop was busy at the moment of cancellation
|
|
17
|
+
(e.g. the sleeping task's timer callback had already been enqueued), the
|
|
18
|
+
cancellation was silently dropped. The stale ``_reconnect_with_backoff`` task
|
|
19
|
+
would then complete its sleep, call ``_reconnect_func``, and tear down an
|
|
20
|
+
otherwise healthy connection — restarting the entire disconnect → reconnect →
|
|
21
|
+
``AWS_ERROR_MQTT_UNEXPECTED_HANGUP`` cycle. Fixed by replacing the direct
|
|
22
|
+
``task.cancel()`` call with a ``_cancel_pending_reconnect()`` coroutine scheduled
|
|
23
|
+
via ``_schedule_coroutine``, so the cancellation runs on the event loop where
|
|
24
|
+
asyncio operations are safe. The method also uses an identity check before
|
|
25
|
+
clearing ``_reconnect_task`` to avoid wiping a newer task created during the
|
|
26
|
+
await, and clears stale references to already-done tasks.
|
|
27
|
+
|
|
11
28
|
Version 8.1.0 (2026-05-16)
|
|
12
29
|
==========================
|
|
13
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nwp500-python
|
|
3
|
-
Version: 8.1.
|
|
3
|
+
Version: 8.1.3
|
|
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
|
|
@@ -89,8 +89,6 @@ Quick Example
|
|
|
89
89
|
Documentation
|
|
90
90
|
=============
|
|
91
91
|
|
|
92
|
-
The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
|
|
93
|
-
|
|
94
92
|
* `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
|
|
95
93
|
* `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
|
|
96
94
|
* `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
|
|
@@ -47,8 +47,6 @@ Quick Example
|
|
|
47
47
|
Documentation
|
|
48
48
|
=============
|
|
49
49
|
|
|
50
|
-
The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
|
|
51
|
-
|
|
52
50
|
* `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
|
|
53
51
|
* `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
|
|
54
52
|
* `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
|
|
@@ -128,9 +128,42 @@ class MqttReconnectionHandler:
|
|
|
128
128
|
# Reset reconnection attempts on successful connection
|
|
129
129
|
self._reconnect_attempts = 0
|
|
130
130
|
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
# Schedule cancellation of any pending reconnect task on the event loop.
|
|
132
|
+
# This method is called from an AWS SDK background thread; asyncio's
|
|
133
|
+
# Task.cancel() is NOT thread-safe when invoked directly from a
|
|
134
|
+
# non-event-loop thread. If the event loop is busy (e.g. the sleeping
|
|
135
|
+
# task's timer callback was already queued) the cancellation can be
|
|
136
|
+
# silently dropped, leaving the stale _reconnect_with_backoff loop
|
|
137
|
+
# alive. That loop then completes its sleep and calls _reconnect_func,
|
|
138
|
+
# tearing down a perfectly healthy connection and restarting the
|
|
139
|
+
# disconnect/reconnect cycle.
|
|
140
|
+
self._schedule_coroutine(self._cancel_pending_reconnect())
|
|
141
|
+
|
|
142
|
+
async def _cancel_pending_reconnect(self) -> None:
|
|
143
|
+
"""Cancel any pending reconnect task.
|
|
144
|
+
|
|
145
|
+
Must be called on the event loop (via _schedule_coroutine) so that
|
|
146
|
+
asyncio Task operations are thread-safe.
|
|
147
|
+
|
|
148
|
+
Uses an identity check before clearing _reconnect_task to avoid
|
|
149
|
+
accidentally wiping a new task that was created while the cancelled
|
|
150
|
+
task was being awaited. Also clears stale references to already-done
|
|
151
|
+
tasks so the handler never holds on to finished task objects.
|
|
152
|
+
"""
|
|
153
|
+
task = self._reconnect_task
|
|
154
|
+
if task is None:
|
|
155
|
+
return
|
|
156
|
+
if task.done():
|
|
157
|
+
# Clear stale reference to an already-finished task.
|
|
158
|
+
if self._reconnect_task is task:
|
|
159
|
+
self._reconnect_task = None
|
|
160
|
+
return
|
|
161
|
+
task.cancel()
|
|
162
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
163
|
+
await task
|
|
164
|
+
# Only clear the reference if it still points to the same task.
|
|
165
|
+
# A new reconnect task may have been created while we were awaiting.
|
|
166
|
+
if self._reconnect_task is task:
|
|
134
167
|
self._reconnect_task = None
|
|
135
168
|
|
|
136
169
|
async def _start_reconnect_task(self) -> None:
|
|
@@ -142,7 +175,7 @@ class MqttReconnectionHandler:
|
|
|
142
175
|
|
|
143
176
|
The is_connected guard is re-checked here because this coroutine may
|
|
144
177
|
be queued via run_coroutine_threadsafe and run after the connection
|
|
145
|
-
has already been restored (e.g. by
|
|
178
|
+
has already been restored (e.g. by _cancel_pending_reconnect clearing
|
|
146
179
|
_reconnect_task), in which case starting a new backoff loop would
|
|
147
180
|
incorrectly tear down a healthy connection.
|
|
148
181
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nwp500-python
|
|
3
|
-
Version: 8.1.
|
|
3
|
+
Version: 8.1.3
|
|
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
|
|
@@ -89,8 +89,6 @@ Quick Example
|
|
|
89
89
|
Documentation
|
|
90
90
|
=============
|
|
91
91
|
|
|
92
|
-
The documentation follows the `Diátaxis <https://diataxis.fr/>`_ framework:
|
|
93
|
-
|
|
94
92
|
* `Tutorials <https://nwp500-python.readthedocs.io/en/latest/tutorials/getting-started.html>`_: Start here if you're new to the library.
|
|
95
93
|
* `How-to Guides <https://nwp500-python.readthedocs.io/en/latest/how-to/index.html>`_: Practical step-by-step recipes for specific tasks.
|
|
96
94
|
* `Reference <https://nwp500-python.readthedocs.io/en/latest/reference/index.html>`_: Technical descriptions of the API, models, and protocol.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Tests for the MQTT reconnection storm fix.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Three bugs were fixed:
|
|
5
5
|
|
|
6
6
|
Bug 1 — Stale interruption events fire after a resume clears _reconnect_task.
|
|
7
7
|
The AWS SDK fires on_connection_interrupted callbacks from background threads
|
|
@@ -21,6 +21,18 @@ Bug 2 — Closing the old connection inside _active_reconnect / _deep_reconnect
|
|
|
21
21
|
Fix: _actively_reconnecting flag suppresses the reconnection-handler
|
|
22
22
|
delegation in _on_connection_interrupted_internal while the intentional
|
|
23
23
|
teardown is in progress.
|
|
24
|
+
|
|
25
|
+
Bug 3 — on_connection_resumed calls Task.cancel() directly from an AWS SDK
|
|
26
|
+
background thread. asyncio.Task.cancel() is NOT thread-safe; when the event
|
|
27
|
+
loop is busy (e.g. the sleeping task's timer callback was already enqueued)
|
|
28
|
+
the cancellation can be silently dropped. The stale _reconnect_with_backoff
|
|
29
|
+
task then completes its sleep, calls _reconnect_func, and tears down an
|
|
30
|
+
otherwise healthy connection, restarting the entire
|
|
31
|
+
disconnect/reconnect cycle.
|
|
32
|
+
|
|
33
|
+
Fix: on_connection_resumed schedules _cancel_pending_reconnect() via
|
|
34
|
+
_schedule_coroutine so the cancellation runs on the event loop where asyncio
|
|
35
|
+
Task operations are safe.
|
|
24
36
|
"""
|
|
25
37
|
|
|
26
38
|
from __future__ import annotations
|
|
@@ -177,10 +189,11 @@ class TestReconnectionHandlerIsConnectedGuard:
|
|
|
177
189
|
|
|
178
190
|
1. Connection drops → on_connection_interrupted schedules
|
|
179
191
|
_start_reconnect_task (coroutine A queued but not yet run).
|
|
180
|
-
2. Connection resumes → on_connection_resumed
|
|
181
|
-
|
|
182
|
-
3.
|
|
183
|
-
_reconnect_task=None and create a new backoff
|
|
192
|
+
2. Connection resumes → on_connection_resumed schedules
|
|
193
|
+
_cancel_pending_reconnect (no task to cancel yet since A hasn't run).
|
|
194
|
+
3. Both queued coroutines finally run – without the is_connected guard
|
|
195
|
+
coroutine A would see _reconnect_task=None and create a new backoff
|
|
196
|
+
loop even though the client is now healthy.
|
|
184
197
|
"""
|
|
185
198
|
state = {"connected": False}
|
|
186
199
|
config = MqttConnectionConfig(auto_reconnect=True)
|
|
@@ -200,13 +213,16 @@ class TestReconnectionHandlerIsConnectedGuard:
|
|
|
200
213
|
handler.on_connection_interrupted(Exception("dropped"))
|
|
201
214
|
assert len(scheduled) == 1
|
|
202
215
|
|
|
203
|
-
# Step 2: connection resumes before the scheduled coroutine runs
|
|
216
|
+
# Step 2: connection resumes before the scheduled coroutine runs.
|
|
217
|
+
# on_connection_resumed now schedules _cancel_pending_reconnect rather
|
|
218
|
+
# than calling task.cancel() directly (Bug 3 fix).
|
|
204
219
|
state["connected"] = True
|
|
205
220
|
handler.on_connection_resumed(return_code=0, session_present=False)
|
|
206
|
-
assert handler._reconnect_task is None
|
|
221
|
+
assert handler._reconnect_task is None # no task was ever created
|
|
207
222
|
|
|
208
|
-
# Step 3:
|
|
209
|
-
|
|
223
|
+
# Step 3: run all scheduled coroutines
|
|
224
|
+
# (_start_reconnect_task + _cancel_pending_reconnect)
|
|
225
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
210
226
|
|
|
211
227
|
# With the fix, no new task must have been created
|
|
212
228
|
assert handler._reconnect_task is None
|
|
@@ -232,6 +248,136 @@ class TestReconnectionHandlerIsConnectedGuard:
|
|
|
232
248
|
await asyncio.gather(handler._reconnect_task, return_exceptions=True)
|
|
233
249
|
|
|
234
250
|
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Bug 3: on_connection_resumed must cancel via the event loop, not directly
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class TestThreadSafeTaskCancellation:
|
|
257
|
+
"""Bug 3 – on_connection_resumed must not call Task.cancel() from a thread.
|
|
258
|
+
|
|
259
|
+
asyncio.Task.cancel() is NOT thread-safe. When called from an AWS SDK
|
|
260
|
+
background thread, the cancellation can be silently dropped if the event
|
|
261
|
+
loop is busy (e.g. the sleep timer fires at the same moment). The stale
|
|
262
|
+
task then triggers a spurious reconnection.
|
|
263
|
+
|
|
264
|
+
Fix: on_connection_resumed schedules _cancel_pending_reconnect() via
|
|
265
|
+
_schedule_coroutine so the cancellation happens on the event loop.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
269
|
+
async def test_on_connection_resumed_schedules_cancel_not_direct_call(
|
|
270
|
+
self,
|
|
271
|
+
):
|
|
272
|
+
"""
|
|
273
|
+
on_connection_resumed must schedule _cancel_pending_reconnect via
|
|
274
|
+
_schedule_coroutine rather than calling task.cancel() directly.
|
|
275
|
+
"""
|
|
276
|
+
handler, scheduled = _make_handler(connected=False)
|
|
277
|
+
|
|
278
|
+
# Let a reconnect task start (sleeping in backoff)
|
|
279
|
+
handler.on_connection_interrupted(Exception("dropped"))
|
|
280
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
281
|
+
scheduled.clear()
|
|
282
|
+
|
|
283
|
+
assert handler._reconnect_task is not None
|
|
284
|
+
assert not handler._reconnect_task.done()
|
|
285
|
+
|
|
286
|
+
# Simulate connection resuming from a background thread
|
|
287
|
+
handler.on_connection_resumed(return_code=0, session_present=True)
|
|
288
|
+
|
|
289
|
+
# _cancel_pending_reconnect must have been *scheduled*, not run yet
|
|
290
|
+
assert len(scheduled) == 1
|
|
291
|
+
# No direct Task.cancel() was called from the background thread:
|
|
292
|
+
# task.cancelling() == 0 proves no cancellation request is pending
|
|
293
|
+
# before the event loop runs _cancel_pending_reconnect.
|
|
294
|
+
assert handler._reconnect_task is not None
|
|
295
|
+
assert handler._reconnect_task.cancelling() == 0
|
|
296
|
+
|
|
297
|
+
# Now let the event loop process the cancellation
|
|
298
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
299
|
+
|
|
300
|
+
# After the event-loop cancellation, the task must be cleared
|
|
301
|
+
assert handler._reconnect_task is None
|
|
302
|
+
|
|
303
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
304
|
+
async def test_cancel_pending_reconnect_is_idempotent_with_no_task(self):
|
|
305
|
+
"""_cancel_pending_reconnect is a no-op when _reconnect_task is None."""
|
|
306
|
+
handler, _ = _make_handler(connected=True)
|
|
307
|
+
assert handler._reconnect_task is None
|
|
308
|
+
|
|
309
|
+
# Should not raise
|
|
310
|
+
await handler._cancel_pending_reconnect()
|
|
311
|
+
|
|
312
|
+
assert handler._reconnect_task is None
|
|
313
|
+
|
|
314
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
315
|
+
async def test_cancel_pending_reconnect_clears_completed_task(self):
|
|
316
|
+
"""_cancel_pending_reconnect clears a stale reference to a done task."""
|
|
317
|
+
handler, scheduled = _make_handler(connected=False)
|
|
318
|
+
|
|
319
|
+
handler.on_connection_interrupted(Exception("dropped"))
|
|
320
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
321
|
+
scheduled.clear()
|
|
322
|
+
|
|
323
|
+
task = handler._reconnect_task
|
|
324
|
+
assert task is not None
|
|
325
|
+
|
|
326
|
+
# Cancel and drain the task manually, leaving a stale reference
|
|
327
|
+
task.cancel()
|
|
328
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
329
|
+
assert task.done()
|
|
330
|
+
assert handler._reconnect_task is task # stale reference still held
|
|
331
|
+
|
|
332
|
+
# _cancel_pending_reconnect must clear the stale reference
|
|
333
|
+
await handler._cancel_pending_reconnect()
|
|
334
|
+
assert handler._reconnect_task is None
|
|
335
|
+
|
|
336
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
337
|
+
async def test_resumed_then_interrupted_creates_new_task(self):
|
|
338
|
+
"""
|
|
339
|
+
After resume cancels an existing task, a subsequent genuine drop must
|
|
340
|
+
still be able to start a fresh reconnect task.
|
|
341
|
+
"""
|
|
342
|
+
state = {"connected": False}
|
|
343
|
+
config = MqttConnectionConfig(auto_reconnect=True)
|
|
344
|
+
scheduled = []
|
|
345
|
+
|
|
346
|
+
handler = MqttReconnectionHandler(
|
|
347
|
+
config=config,
|
|
348
|
+
is_connected_func=lambda: state["connected"],
|
|
349
|
+
schedule_coroutine_func=lambda coro: scheduled.append(
|
|
350
|
+
asyncio.ensure_future(coro)
|
|
351
|
+
),
|
|
352
|
+
reconnect_func=AsyncMock(),
|
|
353
|
+
)
|
|
354
|
+
handler.enable()
|
|
355
|
+
|
|
356
|
+
# Connection drops → backoff task starts
|
|
357
|
+
handler.on_connection_interrupted(Exception("first drop"))
|
|
358
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
359
|
+
scheduled.clear()
|
|
360
|
+
assert handler._reconnect_task is not None
|
|
361
|
+
|
|
362
|
+
# Connection resumes → cancel scheduled on loop
|
|
363
|
+
state["connected"] = True
|
|
364
|
+
handler.on_connection_resumed(return_code=0, session_present=True)
|
|
365
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
366
|
+
scheduled.clear()
|
|
367
|
+
assert handler._reconnect_task is None
|
|
368
|
+
|
|
369
|
+
# Connection drops again (genuine)
|
|
370
|
+
state["connected"] = False
|
|
371
|
+
handler.on_connection_interrupted(Exception("second drop"))
|
|
372
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
373
|
+
scheduled.clear()
|
|
374
|
+
|
|
375
|
+
# A new reconnect task must have been created
|
|
376
|
+
assert handler._reconnect_task is not None
|
|
377
|
+
handler._reconnect_task.cancel()
|
|
378
|
+
await asyncio.gather(handler._reconnect_task, return_exceptions=True)
|
|
379
|
+
|
|
380
|
+
|
|
235
381
|
# ---------------------------------------------------------------------------
|
|
236
382
|
# Bug 2: _actively_reconnecting suppresses spurious interrupt callbacks
|
|
237
383
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/legacy_auth_constructor.py
RENAMED
|
File without changes
|
{nwp500_python-8.1.2 → nwp500_python-8.1.3}/examples/intermediate/mqtt_realtime_monitoring.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|