nwp500-python 8.1.0__tar.gz → 8.1.1__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.0 → nwp500_python-8.1.1}/CHANGELOG.rst +3 -0
- {nwp500_python-8.1.0/src/nwp500_python.egg-info → nwp500_python-8.1.1}/PKG-INFO +1 -1
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/client.py +37 -3
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/reconnection.py +17 -2
- {nwp500_python-8.1.0 → nwp500_python-8.1.1/src/nwp500_python.egg-info}/PKG-INFO +1 -1
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/SOURCES.txt +1 -0
- nwp500_python-8.1.1/tests/test_mqtt_reconnection_storm.py +447 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.coveragerc +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.github/RESOLVING_PR_COMMENTS.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.github/copilot-instructions.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.github/workflows/ci.yml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.github/workflows/release.yml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.gitignore +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.pre-commit-config.yaml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/.readthedocs.yml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/AUTHORS.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/CONTRIBUTING.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/LICENSE.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/Makefile +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/README.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/RELEASE.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/Makefile +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/_static/.gitignore +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/conf.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/explanation/advanced-features.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/explanation/architecture.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/explanation/index.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/authenticate.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/auto-recovery.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/diagnose-mqtt.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/home-assistant.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/index.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/maintenance.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/manage-units.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/monitor-status.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/optimize-tou.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/queue-commands.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/schedule-operation.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/how-to/track-energy.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/index.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/openapi.yaml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/authors.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/changelog.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/contributing.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/history.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/project/license.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/configuration.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/enumerations.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/index.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/installation.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/data_conversions.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/device_features.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/device_status.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/error_codes.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/mqtt_protocol.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/quick_reference.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/protocol/rest_api.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/api_client.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/auth_client.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/cli.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/events.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/exceptions.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/models.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/reference/python_api/mqtt_client.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/requirements.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/docs/tutorials/getting-started.rst +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/.ruff.toml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/README.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/air_filter_reset.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/anti_legionella.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/auto_recovery.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/combined_callbacks.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/demand_response.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/device_capabilities.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/device_status_debug.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/energy_analytics.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/error_code_demo.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/firmware_payload_capture.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/mqtt_diagnostics.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/power_control.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/recirculation_control.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/reconnection_demo.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/reservation_schedule.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/simple_auto_recovery.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/token_restoration.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/tou_openei.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/tou_schedule.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/advanced/water_reservation.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/beginner/01_authentication.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/beginner/02_list_devices.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/beginner/03_get_status.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/beginner/04_set_temperature.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/advanced_auth_patterns.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/command_queue.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/device_status_callback.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/error_handling.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/event_driven_control.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/improved_auth.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/legacy_auth_constructor.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/mqtt_realtime_monitoring.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/periodic_requests.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/set_mode.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/intermediate/vacation_mode.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/mask.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/periodic_device_info.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/simple_periodic_info.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/test_api_client.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/test_mqtt_connection.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/test_mqtt_messaging.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/examples/testing/test_periodic_minimal.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/pyproject.toml +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/README.md +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/bump_version.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/diagnose_mqtt_connection.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/extract_changelog.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/format.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/lint.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/setup-dev.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/scripts/validate_version.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/setup.cfg +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/setup.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/__init__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/_base.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/api_client.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/auth.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/__init__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/__main__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/commands.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/handlers.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/monitoring.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/output_formatters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/rich_output.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/cli/token_storage.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/command_decorators.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/config.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/converters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/device_capabilities.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/device_info_cache.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/encoding.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/enums.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/events.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/exceptions.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/factory.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/field_factory.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/__init__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/_converters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/device.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/energy.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/feature.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/mqtt_models.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/schedule.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/status.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/models/tou.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/__init__.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/command_queue.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/connection.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/control.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/diagnostics.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/periodic.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/state_tracker.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/subscriptions.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt/utils.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/mqtt_events.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/openei.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/py.typed +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/reservations.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/temperature.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/topic_builder.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/unit_system.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500/utils.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/dependency_links.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/entry_points.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/not-zip-safe +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/requires.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/src/nwp500_python.egg-info/top_level.txt +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/conftest.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_api_helpers.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_auth.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_bug_fixes.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_cli_basic.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_cli_commands.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_command_decorators.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_command_queue.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_device_capabilities.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_device_info_cache.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_events.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_exceptions.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_model_converters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_models.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_mqtt_client_init.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_mqtt_hypothesis.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_mqtt_reconnection.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_multi_device.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_openei.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_reservations.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_temperature_converters.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_tou_api.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_unit_switching.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tests/test_utils.py +0 -0
- {nwp500_python-8.1.0 → nwp500_python-8.1.1}/tox.ini +0 -0
|
@@ -226,6 +226,11 @@ class NavienMqttClient(EventEmitter):
|
|
|
226
226
|
# Connection state (simpler than checking _connection_manager)
|
|
227
227
|
self._connection: mqtt.Connection | None = None
|
|
228
228
|
self._connected = False
|
|
229
|
+
# Guards _active_reconnect / _deep_reconnect against re-entrancy.
|
|
230
|
+
# While True, _on_connection_interrupted_internal will not forward
|
|
231
|
+
# events to the reconnection handler, preventing the intentional
|
|
232
|
+
# teardown of the old connection from spawning a competing backoff loop.
|
|
233
|
+
self._actively_reconnecting = False
|
|
229
234
|
|
|
230
235
|
_logger.info(
|
|
231
236
|
f"Initialized MQTT client with ID: {self.config.client_id}"
|
|
@@ -276,8 +281,17 @@ class NavienMqttClient(EventEmitter):
|
|
|
276
281
|
)
|
|
277
282
|
)
|
|
278
283
|
|
|
279
|
-
# Delegate to reconnection handler if available
|
|
280
|
-
|
|
284
|
+
# Delegate to reconnection handler if available.
|
|
285
|
+
# Skip while _actively_reconnecting: the interruption was caused by
|
|
286
|
+
# _active_reconnect / _deep_reconnect intentionally closing the old
|
|
287
|
+
# connection. Forwarding it would queue a _start_reconnect_task
|
|
288
|
+
# coroutine that could fire after the new connection is up and the
|
|
289
|
+
# existing backoff task has been cancelled, spawning a competing loop.
|
|
290
|
+
if (
|
|
291
|
+
self._reconnection_handler
|
|
292
|
+
and self.config.auto_reconnect
|
|
293
|
+
and not self._actively_reconnecting
|
|
294
|
+
):
|
|
281
295
|
self._reconnection_handler.on_connection_interrupted(error)
|
|
282
296
|
|
|
283
297
|
# Record diagnostic event
|
|
@@ -380,8 +394,13 @@ class NavienMqttClient(EventEmitter):
|
|
|
380
394
|
_logger.debug("Already connected, skipping reconnection")
|
|
381
395
|
return
|
|
382
396
|
|
|
397
|
+
if self._actively_reconnecting:
|
|
398
|
+
_logger.debug("Active reconnection already in progress, skipping")
|
|
399
|
+
return
|
|
400
|
+
|
|
383
401
|
_logger.info("Attempting active reconnection...")
|
|
384
402
|
|
|
403
|
+
self._actively_reconnecting = True
|
|
385
404
|
try:
|
|
386
405
|
# Ensure tokens are still valid
|
|
387
406
|
await self._auth_client.ensure_valid_token()
|
|
@@ -390,6 +409,9 @@ class NavienMqttClient(EventEmitter):
|
|
|
390
409
|
if self._connection_manager:
|
|
391
410
|
# Close old connection to stop SDK auto-reconnect and
|
|
392
411
|
# prevent two connections with the same client ID.
|
|
412
|
+
# _actively_reconnecting suppresses the
|
|
413
|
+
# on_connection_interrupted callback that closing triggers,
|
|
414
|
+
# preventing a competing backoff loop from being spawned.
|
|
393
415
|
_logger.debug("Recreating MQTT connection...")
|
|
394
416
|
try:
|
|
395
417
|
await self._connection_manager.close()
|
|
@@ -432,6 +454,8 @@ class NavienMqttClient(EventEmitter):
|
|
|
432
454
|
f"Error during active reconnection: {e}", exc_info=True
|
|
433
455
|
)
|
|
434
456
|
raise
|
|
457
|
+
finally:
|
|
458
|
+
self._actively_reconnecting = False
|
|
435
459
|
|
|
436
460
|
async def _deep_reconnect(self) -> None:
|
|
437
461
|
"""
|
|
@@ -451,13 +475,21 @@ class NavienMqttClient(EventEmitter):
|
|
|
451
475
|
_logger.debug("Already connected, skipping deep reconnection")
|
|
452
476
|
return
|
|
453
477
|
|
|
478
|
+
if self._actively_reconnecting:
|
|
479
|
+
_logger.debug("Active reconnection already in progress, skipping")
|
|
480
|
+
return
|
|
481
|
+
|
|
454
482
|
_logger.warning(
|
|
455
483
|
"Performing deep reconnection (full rebuild)... "
|
|
456
484
|
"This may take longer."
|
|
457
485
|
)
|
|
458
486
|
|
|
487
|
+
self._actively_reconnecting = True
|
|
459
488
|
try:
|
|
460
|
-
# Step 1: Clean up existing connection if any
|
|
489
|
+
# Step 1: Clean up existing connection if any.
|
|
490
|
+
# _actively_reconnecting suppresses the on_connection_interrupted
|
|
491
|
+
# callback that closing triggers, preventing a competing backoff
|
|
492
|
+
# loop from being spawned.
|
|
461
493
|
if self._connection_manager:
|
|
462
494
|
_logger.debug("Cleaning up old connection...")
|
|
463
495
|
try:
|
|
@@ -534,6 +566,8 @@ class NavienMqttClient(EventEmitter):
|
|
|
534
566
|
) as e:
|
|
535
567
|
_logger.error(f"Error during deep reconnection: {e}", exc_info=True)
|
|
536
568
|
raise
|
|
569
|
+
finally:
|
|
570
|
+
self._actively_reconnecting = False
|
|
537
571
|
|
|
538
572
|
async def connect(self) -> bool:
|
|
539
573
|
"""
|
|
@@ -94,11 +94,18 @@ class MqttReconnectionHandler:
|
|
|
94
94
|
"""
|
|
95
95
|
_logger.warning(f"Connection interrupted: {error}")
|
|
96
96
|
|
|
97
|
-
# Start automatic reconnection if enabled
|
|
97
|
+
# Start automatic reconnection if enabled.
|
|
98
|
+
# Also guard against stale interruption events that arrive after the
|
|
99
|
+
# connection has already been restored: these can be queued via
|
|
100
|
+
# run_coroutine_threadsafe and fire after on_connection_resumed has
|
|
101
|
+
# cancelled _reconnect_task (setting it to None), which would
|
|
102
|
+
# otherwise bypass the task-existence check and spawn a new backoff
|
|
103
|
+
# loop while the client is perfectly healthy.
|
|
98
104
|
if (
|
|
99
105
|
self.config.auto_reconnect
|
|
100
106
|
and self._enabled
|
|
101
107
|
and not self._manual_disconnect
|
|
108
|
+
and not self._is_connected_func()
|
|
102
109
|
and (not self._reconnect_task or self._reconnect_task.done())
|
|
103
110
|
):
|
|
104
111
|
_logger.info("Starting automatic reconnection...")
|
|
@@ -132,8 +139,16 @@ class MqttReconnectionHandler:
|
|
|
132
139
|
|
|
133
140
|
This is a helper method to create the reconnect task from within
|
|
134
141
|
a coroutine that's scheduled via _schedule_coroutine.
|
|
142
|
+
|
|
143
|
+
The is_connected guard is re-checked here because this coroutine may
|
|
144
|
+
be queued via run_coroutine_threadsafe and run after the connection
|
|
145
|
+
has already been restored (e.g. by on_connection_resumed cancelling
|
|
146
|
+
_reconnect_task), in which case starting a new backoff loop would
|
|
147
|
+
incorrectly tear down a healthy connection.
|
|
135
148
|
"""
|
|
136
|
-
if not self.
|
|
149
|
+
if not self._is_connected_func() and (
|
|
150
|
+
not self._reconnect_task or self._reconnect_task.done()
|
|
151
|
+
):
|
|
137
152
|
self._reconnect_task = asyncio.create_task(
|
|
138
153
|
self._reconnect_with_backoff()
|
|
139
154
|
)
|
|
@@ -189,6 +189,7 @@ tests/test_models.py
|
|
|
189
189
|
tests/test_mqtt_client_init.py
|
|
190
190
|
tests/test_mqtt_hypothesis.py
|
|
191
191
|
tests/test_mqtt_reconnection.py
|
|
192
|
+
tests/test_mqtt_reconnection_storm.py
|
|
192
193
|
tests/test_multi_device.py
|
|
193
194
|
tests/test_openei.py
|
|
194
195
|
tests/test_reservations.py
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the MQTT reconnection storm fix.
|
|
3
|
+
|
|
4
|
+
Two bugs were fixed:
|
|
5
|
+
|
|
6
|
+
Bug 1 — Stale interruption events fire after a resume clears _reconnect_task.
|
|
7
|
+
The AWS SDK fires on_connection_interrupted callbacks from background threads
|
|
8
|
+
via run_coroutine_threadsafe. When on_connection_resumed cancels and nulls
|
|
9
|
+
_reconnect_task, queued _start_reconnect_task coroutines that haven't run yet
|
|
10
|
+
see _reconnect_task=None and spawn a new _reconnect_with_backoff task even
|
|
11
|
+
though the client is now healthy.
|
|
12
|
+
|
|
13
|
+
Fix: both on_connection_interrupted and _start_reconnect_task now check
|
|
14
|
+
is_connected_func() before starting a new backoff loop.
|
|
15
|
+
|
|
16
|
+
Bug 2 — Closing the old connection inside _active_reconnect / _deep_reconnect
|
|
17
|
+
fires _on_connection_interrupted_internal from a background SDK thread.
|
|
18
|
+
This queued another _start_reconnect_task coroutine that would fire after the
|
|
19
|
+
new connection was established, tearing it down immediately.
|
|
20
|
+
|
|
21
|
+
Fix: _actively_reconnecting flag suppresses the reconnection-handler
|
|
22
|
+
delegation in _on_connection_interrupted_internal while the intentional
|
|
23
|
+
teardown is in progress.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
30
|
+
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
from nwp500.auth import (
|
|
34
|
+
AuthenticationResponse,
|
|
35
|
+
AuthTokens,
|
|
36
|
+
NavienAuthClient,
|
|
37
|
+
UserInfo,
|
|
38
|
+
)
|
|
39
|
+
from nwp500.mqtt.reconnection import MqttReconnectionHandler
|
|
40
|
+
from nwp500.mqtt.utils import MqttConnectionConfig
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Shared helpers
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _make_auth_client() -> NavienAuthClient:
|
|
48
|
+
client = NavienAuthClient("test@example.com", "password")
|
|
49
|
+
tokens = AuthTokens(
|
|
50
|
+
id_token="tok",
|
|
51
|
+
access_token="acc",
|
|
52
|
+
refresh_token="ref",
|
|
53
|
+
authentication_expires_in=3600,
|
|
54
|
+
access_key_id="key",
|
|
55
|
+
secret_key="secret",
|
|
56
|
+
session_token="sess",
|
|
57
|
+
authorization_expires_in=3600,
|
|
58
|
+
)
|
|
59
|
+
client._auth_response = AuthenticationResponse(
|
|
60
|
+
user_info=UserInfo(user_first_name="T", user_last_name="U"),
|
|
61
|
+
tokens=tokens,
|
|
62
|
+
)
|
|
63
|
+
return client
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _make_handler(
|
|
67
|
+
*,
|
|
68
|
+
connected: bool = False,
|
|
69
|
+
auto_reconnect: bool = True,
|
|
70
|
+
max_reconnect_attempts: int = -1,
|
|
71
|
+
) -> tuple[MqttReconnectionHandler, list[asyncio.Task]]:
|
|
72
|
+
"""Return a handler and a list that records every scheduled coroutine."""
|
|
73
|
+
config = MqttConnectionConfig(
|
|
74
|
+
auto_reconnect=auto_reconnect,
|
|
75
|
+
max_reconnect_attempts=max_reconnect_attempts,
|
|
76
|
+
)
|
|
77
|
+
scheduled: list[asyncio.Task] = []
|
|
78
|
+
|
|
79
|
+
def _schedule(coro): # replaces run_coroutine_threadsafe in real code
|
|
80
|
+
t = asyncio.ensure_future(coro)
|
|
81
|
+
scheduled.append(t)
|
|
82
|
+
return t
|
|
83
|
+
|
|
84
|
+
handler = MqttReconnectionHandler(
|
|
85
|
+
config=config,
|
|
86
|
+
is_connected_func=lambda: connected,
|
|
87
|
+
schedule_coroutine_func=_schedule,
|
|
88
|
+
reconnect_func=AsyncMock(),
|
|
89
|
+
deep_reconnect_func=None,
|
|
90
|
+
emit_event_func=None,
|
|
91
|
+
)
|
|
92
|
+
handler.enable()
|
|
93
|
+
return handler, scheduled
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Bug 1: on_connection_interrupted / _start_reconnect_task is_connected guard
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestReconnectionHandlerIsConnectedGuard:
|
|
102
|
+
"""Bug 1 – stale interrupt events must not start a loop when connected."""
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
105
|
+
async def test_on_connection_interrupted_does_not_start_task_when_connected(
|
|
106
|
+
self,
|
|
107
|
+
):
|
|
108
|
+
"""on_connection_interrupted is a no-op when connected."""
|
|
109
|
+
connected = True
|
|
110
|
+
config = MqttConnectionConfig(auto_reconnect=True)
|
|
111
|
+
scheduled = []
|
|
112
|
+
|
|
113
|
+
handler = MqttReconnectionHandler(
|
|
114
|
+
config=config,
|
|
115
|
+
is_connected_func=lambda: connected,
|
|
116
|
+
schedule_coroutine_func=lambda coro: scheduled.append(
|
|
117
|
+
asyncio.ensure_future(coro)
|
|
118
|
+
),
|
|
119
|
+
reconnect_func=AsyncMock(),
|
|
120
|
+
)
|
|
121
|
+
handler.enable()
|
|
122
|
+
|
|
123
|
+
handler.on_connection_interrupted(Exception("dropped"))
|
|
124
|
+
|
|
125
|
+
# Nothing should have been scheduled
|
|
126
|
+
assert scheduled == []
|
|
127
|
+
|
|
128
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
129
|
+
async def test_on_connection_interrupted_starts_task_when_disconnected(
|
|
130
|
+
self,
|
|
131
|
+
):
|
|
132
|
+
"""on_connection_interrupted schedules a task when disconnected."""
|
|
133
|
+
handler, scheduled = _make_handler(connected=False)
|
|
134
|
+
|
|
135
|
+
handler.on_connection_interrupted(Exception("dropped"))
|
|
136
|
+
|
|
137
|
+
assert len(scheduled) == 1
|
|
138
|
+
# Clean up
|
|
139
|
+
scheduled[0].cancel()
|
|
140
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
143
|
+
async def test_start_reconnect_task_no_op_when_connected(self):
|
|
144
|
+
"""_start_reconnect_task must not create a Task when connected."""
|
|
145
|
+
connected = True
|
|
146
|
+
config = MqttConnectionConfig(auto_reconnect=True)
|
|
147
|
+
|
|
148
|
+
handler = MqttReconnectionHandler(
|
|
149
|
+
config=config,
|
|
150
|
+
is_connected_func=lambda: connected,
|
|
151
|
+
schedule_coroutine_func=lambda coro: asyncio.ensure_future(coro),
|
|
152
|
+
reconnect_func=AsyncMock(),
|
|
153
|
+
)
|
|
154
|
+
handler.enable()
|
|
155
|
+
|
|
156
|
+
await handler._start_reconnect_task()
|
|
157
|
+
|
|
158
|
+
assert handler._reconnect_task is None
|
|
159
|
+
|
|
160
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
161
|
+
async def test_start_reconnect_task_creates_task_when_disconnected(self):
|
|
162
|
+
"""_start_reconnect_task creates a Task when genuinely disconnected."""
|
|
163
|
+
handler, _ = _make_handler(connected=False)
|
|
164
|
+
|
|
165
|
+
await handler._start_reconnect_task()
|
|
166
|
+
|
|
167
|
+
assert handler._reconnect_task is not None
|
|
168
|
+
assert not handler._reconnect_task.done()
|
|
169
|
+
|
|
170
|
+
handler._reconnect_task.cancel()
|
|
171
|
+
await asyncio.gather(handler._reconnect_task, return_exceptions=True)
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
174
|
+
async def test_stale_interrupt_after_resume_does_not_spawn_extra_task(self):
|
|
175
|
+
"""
|
|
176
|
+
Simulate the race that caused the reconnection storm:
|
|
177
|
+
|
|
178
|
+
1. Connection drops → on_connection_interrupted schedules
|
|
179
|
+
_start_reconnect_task (coroutine A queued but not yet run).
|
|
180
|
+
2. Connection resumes → on_connection_resumed cancels task, sets
|
|
181
|
+
_reconnect_task = None.
|
|
182
|
+
3. Coroutine A finally runs – without the fix it would see
|
|
183
|
+
_reconnect_task=None and create a new backoff loop.
|
|
184
|
+
"""
|
|
185
|
+
state = {"connected": False}
|
|
186
|
+
config = MqttConnectionConfig(auto_reconnect=True)
|
|
187
|
+
scheduled = []
|
|
188
|
+
|
|
189
|
+
handler = MqttReconnectionHandler(
|
|
190
|
+
config=config,
|
|
191
|
+
is_connected_func=lambda: state["connected"],
|
|
192
|
+
schedule_coroutine_func=lambda coro: scheduled.append(
|
|
193
|
+
asyncio.ensure_future(coro)
|
|
194
|
+
),
|
|
195
|
+
reconnect_func=AsyncMock(),
|
|
196
|
+
)
|
|
197
|
+
handler.enable()
|
|
198
|
+
|
|
199
|
+
# Step 1: connection drops, schedule the coroutine but don't run it yet
|
|
200
|
+
handler.on_connection_interrupted(Exception("dropped"))
|
|
201
|
+
assert len(scheduled) == 1
|
|
202
|
+
|
|
203
|
+
# Step 2: connection resumes before the scheduled coroutine runs
|
|
204
|
+
state["connected"] = True
|
|
205
|
+
handler.on_connection_resumed(return_code=0, session_present=False)
|
|
206
|
+
assert handler._reconnect_task is None
|
|
207
|
+
|
|
208
|
+
# Step 3: the stale coroutine from step 1 runs now
|
|
209
|
+
await scheduled[0]
|
|
210
|
+
|
|
211
|
+
# With the fix, no new task must have been created
|
|
212
|
+
assert handler._reconnect_task is None
|
|
213
|
+
|
|
214
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
215
|
+
async def test_multiple_simultaneous_interrupts_create_only_one_task(self):
|
|
216
|
+
"""
|
|
217
|
+
Multiple concurrent on_connection_interrupted calls (from different
|
|
218
|
+
SDK threads) must not spawn more than one backoff task.
|
|
219
|
+
"""
|
|
220
|
+
handler, scheduled = _make_handler(connected=False)
|
|
221
|
+
|
|
222
|
+
# Simulate three rapid interruption callbacks
|
|
223
|
+
for _ in range(3):
|
|
224
|
+
handler.on_connection_interrupted(Exception("dropped"))
|
|
225
|
+
|
|
226
|
+
# Let all the scheduled coroutines run
|
|
227
|
+
await asyncio.gather(*scheduled, return_exceptions=True)
|
|
228
|
+
|
|
229
|
+
# Only one _reconnect_with_backoff task should exist
|
|
230
|
+
assert handler._reconnect_task is not None
|
|
231
|
+
handler._reconnect_task.cancel()
|
|
232
|
+
await asyncio.gather(handler._reconnect_task, return_exceptions=True)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Bug 2: _actively_reconnecting suppresses spurious interrupt callbacks
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TestActivelyReconnectingFlag:
|
|
241
|
+
"""Bug 2 – old connection teardown must not trigger a new backoff loop."""
|
|
242
|
+
|
|
243
|
+
def _make_mqtt_client(self) -> NavienMqttClient: # noqa: F821
|
|
244
|
+
from nwp500.mqtt import NavienMqttClient
|
|
245
|
+
|
|
246
|
+
return NavienMqttClient(_make_auth_client())
|
|
247
|
+
|
|
248
|
+
def test_actively_reconnecting_initialises_false(self):
|
|
249
|
+
"""_actively_reconnecting starts False."""
|
|
250
|
+
client = self._make_mqtt_client()
|
|
251
|
+
assert client._actively_reconnecting is False
|
|
252
|
+
|
|
253
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
254
|
+
async def test_interrupted_internal_skips_handler_when_flag_set( # noqa: E501
|
|
255
|
+
self,
|
|
256
|
+
):
|
|
257
|
+
"""
|
|
258
|
+
While _actively_reconnecting is True (old connection being closed),
|
|
259
|
+
_on_connection_interrupted_internal must NOT forward the event to the
|
|
260
|
+
reconnection handler – preventing a competing backoff task.
|
|
261
|
+
"""
|
|
262
|
+
from awscrt.exceptions import AwsCrtError
|
|
263
|
+
|
|
264
|
+
client = self._make_mqtt_client()
|
|
265
|
+
|
|
266
|
+
mock_handler = MagicMock()
|
|
267
|
+
client._reconnection_handler = mock_handler
|
|
268
|
+
client.config = MqttConnectionConfig(auto_reconnect=True)
|
|
269
|
+
|
|
270
|
+
client._actively_reconnecting = True # flag is set
|
|
271
|
+
|
|
272
|
+
error = AwsCrtError(
|
|
273
|
+
code=0,
|
|
274
|
+
name="AWS_ERROR_MQTT_UNEXPECTED_HANGUP",
|
|
275
|
+
message="hangup",
|
|
276
|
+
)
|
|
277
|
+
client._on_connection_interrupted_internal(
|
|
278
|
+
connection=MagicMock(), error=error
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# The handler must NOT have been notified
|
|
282
|
+
mock_handler.on_connection_interrupted.assert_not_called()
|
|
283
|
+
|
|
284
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
285
|
+
async def test_on_connection_interrupted_internal_forwards_when_flag_clear(
|
|
286
|
+
self,
|
|
287
|
+
):
|
|
288
|
+
"""
|
|
289
|
+
When _actively_reconnecting is False (genuine drop), the event IS
|
|
290
|
+
forwarded to the reconnection handler.
|
|
291
|
+
"""
|
|
292
|
+
from awscrt.exceptions import AwsCrtError
|
|
293
|
+
|
|
294
|
+
client = self._make_mqtt_client()
|
|
295
|
+
|
|
296
|
+
mock_handler = MagicMock()
|
|
297
|
+
client._reconnection_handler = mock_handler
|
|
298
|
+
client.config = MqttConnectionConfig(auto_reconnect=True)
|
|
299
|
+
|
|
300
|
+
client._actively_reconnecting = False # flag is clear (default)
|
|
301
|
+
|
|
302
|
+
error = AwsCrtError(
|
|
303
|
+
code=0,
|
|
304
|
+
name="AWS_ERROR_MQTT_UNEXPECTED_HANGUP",
|
|
305
|
+
message="hangup",
|
|
306
|
+
)
|
|
307
|
+
client._on_connection_interrupted_internal(
|
|
308
|
+
connection=MagicMock(), error=error
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
mock_handler.on_connection_interrupted.assert_called_once_with(error)
|
|
312
|
+
|
|
313
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
314
|
+
async def test_active_reconnect_sets_and_clears_flag_on_success(self):
|
|
315
|
+
"""_active_reconnect sets the flag, does its work, then clears it."""
|
|
316
|
+
client = self._make_mqtt_client()
|
|
317
|
+
client._connected = False
|
|
318
|
+
client._loop = asyncio.get_running_loop()
|
|
319
|
+
|
|
320
|
+
flag_during = []
|
|
321
|
+
|
|
322
|
+
async def fake_reconnect():
|
|
323
|
+
flag_during.append(client._actively_reconnecting)
|
|
324
|
+
client._connected = True
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
mock_conn_mgr = AsyncMock()
|
|
328
|
+
mock_conn_mgr.close = AsyncMock()
|
|
329
|
+
mock_conn_mgr.connect = AsyncMock(side_effect=fake_reconnect)
|
|
330
|
+
mock_conn_mgr.connection = MagicMock()
|
|
331
|
+
client._connection_manager = mock_conn_mgr
|
|
332
|
+
|
|
333
|
+
with patch(
|
|
334
|
+
"nwp500.mqtt.client.MqttConnection", return_value=mock_conn_mgr
|
|
335
|
+
):
|
|
336
|
+
client._auth_client.ensure_valid_token = AsyncMock()
|
|
337
|
+
client._subscription_manager = None
|
|
338
|
+
await client._active_reconnect()
|
|
339
|
+
|
|
340
|
+
assert flag_during == [True], "Flag must be True while reconnecting"
|
|
341
|
+
assert not client._actively_reconnecting, "Flag must be cleared after"
|
|
342
|
+
|
|
343
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
344
|
+
async def test_active_reconnect_clears_flag_on_exception(self):
|
|
345
|
+
"""_active_reconnect clears the flag even on exception."""
|
|
346
|
+
from awscrt.exceptions import AwsCrtError
|
|
347
|
+
|
|
348
|
+
client = self._make_mqtt_client()
|
|
349
|
+
client._connected = False
|
|
350
|
+
client._loop = asyncio.get_running_loop()
|
|
351
|
+
|
|
352
|
+
mock_conn_mgr = AsyncMock()
|
|
353
|
+
mock_conn_mgr.close = AsyncMock()
|
|
354
|
+
mock_conn_mgr.connect = AsyncMock(
|
|
355
|
+
side_effect=AwsCrtError(
|
|
356
|
+
code=0, name="AWS_ERROR_MQTT_UNEXPECTED_HANGUP", message="fail"
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
client._connection_manager = mock_conn_mgr
|
|
360
|
+
|
|
361
|
+
with patch(
|
|
362
|
+
"nwp500.mqtt.client.MqttConnection", return_value=mock_conn_mgr
|
|
363
|
+
):
|
|
364
|
+
client._auth_client.ensure_valid_token = AsyncMock()
|
|
365
|
+
with pytest.raises(AwsCrtError):
|
|
366
|
+
await client._active_reconnect()
|
|
367
|
+
|
|
368
|
+
assert not client._actively_reconnecting, "must be cleared on error"
|
|
369
|
+
"""
|
|
370
|
+
A second concurrent call to _active_reconnect while the first is
|
|
371
|
+
still running must return immediately without making changes.
|
|
372
|
+
"""
|
|
373
|
+
client = self._make_mqtt_client()
|
|
374
|
+
client._connected = False
|
|
375
|
+
client._loop = asyncio.get_running_loop()
|
|
376
|
+
client._actively_reconnecting = True # Simulate first call in progress
|
|
377
|
+
|
|
378
|
+
# No connection manager – if we got past the guard we'd crash
|
|
379
|
+
client._connection_manager = None
|
|
380
|
+
|
|
381
|
+
# Should return immediately without touching the connection
|
|
382
|
+
await client._active_reconnect() # Must not raise
|
|
383
|
+
|
|
384
|
+
# State unchanged
|
|
385
|
+
assert client._actively_reconnecting is True
|
|
386
|
+
assert not client._connected
|
|
387
|
+
|
|
388
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
389
|
+
async def test_deep_reconnect_sets_and_clears_flag(self):
|
|
390
|
+
"""_deep_reconnect also sets and clears _actively_reconnecting."""
|
|
391
|
+
client = self._make_mqtt_client()
|
|
392
|
+
client._connected = False
|
|
393
|
+
client._loop = asyncio.get_running_loop()
|
|
394
|
+
|
|
395
|
+
flag_during = []
|
|
396
|
+
|
|
397
|
+
async def fake_connect():
|
|
398
|
+
flag_during.append(client._actively_reconnecting)
|
|
399
|
+
client._connected = True
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
mock_conn_mgr = AsyncMock()
|
|
403
|
+
mock_conn_mgr.close = AsyncMock()
|
|
404
|
+
mock_conn_mgr.connect = AsyncMock(side_effect=fake_connect)
|
|
405
|
+
mock_conn_mgr.connection = MagicMock()
|
|
406
|
+
client._connection_manager = mock_conn_mgr
|
|
407
|
+
|
|
408
|
+
with patch(
|
|
409
|
+
"nwp500.mqtt.client.MqttConnection", return_value=mock_conn_mgr
|
|
410
|
+
):
|
|
411
|
+
client._auth_client.ensure_valid_token = AsyncMock()
|
|
412
|
+
client._auth_client.current_tokens.refresh_token = "ref"
|
|
413
|
+
client._auth_client.refresh_token = AsyncMock()
|
|
414
|
+
client._subscription_manager = None
|
|
415
|
+
await client._deep_reconnect()
|
|
416
|
+
|
|
417
|
+
assert flag_during == [True], "Flag True while deep-reconnecting"
|
|
418
|
+
assert not client._actively_reconnecting, "Flag must be cleared after"
|
|
419
|
+
|
|
420
|
+
@pytest.mark.asyncio(loop_scope="function")
|
|
421
|
+
async def test_deep_reconnect_clears_flag_on_exception(self):
|
|
422
|
+
"""_deep_reconnect clears the flag even when an exception is raised."""
|
|
423
|
+
from awscrt.exceptions import AwsCrtError
|
|
424
|
+
|
|
425
|
+
client = self._make_mqtt_client()
|
|
426
|
+
client._connected = False
|
|
427
|
+
client._loop = asyncio.get_running_loop()
|
|
428
|
+
|
|
429
|
+
mock_conn_mgr = AsyncMock()
|
|
430
|
+
mock_conn_mgr.close = AsyncMock()
|
|
431
|
+
mock_conn_mgr.connect = AsyncMock(
|
|
432
|
+
side_effect=AwsCrtError(
|
|
433
|
+
code=0, name="AWS_ERROR_MQTT_UNEXPECTED_HANGUP", message="fail"
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
client._connection_manager = mock_conn_mgr
|
|
437
|
+
|
|
438
|
+
with patch(
|
|
439
|
+
"nwp500.mqtt.client.MqttConnection", return_value=mock_conn_mgr
|
|
440
|
+
):
|
|
441
|
+
client._auth_client.ensure_valid_token = AsyncMock()
|
|
442
|
+
client._auth_client.current_tokens.refresh_token = "ref"
|
|
443
|
+
client._auth_client.refresh_token = AsyncMock()
|
|
444
|
+
with pytest.raises(AwsCrtError):
|
|
445
|
+
await client._deep_reconnect()
|
|
446
|
+
|
|
447
|
+
assert not client._actively_reconnecting, "must be cleared on error"
|
|
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
|